package search import ( "context" "errors" "net/http" "strings" "testing" ) // 本文件钉住秘塔搜索 provider 的**请求面**、**宽松解析**与**错误映射** // (依据:DevDocs/reference/search-providers/metaso 的官方 JS 产物 + 本机无 key 实测)。 // // ⚠️ 与其它家不同的一条前提:**响应 schema 只有第三方来源、且三方互相打架** // (条目级 `date` vs `displayDate`;顶层 `webpages` 无官方来源)⇒ 本家的解析是**宽松解析** // (多字段名回退),用例也只能钉住「这些候选键都能出结果」与「字段全缺时不 panic」, // **不能**钉住「官方就是这些字段」。拿到真 key 后必须按真实报文收窄。 // // 全部打 httptest,**不打真实网络**(无 API KEY ⇒ 成功响应真机未实测;错误码只确证了 2005)。 // metasoOK 是第三方实测形状的应答(`third-party-response-schema.md` 的 zod schema 逐字段照抄: // 顶层**没有 data 信封**、`score` 是字符串、`rawContent` 是开启抓原文后的正文)。 const metasoOK = `{ "credits": 3, "searchParameters": {"q": "昔涟", "size": 10, "includeSummary": false}, "webpages": [ {"title": "昔涟是什么", "link": "https://m.example/1", "score": "0.93", "snippet": "秘塔摘要", "position": 1, "rawContent": "秘塔正文原文", "date": "2025-06-01"}, {"title": "第二条", "link": "https://m.example/2", "position": 2, "snippet": "第二摘要"} ], "total": 2 }` // TestMetasoRequestShape 钉住请求面:POST `/api/v1/search` + Bearer + Accept,请求体是 // 官方前端那三个字段(`q` / `scope=webpage` / `size` **字符串**),且 `mode` 不发 // (是否必需未确证)。 func TestMetasoRequestShape(t *testing.T) { t.Parallel() cases := []struct { name string count int wantSize string wantQuery string }{ {"未给条数 ⇒ 官方默认 \"10\"", 0, metasoDefaultSize, "昔涟"}, {"给了条数 ⇒ 原样成字符串", 5, "5", "昔涟"}, {"越界条数 ⇒ 钳到未确证的上限 100", 1000, "100", "昔涟"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, cap := fixtureServer(t, metasoOK) if _, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: tc.wantQuery, Count: tc.count}); err != nil { t.Fatalf("Search: %v", err) } if cap.req == nil { t.Fatal("没发出任何请求") } if cap.req.Method != http.MethodPost { t.Errorf("method = %q, want POST", cap.req.Method) } if cap.req.URL.Path != metasoPath { t.Errorf("path = %q, want %q", cap.req.URL.Path, metasoPath) } if got := cap.req.Header.Get("Authorization"); got != "Bearer "+testKey { t.Errorf("Authorization = %q, want Bearer ", got) } if got := cap.req.Header.Get("Accept"); got != metasoAcceptHeader { t.Errorf("Accept = %q, want %q", got, metasoAcceptHeader) } var body map[string]any mustUnmarshal(t, cap.body, &body) if body["q"] != tc.wantQuery { t.Errorf("body q = %v, want %q", body["q"], tc.wantQuery) } if body["scope"] != metasoScopeWebpage { t.Errorf("body scope = %v, want %q", body["scope"], metasoScopeWebpage) } if body["size"] != tc.wantSize { t.Errorf("body size = %v (%T), want 字符串 %q", body["size"], body["size"], tc.wantSize) } if _, ok := body["mode"]; ok { t.Error("body 不该带 mode:官方前端发、第三方 cURL 不发,是否必需未确证 ⇒ 不发") } }) } } // TestMetasoResponseNormalizesLooseFields 覆盖「宽松解析」的主路径:第三方实测字段 // (title/link/snippet/rawContent/date/score 字符串)都能出结果。 func TestMetasoResponseNormalizesLooseFields(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, metasoOK) got, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "昔涟"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 2 { t.Fatalf("条数 = %d, want 2: %+v", len(got), got) } first := got[0] if first.Title != "昔涟是什么" || first.Link != "https://m.example/1" { t.Errorf("Title/Link = %q/%q", first.Title, first.Link) } if first.Snippet != "秘塔摘要" { t.Errorf("Snippet = %q, want 第三方实测的 snippet", first.Snippet) } if first.Content != "秘塔正文原文" { t.Errorf("Content = %q, want rawContent(抓原文开关打开时的正文)", first.Content) } if first.Published != "2025-06-01" { t.Errorf("Published = %q, want 第三方实测的 date", first.Published) } if first.Score != 0.93 { t.Errorf("Score = %v, want 0.93(第三方实测 score 是字符串,pickFloat 应认)", first.Score) } // position=1 是**排序位次**,不是相关性分数 ⇒ 不许被当成 Score(否则降序排会把首条排到末位)。 if got[1].Score != 0 { t.Errorf("第二条 Score = %v, want 0(position 不是分数,不许投影)", got[1].Score) } } // TestMetasoResponseLooseArrayAndKeyFallbacks 是验收「宽松解析:多种字段名都能出结果」: // 结果数组与条目字段名都按多组候选回退(因为 response schema 无官方来源)。 func TestMetasoResponseLooseArrayAndKeyFallbacks(t *testing.T) { t.Parallel() cases := []struct { name string fixture string }{ {"顶层 webpages(主候选)", `{"webpages":[{"title":"T","link":"https://x/1","snippet":"S"}]}`}, {"data 信封里的 webpages", `{"data":{"webpages":[{"title":"T","link":"https://x/1","snippet":"S"}]}}`}, {"documents(scope 变体的容错)", `{"documents":[{"title":"T","link":"https://x/1","snippet":"S"}]}`}, {"results 通用键", `{"results":[{"title":"T","link":"https://x/1","snippet":"S"}]}`}, {"字段名变体:name/url/summary", `{"webpages":[{"name":"T","url":"https://x/1","summary":"S"}]}`}, {"字段名变体:passage/content", `{"webpages":[{"title":"T","link":"https://x/1","passage":"S","content":"C"}]}`}, {"字段名变体:displayDate", `{"webpages":[{"title":"T","link":"https://x/1","displayDate":"2025-01-02"}]}`}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, tc.fixture) got, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("Search: %v", err) } if len(got) != 1 { t.Fatalf("条数 = %d, want 1(候选键应能命中): %+v", len(got), got) } if got[0].Title != "T" || got[0].Link != "https://x/1" { t.Errorf("归一化结果 = %+v, want title=T link=https://x/1", got[0]) } }) } } // TestMetasoAllFieldsMissingDoesNotPanic 是验收「字段全缺时不 panic」:条目是空对象 ⇒ // 归一化成一条**四字段全空**的行,由共享后处理(Finalize)丢弃 —— 不 panic、不报错。 func TestMetasoAllFieldsMissingDoesNotPanic(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, `{"webpages":[{}, {"unknown": 1}]}`) got, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("字段全缺不该报错(那会让上游的脏数据变成该家不可用): %v", err) } if len(got) != 2 { t.Fatalf("条数 = %d, want 2", len(got)) } for i, r := range got { if r.Link != "" || r.Title != "" || r.Snippet != "" || r.Content != "" { t.Errorf("[%d] 空条目应四字段全空: %+v", i, r) } } if final := Finalize(got, Options{Limit: 5}); len(final) != 0 { t.Fatalf("Finalize 后条数 = %d, want 0(空壳行不算结果)", len(final)) } } // TestMetasoEmptyIsNotAnError 钉住「空结果不是错误」。 func TestMetasoEmptyIsNotAnError(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, `{"credits":0,"webpages":[]}`) got, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("空 webpages 不是错误: %v", err) } if len(got) != 0 { t.Fatalf("条数 = %d, want 0", len(got)) } } // TestMetasoErrorsOnHTTP200 钉住错误路径:**HTTP 200 + 顶层 `errCode`** // (本机实测:缺 key 与错 key 都是 2005)。只确证了 2005 一条 ⇒ 其它码一律归 upstream, // 但**都必须报失败**(绝不能把失败读成「零结果」)。 func TestMetasoErrorsOnHTTP200(t *testing.T) { t.Parallel() cases := []struct { name string body string wantSent error wantCode string }{ {"凭据无效 2005(字符串)", `{"errCode":2005,"errMsg":"API密钥无效"}`, ErrUnauthorized, ProbeUnauthorized}, {"凭据无效 2005(数字也要认)", `{"errCode":"2005","errMsg":"API密钥无效"}`, ErrUnauthorized, ProbeUnauthorized}, {"站点登录码 401", `{"errCode":401,"errMsg":"需要登录"}`, ErrUnauthorized, ProbeUnauthorized}, {"未确证的码 ⇒ upstream(不猜欠费/限流)", `{"errCode":9999,"errMsg":"unknown"}`, ErrUpstream, ProbeUpstream}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, tc.body) got, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err == nil { t.Fatalf("HTTP 200 带 errCode 必须报失败,却拿到 %d 条结果", len(got)) } if !errors.Is(err, tc.wantSent) { t.Errorf("errors.Is(err, %v) = false: %v", tc.wantSent, err) } if code := ProbeErrorCode(err); code != tc.wantCode { t.Errorf("探针错误码 = %q, want %q", code, tc.wantCode) } // errMsg 是任意上游文本 ⇒ 不许进错误文案(它会进日志与面板响应)。 if strings.Contains(err.Error(), "API密钥无效") { t.Errorf("错误文案带上了上游 errMsg: %v", err) } if strings.Contains(err.Error(), testKey) { t.Fatalf("错误文案泄漏了凭据: %v", err) } }) } } // TestMetasoZeroErrCodeIsNotAnError 钉住成功侧不误杀:`errCode` 缺位或为 0 时按成功处理 // (成功响应里 errCode 长什么样**未实测** ⇒ 两种形态都放行)。 func TestMetasoZeroErrCodeIsNotAnError(t *testing.T) { t.Parallel() for _, fixture := range []string{ `{"errCode":0,"webpages":[{"title":"T","link":"https://x/1"}]}`, `{"errCode":"0","webpages":[{"title":"T","link":"https://x/1"}]}`, `{"webpages":[{"title":"T","link":"https://x/1"}]}`, } { srv, _ := fixtureServer(t, fixture) got, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("errCode=%q 的应答不该当失败: %v", fixture, err) } if len(got) != 1 { t.Fatalf("条数 = %d, want 1(输入 %s)", len(got), fixture) } } } // TestMetasoMissingArrayIsBadResponse 钉住「空」与「解析不了」分开:一个候选数组都没有 // (响应形状变了 / 是别的 scope 的报文)⇒ ErrBadResponse,不是静默 0 条。 func TestMetasoMissingArrayIsBadResponse(t *testing.T) { t.Parallel() for _, body := range []string{`not json`, `{}`, `[]`, `{"credits":1}`} { srv, _ := fixtureServer(t, body) _, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), }).Search(context.Background(), Query{Text: "q"}) if !errors.Is(err, ErrBadResponse) { t.Errorf("输入 %s 应报 ErrBadResponse, got %v", body, err) } } } // TestMetasoOverLimitResponseRejected 是验收 ④:响应体超过本仓读取上限 ⇒ // ErrResponseTooLarge(**不静默截断成半截 JSON**)。 func TestMetasoOverLimitResponseRejected(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, metasoOK) _, err := newMetaso(Config{ APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 16, }).Search(context.Background(), Query{Text: "q"}) if !errors.Is(err, ErrResponseTooLarge) { t.Fatalf("err = %v, want ErrResponseTooLarge", err) } } // TestMetasoSpecArraysStayOrdered 钉住候选数组的**顺序**(主候选必须是 webpages), // 并钉住 Score 候选里**没有** position/rank(它们是排序位次,降序排会把最相关的一条排到最后)。 func TestMetasoSpecArraysStayOrdered(t *testing.T) { t.Parallel() s := metasoSpec() if len(s.Arrays) == 0 || s.Arrays[0] != "webpages" { t.Fatalf("Arrays[0] = %v, want webpages(本地固定 scope=webpage,主候选必须与它对上)", s.Arrays) } for _, key := range s.Score { if key == "position" || key == "rank" { t.Errorf("Score 候选里有 %q:那是排序位次、不是相关性分数", key) } } }