onesvm-browser-server/server/internal/contract/contract_test.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

166 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 应报错")
}
}