onesvm-browser-server/server/internal/contract/contract_test.go
chii 347c94c121 fix: 搜索缓存往返保留 results,并加检索源规则过滤
缓存命中改写信封时补 UnmarshalJSON,避免 results 被抹空;零结果不进 300s 缓存。模版层对 SearXNG hits 做广告标记丢弃与同域限额 1 条,减轻 1688 霸屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 11:33:13 +08:00

202 lines
6.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,违反空数组纪律")
}
}
// TestEnvelopeSearchRoundtrip 反序列化必须还原 Data,再 Marshal 不得丢掉 results。
func TestEnvelopeSearchRoundtrip(t *testing.T) {
src := Envelope{
OK: true, Kind: "search", RequestID: "r-rt", TookMs: 12,
Usage: Usage{Credits: 1, Engine: "searxng-cn"},
Data: &SearchPayload{
Query: "往返",
Results: []SearchResult{{
ID: "r1", Title: "标题", URL: "https://example.com", Content: "摘要", Score: 0.9, Engine: "bing",
}},
},
}
b, err := json.Marshal(src)
if err != nil {
t.Fatal(err)
}
var got Envelope
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
p, ok := got.Data.(*SearchPayload)
if !ok || p == nil || len(p.Results) != 1 || p.Results[0].Title != "标题" {
t.Fatalf("往返丢掉 results: %#v", got.Data)
}
b2, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
var wire struct {
Results []SearchResult `json:"results"`
}
if err := json.Unmarshal(b2, &wire); err != nil || len(wire.Results) != 1 {
t.Fatalf("再序列化丢掉 results: %s", b2)
}
}
// 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 应报错")
}
}