// contract_test.go:信封 golden JSON 测试(A6.1:字段与 mcp-usage 一致性 + 空数组纪律 + +08:00)。 package contract import ( "encoding/json" "strings" "testing" "time" "onesvm.com/onesvm/browser-server/internal/config" ) // TestTimeMarshalPlus08 时间序列化恒带 +08:00。 func TestTimeMarshalPlus08(t *testing.T) { // 输入 UTC 时间,序列化后必须是 +08:00 偏移 u := time.Date(2026, 9, 1, 3, 41, 15, 0, time.UTC) // 北京 11:41:15 b, err := json.Marshal(NewTime(u)) if err != nil { t.Fatal(err) } got := string(b) if !strings.Contains(got, "+08:00") { t.Fatalf("时间序列化缺 +08:00: %s", got) } if got != `"2026-09-01T11:41:15+08:00"` { t.Fatalf("时间序列化不符: %s", got) } } // TestTimeUnmarshalNorm 反序列化任意时区输入都归一到 +08:00。 func TestTimeUnmarshalNorm(t *testing.T) { var tt Time in := `"2026-09-01T02:00:00Z"` if err := json.Unmarshal([]byte(in), &tt); err != nil { t.Fatal(err) } out, _ := json.Marshal(tt) if !strings.Contains(string(out), "+08:00") { t.Fatalf("反序列化后未归一 +08:00: %s", out) } } // goldenSearchResponse mcp-usage §2.1 响应形状基准。 const goldenSearchResponse = `{"ok":true,"kind":"search","request_id":"01K3TEST","took_ms":633,` + `"usage":{"credits":1,"engine":"searxng-cn","tokens_estimate":350},` + `"provenance":{"retrieved_at":"2026-09-01T11:41:15+08:00","adapter":"searxng-cn","proxy_exit":"none","cached":false},` + `"error":null,"query":"跨境电商 政策","answer":null,"results":[]}` // TestEnvelopeSearchGolden 空结果必须是 [] 而非 null;字段名与 mcp-usage §2.1 一致。 func TestEnvelopeSearchGolden(t *testing.T) { env := Envelope{ OK: true, Kind: "search", RequestID: "01K3TEST", TookMs: 633, Usage: Usage{Credits: 1, Engine: "searxng-cn", TokensEstimate: 350}, Provenance: Provenance{ RetrievedAt: NewTime(time.Date(2026, 9, 1, 11, 41, 15, 0, config.TZ)), Adapter: "searxng-cn", ProxyExit: "none", Cached: false, }, Error: nil, Data: &SearchPayload{ Query: "跨境电商 政策", Answer: nil, Results: nil, // 关键:nil 也必须出 [] }, } b, err := json.Marshal(env) if err != nil { t.Fatal(err) } got := string(b) if got != goldenSearchResponse { t.Fatalf("golden 不符:\n got: %s\nwant: %s", got, goldenSearchResponse) } if strings.Contains(got, `"results":null`) { t.Fatal("results 为 null,违反空数组纪律") } } // TestEnvelopeReadShape read 字段齐备性(mcp-usage §2.2)。 func TestEnvelopeReadShape(t *testing.T) { md := "正文" env := Envelope{ OK: true, Kind: "read", RequestID: "r1", Data: &ReadPayload{ URL: "https://example.com", FinalURL: "https://example.com", Title: "标题", Markdown: md, CharCount: len([]rune(md)), Metadata: ReadMetadata{StatusCode: 200, ContentType: "text/html", RetrievedAt: NowTime()}, // Links/Images/Warnings 全 nil:必须序列化为 [] }, } b, err := json.Marshal(env) if err != nil { t.Fatal(err) } s := string(b) for _, want := range []string{`"links":[]`, `"images":[]`, `"warnings":[]`, `"markdown":"正文"`, `"truncated":false`, `"screenshot_url":null`, `"extracted":null`} { if !strings.Contains(s, want) { t.Fatalf("read 信封缺 %s: %s", want, s) } } if strings.Contains(s, `"links":null`) || strings.Contains(s, `"images":null`) { t.Fatal("links/images 为 null,违反空数组纪律") } } // TestErrBodyHTTPStatus 错误码 → HTTP 状态映射(mcp-usage §3 表)。 func TestErrBodyHTTPStatus(t *testing.T) { cases := map[string]int{ CodeRateLimited: 429, CodeQuota: 402, CodeDenied: 403, CodeUnauthorized: 401, CodeUnavailable: 503, CodeTimeout: 504, CodeUpstream: 502, CodeBlocked: 200, CodeExtractFailed: 200, } for code, want := range cases { if got := (ErrBody{Code: code}).HTTPStatus(); got != want { t.Errorf("code=%s got=%d want=%d", code, got, want) } } } // TestRetryable 瞬时错误判定(仅 timeout/upstream)。 func TestRetryable(t *testing.T) { if !Retryable(CodeTimeout) || !Retryable(CodeUpstream) { t.Fatal("timeout/upstream 应可重试") } for _, c := range []string{CodeDenied, CodeQuota, CodeRateLimited, CodeBlocked} { if Retryable(c) { t.Errorf("code=%s 不应可重试", c) } } } // TestCapsMatch 能力路由标签匹配。 func TestCapsMatch(t *testing.T) { c := Caps{Intents: []string{"search", "read"}, Render: RenderLight, Regions: []string{"domestic", "overseas"}} if err := c.Match("search", RegionOverseas, RenderNone); err != nil { t.Fatalf("应满足: %v", err) } if err := c.Match("search", RegionOverseas, RenderFull); err == nil { t.Fatal("light 适配器不应满足 full 需求") } c2 := Caps{Intents: []string{"search"}, Render: RenderNone, Regions: []string{"domestic"}} if err := c2.Match("read", RegionOverseas, RenderNone); err == nil { t.Fatal("不支持 read+overseas 的适配器应被拒") } } // TestValidRegion region 校验与默认。 func TestValidRegion(t *testing.T) { if r, _ := ValidRegion(""); r != RegionDomestic { t.Fatal("空 region 应默认 domestic") } if _, err := ValidRegion("mars"); err == nil { t.Fatal("非法 region 应报错") } }