diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt index 66b111b..0de5ca0 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt @@ -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 { diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoRealPayloadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoRealPayloadTest.kt new file mode 100644 index 0000000..891925a --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoRealPayloadTest.kt @@ -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")) + } +}