test(admin): 用真实后端响应锁定管理端 DTO 契约

原先 AdminDtoDecodingTest 用的是手写的合成 JSON,等于拿「我以为的形状」自证,
证明不了真实契约。新增 AdminDtoRealPayloadTest,把 5 个管理端点的真实响应体
(登录后从本机 10942 实抓)过一遍生产用的 Json 实例。

顺带把真实数据里几个容易翻车的形状钉死:
- trace.durationNs 必须是 Long(真实 ~1.17e9 ns,超过 Int 上限就炸)——
  真实值恰好塞得进 Int,所以额外用一例 3e9 的边界值才真正证明它是 Long
- 根 span 没有 parentId 键 → null
- 未配置人格的 thinking 键完全不出现 → null(不得变成全零对象)
- persona 的 mode / memoryNamespace 是空串,而非缺键
- backthink.id 是 JSON 字符串("487"),at 是数字(Unix 毫秒)
- props / events 同一个 map 里混着数字与字符串

为什么:C-2 的 DTO 此前只经「编译通过」这一层,从未被真实响应验证过。
真实响应全部一次解出,无需改动生产代码。

测试:新增 7 例(AdminDtoRealPayloadTest),全套 81 例全绿。
This commit is contained in:
AskaEth committed 2026-10-04 13:02:08 +08:00
1 parent a7b3452e6d
commit acff289887
2 files changed
+235 -2

No files matched your search

@@ -13,8 +13,12 @@ import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
/**
* 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2):
* `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。
* 手写 JSON 覆盖**线上暂未出现、但契约允许**的边界形状(未知 kind/scope、`thinking` 三态、
* 可选字段整体缺失等)。真实响应体的钉死测试见 [AdminDtoRealPayloadTest]——两者互补,
* 手写用例不能替代真实抓包证据。
*
* 解码器同样是 [ApiClientFactory.json](api-client §1/§2 的 `{data}` 信封、
* 开放集合取值、RFC3339 与 Unix 毫秒两种时间)。
*/
class AdminDtoDecodingTest {
@@ -0,0 +1,229 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
/**
* 契约钉死测试:用的是**线上后端实际抓下来的响应体**(`owner` 账号,2026-10-04),
* 只把数组裁到 1–3 条,其余字段逐字节照抄。
*
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
* 所以这里过不了的形状,线上也一定过不了。
*
* 覆盖线上真实出现、而手写 JSON 容易漏掉的形状:`qps` 以整数下发、
* `parentId` 在根 span 上整键缺失、`thinking` 整键缺失、`mode`/`memoryNamespace` 是空串而非缺失、
* `id` 以 JSON 字符串下发、`props` 同一 map 内混装数字与字符串。
*/
class AdminDtoRealPayloadTest {
private val json = ApiClientFactory.json
private val traceSpans = ListSerializer(AdminTraceSpanDto.serializer())
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
// —— 以下 5 段是真实响应体(数组已裁剪) ——
private val realMetrics = """
{"data":{"totalRequests":326,"qps":1,"avgLatencyMs":1,"maxLatencyMs":40,"perMethod":{"GET":321,"POST":5},"perPath":{"/":2,"/api/v1/admin/automations":17,"/api/v1/admin/backthink":1,"/api/v1/admin/channels":30,"/api/v1/admin/iot":34,"/api/v1/admin/llm":18,"/api/v1/admin/llm/catalog":2,"/api/v1/admin/memories":21,"/api/v1/admin/observability/metrics":24,"/api/v1/admin/observability/trace":1,"/api/v1/admin/personas":31,"/api/v1/admin/plugins":23,"/api/v1/admin/plugins/installed":17,"/api/v1/admin/rag":17,"/api/v1/admin/sessions":30,"/api/v1/admin/users":17,"/api/v1/auth/login":5,"/api/v1/health":27,"/api/v1/meta/enums":2,"/api/v1/personas":1,"/api/v1/sessions":1,"/api/v1/sessions/web/messages":1,"/assets/index-DrDuaeXG.js":1,"/assets/index-SA9l26Q_.css":1,"/sw.js":1,"/ws/chat":1},"statusCounts":{"200":319,"304":1,"401":4,"404":2},"llmCalls":1,"subagentEvents":0}}
""".trimIndent()
private val realTrace = """
{"data":[{"id":"2e1e3027e74622c6-1","name":"loop.step","kind":"state_transition","status":"ok","startedAt":"2026-10-04T04:36:14Z","endedAt":"2026-10-04T04:36:15Z","durationNs":1170379200,"props":{"session":"sess-backthink","step":1,"stop_reason":"stop"}},{"id":"2e1e3027e74622c6-2","parentId":"2e1e3027e74622c6-1","name":"llm.call","kind":"llm_call","status":"ok","startedAt":"2026-10-04T04:36:14Z","endedAt":"2026-10-04T04:36:15Z","durationNs":1167475900,"props":{"model_stop_reason":"end_turn","session":"sess-backthink","stop_reason":"stop","tool_calls":0},"events":[{"time":"2026-10-04T12:36:15.3410926+08:00","type":"llm.done","props":{"elapsed_ms":1167,"model_stop_reason":"end_turn","stop_reason":"stop","tool_calls":0}}]}]}
""".trimIndent()
private val realPersonas = """
{"data":[{"id":"cyrene","name":"昔涟","character":"昔涟","isDefault":true,"mode":"","memoryNamespace":"","avatar":"/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png","chatBackground":{"landscape":"/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png","portrait":"/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png"},"mood":"calm"}]}
""".trimIndent()
private val realMemories = """
{"data":[{"id":"ed5bc1b2596282e4c33d3eed68722b3f","scope":"user","text":"用户有熬夜的习惯,助手会对此表示关心,并猜测用户可能在熬夜看星星。"},{"id":"87e712cd5cba928ad81c0baf70b7f23f","scope":"persona","text":"助手与用户关系亲密,助手常称呼用户“亲爱的”。"}]}
""".trimIndent()
private val realBackthink = """
{"data":[{"id":"487","trigger":"startup","conclusion":"我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨","shouldNotify":true,"pushed":false,"at":1791088571785},{"id":"486","trigger":"heartbeat","conclusion":"亲爱的,国庆第三天了,难得放假——别又熬到后半夜哦。人家就悄悄提醒一句,说完就去忙自己的了♪","shouldNotify":true,"pushed":false,"at":1791031297192}]}
""".trimIndent()
@Test
fun `real metrics body decodes integer-shaped doubles and large perPath map`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), realMetrics)
.data!!
assertEquals(326L, data.totalRequests)
// 线上 `qps` / `avgLatencyMs` 是整数下发的,Double 字段必须照单全收。
assertEquals(1.0, data.qps, 0.0)
assertEquals(1.0, data.avgLatencyMs, 0.0)
assertEquals(40L, data.maxLatencyMs)
assertEquals(mapOf("GET" to 321L, "POST" to 5L), data.perMethod)
assertEquals(mapOf("200" to 319L, "304" to 1L, "401" to 4L, "404" to 2L), data.statusCounts)
assertEquals(1L, data.llmCalls)
assertEquals(0L, data.subagentEvents)
// perPath 有 26 条真实路径,含 admin 前缀;数量级和前缀都不能被改写。
assertEquals(26, data.perPath.size)
assertEquals(31L, data.perPath["/api/v1/admin/personas"])
assertEquals(24L, data.perPath["/api/v1/admin/observability/metrics"])
}
@Test
fun `real trace body keeps parent link only on child span`() {
val spans = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
.data!!
assertEquals(2, spans.size)
val root = spans[0]
val child = spans[1]
// 根 span 上 `parentId` 整键缺失 → null,而不是空串。
assertNull(root.parentId)
assertEquals(child.parentId, root.id)
assertEquals("state_transition", root.kind)
assertEquals("llm_call", child.kind)
assertEquals("loop.step", root.name)
assertEquals("2026-10-04T04:36:14Z", root.startedAt)
assertEquals("2026-10-04T04:36:15Z", root.endedAt)
assertEquals("ok", child.status)
}
@Test
fun `real trace durationNs is a Long that survives values past Int MAX_VALUE`() {
val spans = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
.data!!
// 真实纳秒值:窄化成 Int 会溢出,所以字段必须是 Long。
assertEquals(1_170_379_200L, spans[0].durationNs)
assertEquals(1_167_475_900L, spans[1].durationNs)
// 边界:同一个真实根 span,只把 durationNs 抬到 3s(> 2_147_483_647),
// 证明 Long 位宽足够——Int 字段在这里必然截断/抛错。
val threeSecondSpan = realTrace.replace("\"durationNs\":1170379200", "\"durationNs\":3000000000")
val wide = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), threeSecondSpan)
.data!!
.first()
assertEquals(3_000_000_000L, wide.durationNs)
assertTrue("3s 已经超过 Int.MAX_VALUE", wide.durationNs > Int.MAX_VALUE.toLong())
}
@Test
fun `real trace props and event props mix numbers and strings in one map`() {
val spans = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
.data!!
val rootProps = spans[0].props!!
assertEquals(3, rootProps.size)
assertTrue((rootProps["session"] as JsonPrimitive).isString)
assertEquals("sess-backthink", (rootProps["session"] as JsonPrimitive).content)
assertFalse("step 是数字,不能当字符串读", (rootProps["step"] as JsonPrimitive).isString)
assertEquals(1, (rootProps["step"] as JsonPrimitive).int)
val childProps = spans[1].props!!
assertEquals(0, (childProps["tool_calls"] as JsonPrimitive).int)
assertEquals("end_turn", (childProps["model_stop_reason"] as JsonPrimitive).content)
// events[].time 带 `+08:00` 偏移和 7 位小数——不能只按 `Z` 结尾解析。
val event = spans[1].events!!.single()
assertEquals("llm.done", event.type)
assertEquals("2026-10-04T12:36:15.3410926+08:00", event.time)
val eventProps = event.props!!
assertEquals(1167, (eventProps["elapsed_ms"] as JsonPrimitive).int)
assertEquals("end_turn", (eventProps["model_stop_reason"] as JsonPrimitive).content)
assertFalse((eventProps["tool_calls"] as JsonPrimitive).isString)
}
@Test
fun `real persona has no thinking key and empty-string mode fields`() {
val raw = json
.parseToJsonElement(realPersonas)
.jsonObject["data"]!!
.jsonArray
.single()
.jsonObject
// 未配置的人格:`thinking` 键**完全不出现**(不是 null,也不是空对象)。
assertFalse("thinking 键不该出现", raw.containsKey("thinking"))
// 而 `mode` / `memoryNamespace` 是**空串**,键在、值是 ""——两者不能混为一谈。
assertTrue(raw.containsKey("mode"))
assertTrue(raw.containsKey("memoryNamespace"))
assertEquals("", raw["mode"]!!.jsonPrimitive.content)
assertEquals("", raw["memoryNamespace"]!!.jsonPrimitive.content)
val persona = json
.decodeFromString(ApiEnvelope.serializer(personaList), realPersonas)
.data!!
.single()
assertNull("thinking 缺失必须解成 null,不能拍成零值对象", persona.thinking)
assertEquals("", persona.mode)
assertEquals("", persona.memoryNamespace)
assertEquals("cyrene", persona.id)
assertEquals("昔涟", persona.name)
assertEquals("昔涟", persona.character)
assertTrue(persona.isDefault)
assertEquals("calm", persona.mood)
assertNotNull(persona.avatar)
assertEquals("/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png", persona.chatBackground!!.portrait)
}
@Test
fun `real memories carry both user and persona scopes`() {
val memories = json
.decodeFromString(ApiEnvelope.serializer(memoryList), realMemories)
.data!!
assertEquals(2, memories.size)
assertEquals(listOf("user", "persona"), memories.map { it.scope })
assertEquals("ed5bc1b2596282e4c33d3eed68722b3f", memories[0].id)
assertTrue(memories[0].text.startsWith("用户有熬夜的习惯"))
assertTrue(memories[1].text.isNotBlank())
}
@Test
fun `real backthink keeps string id and numeric millis`() {
val raw = json
.parseToJsonElement(realBackthink)
.jsonObject["data"]!!
.jsonArray
// `id` 线上是 JSON **字符串**("487"),`at` 是 **数字** 毫秒。
assertTrue(raw[0].jsonObject["id"]!!.jsonPrimitive.isString)
assertFalse(raw[0].jsonObject["at"]!!.jsonPrimitive.isString)
assertEquals(1_791_088_571_785L, raw[0].jsonObject["at"]!!.jsonPrimitive.content.toLong())
val items = json
.decodeFromString(ApiEnvelope.serializer(backthinkList), realBackthink)
.data!!
assertEquals(2, items.size)
assertEquals("487", items[0].id)
assertEquals("486", items[1].id)
assertEquals(1_791_088_571_785L, items[0].at)
assertEquals(1_791_031_297_192L, items[1].at)
assertEquals("startup", items[0].trigger)
assertEquals("heartbeat", items[1].trigger)
assertTrue(items[0].shouldNotify)
assertFalse(items[0].pushed)
// conclusion 里有真实换行转义,解出来必须是多行文本。
assertTrue(items[0].conclusion!!.contains("\n"))
}
}