单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
5.2 KiB
Go
166 lines
5.2 KiB
Go
// 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 应报错")
|
||
}
|
||
}
|