package search import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "testing" ) // testKey 是夹具里用的假密钥。它只出现在**请求头**里:任何日志/错误/响应里出现它 // 都是缺陷(密钥不泄漏,design/27 §1.5)。 const testKey = "test-api-key-do-not-log" // mustUnmarshal 解一份 JSON 到 out,失败即 Fatal。 func mustUnmarshal(t *testing.T, raw string, out any) { t.Helper() if err := json.Unmarshal([]byte(raw), out); err != nil { t.Fatalf("unmarshal %q: %v", raw, err) } } // fixtureServer 造一个「记录请求 + 回固定 JSON」的测试服务器。 func fixtureServer(t *testing.T, fixture string) (*httptest.Server, *captured) { t.Helper() c := &captured{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) c.set(r, raw) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(fixture)) })) t.Cleanup(srv.Close) return srv, c } // captured 记录最后一次收到的请求(测试同步:handler 先于 Search 返回执行完)。 type captured struct { req *http.Request body string } func (c *captured) set(r *http.Request, body []byte) { // 只留断言需要的部分:URL/Method/Header(不含 Body 读句柄)。 clone := r.Clone(r.Context()) c.req = clone c.body = string(body) } // providerCase 是「一家 provider 的 httptest 夹具」:断言请求形态 + 响应归一化。 type providerCase struct { name string newP func(Config) Provider // method / path 是期望的请求形态。 method string path string // authHeader / authValue 是期望的认证头(authHeader 为空 = 该家不用 key,如 searxng)。 authHeader string authValue string // fixture 是上游返回的 JSON;want* 是归一化后应得到的第一条结果。 fixture string wantTitle string wantLink string wantContent string // checkRequest 做该家特有的断言(请求体字段等)。 checkRequest func(t *testing.T, c *captured) } // searchProviderCaseTable 是八家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。 func searchProviderCaseTable() []providerCase { return []providerCase{ searxngCase(), zhipuCase(), aliyunCase(), bochaCase(), volcengineCase(), baiduCase(), tencentWSACase(), metasoCase(), } } // searxngCase 是 searxng 的夹具(GET + q/format=json,无认证头)。 func searxngCase() providerCase { return providerCase{ name: "searxng", newP: newSearXNG, // searxng 的基地址就是端点本身(无固定路径)⇒ 请求落在 "/" 上, // 查询串才是它的语义(见 checkRequest)。带路径的端点由 io 包既有测试钉住。 method: http.MethodGet, path: "/", fixture: `{"results":[{"title":"SearX 结果","url":"https://s.example/1","content":"正文","score":0.5}]}`, wantTitle: "SearX 结果", wantLink: "https://s.example/1", wantContent: "正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() if got := c.req.URL.Query().Get("q"); got != "昔涟" { t.Errorf("searxng q = %q, want 昔涟", got) } if got := c.req.URL.Query().Get("format"); got != "json" { t.Errorf("searxng format = %q, want json", got) } if c.req.Header.Get("Authorization") != "" { t.Errorf("searxng 不该带认证头: %q", c.req.Header.Get("Authorization")) } }, } } // zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query/search_intent/content_size)。 func zhipuCase() providerCase { return providerCase{ name: "zhipu", newP: newZhipu, method: http.MethodPost, path: zhipuPath, authHeader: "Authorization", authValue: "Bearer " + testKey, fixture: `{"search_result":[{"title":"智谱结果","link":"https://z.example/1","content":"智谱正文"}]}`, wantTitle: "智谱结果", wantLink: "https://z.example/1", wantContent: "智谱正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() // 只发官方文档确证过的字段:search_engine / search_query / search_intent / content_size, // 外加**条件透传**的 count(仅当 Count 落在官方区间 1–50;本夹具 Query 未给 Count ⇒ 不发)。 var body map[string]any mustUnmarshal(t, c.body, &body) if body["search_query"] != "昔涟" { t.Errorf("zhipu body search_query = %v", body["search_query"]) } if body["search_engine"] != zhipuEngineStd { t.Errorf("zhipu body search_engine = %v, want %s", body["search_engine"], zhipuEngineStd) } if body["search_intent"] != false { t.Errorf("zhipu body search_intent = %v, want false(官方标 required)", body["search_intent"]) } if body["content_size"] != zhipuContentSize { t.Errorf("zhipu body content_size = %v, want %s", body["content_size"], zhipuContentSize) } }, } } // aliyunCase 是阿里云 IQS 的夹具(GET + X-API-Key + query 查询串)。 func aliyunCase() providerCase { return providerCase{ name: "aliyun_iqs", newP: newAliyunIQS, method: http.MethodGet, path: aliyunIQSPath, authHeader: "X-API-Key", authValue: testKey, fixture: `{"pageItems":[{"title":"IQS 结果","link":"https://a.example/1","snippet":"摘要",` + `"mainText":"IQS 正文","publishTime":1700000000000,"score":0.9}]}`, wantTitle: "IQS 结果", wantLink: "https://a.example/1", wantContent: "IQS 正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() if got := c.req.URL.Query().Get("query"); got != "昔涟" { t.Errorf("aliyun query = %q, want 昔涟", got) } }, } } // bochaCase 是博查的夹具(POST + Bearer;summary=true / count 夹在官方区间)。 func bochaCase() providerCase { return providerCase{ name: "bocha", newP: newBocha, method: http.MethodPost, path: bochaPath, authHeader: "Authorization", authValue: "Bearer " + testKey, fixture: `{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1",` + `"snippet":"摘要","summary":"博查总结","datePublished":"2025-01-01"}]}}`, wantTitle: "博查结果", wantLink: "https://b.example/1", wantContent: "博查总结", checkRequest: func(t *testing.T, c *captured) { t.Helper() var body map[string]any mustUnmarshal(t, c.body, &body) if body["query"] != "昔涟" { t.Errorf("bocha body query = %v", body["query"]) } if body["summary"] != true { t.Errorf("bocha body summary = %v, want true(中文摘要)", body["summary"]) } if body["count"] != float64(bochaDefaultCount) { t.Errorf("bocha body count = %v, want %d", body["count"], bochaDefaultCount) } }, } } // volcengineCase 是火山·豆包搜索的夹具(POST + Bearer;请求字段是大写驼峰)。 func volcengineCase() providerCase { return providerCase{ name: "volcengine", newP: newVolcengine, method: http.MethodPost, path: volcenginePath, authHeader: "Authorization", authValue: "Bearer " + testKey, fixture: `{"Result":{"WebResults":[{"Title":"火山结果","Url":"https://v.example/1",` + `"Snippet":"摘要","Content":"火山正文","PublishTime":1700000000000,"RankScore":0.8}]}}`, wantTitle: "火山结果", wantLink: "https://v.example/1", wantContent: "火山正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() var body map[string]any mustUnmarshal(t, c.body, &body) if body["Query"] != "昔涟" { t.Errorf("volcengine body Query = %v", body["Query"]) } if body["SearchType"] != volcengineSearchTypeWeb { t.Errorf("volcengine body SearchType = %v", body["SearchType"]) } }, } } // baiduCase 是百度千帆的夹具(POST + Bearer;单轮 messages + search_source)。 func baiduCase() providerCase { return providerCase{ name: "baidu_qianfan", newP: newBaiduQianfan, method: http.MethodPost, path: baiduQianfanPath, authHeader: "Authorization", authValue: "Bearer " + testKey, fixture: `{"references":[{"title":"百度结果","url":"https://q.example/1",` + `"snippet":"摘要","content":"百度正文"}]}`, wantTitle: "百度结果", wantLink: "https://q.example/1", wantContent: "百度正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() var body map[string]any mustUnmarshal(t, c.body, &body) if body["search_source"] != baiduSearchSourceV2 { t.Errorf("baidu body search_source = %v, want %s", body["search_source"], baiduSearchSourceV2) } msgs, ok := body["messages"].([]any) if !ok || len(msgs) != 1 { t.Fatalf("百度搜索仅支持单轮输入,messages = %v", body["messages"]) } first, _ := msgs[0].(map[string]any) if first["content"] != "昔涟" || first["role"] != "user" { t.Errorf("baidu messages[0] = %v", first) } }, } } // tencentWSACase 是腾讯云·元宝搜索的夹具(POST + Bearer;`Pages` 是 **JSON 字符串**数组)。 func tencentWSACase() providerCase { return providerCase{ name: NameTencentWSA, newP: newTencentWSA, method: http.MethodPost, path: tencentWSAPath, authHeader: "Authorization", authValue: "Bearer " + testKey, // Pages 的元素是一段 JSON 文本(官方 Array of String 形状)⇒ 二次解析才能取到字段。 fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` + `\"passage\":\"元宝摘要\",\"content\":\"元宝正文\",\"date\":\"2025-06-01\",\"score\":0.9}"],` + `"Query":"昔涟","Version":"standard","RequestId":"r-1"}}`, wantTitle: "元宝结果", wantLink: "https://t.example/1", wantContent: "元宝正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() var body map[string]any mustUnmarshal(t, c.body, &body) if body["Query"] != "昔涟" { t.Errorf("tencent_wsa body Query = %v", body["Query"]) } // 只发官方确证为必选的 Query:Cnt/Industry 是尊享版门槛、Site 是域名过滤 // (design/27 §4:过滤统一在 Finalize 做)、Mode/Freshness 版本口径未确证。 for _, absent := range []string{"Cnt", "Industry", "Site", "Mode", "Freshness"} { if _, ok := body[absent]; ok { t.Errorf("tencent_wsa 不该发 %s(版本门槛/过滤参数,见 provider 注释)", absent) } } }, } } // metasoCase 是秘塔搜索的夹具(POST + Bearer + Accept;`size` 是**字符串**)。 func metasoCase() providerCase { return providerCase{ name: NameMetaso, newP: newMetaso, method: http.MethodPost, path: metasoPath, authHeader: "Authorization", authValue: "Bearer " + testKey, fixture: `{"credits":1,"webpages":[{"title":"秘塔结果","link":"https://m.example/1",` + `"snippet":"秘塔摘要","rawContent":"秘塔正文","date":"2025-06-01","score":"0.9"}]}`, wantTitle: "秘塔结果", wantLink: "https://m.example/1", wantContent: "秘塔正文", checkRequest: func(t *testing.T, c *captured) { t.Helper() var body map[string]any mustUnmarshal(t, c.body, &body) if body["q"] != "昔涟" { t.Errorf("metaso body q = %v", body["q"]) } if body["scope"] != metasoScopeWebpage { t.Errorf("metaso body scope = %v, want %s", body["scope"], metasoScopeWebpage) } // 官方前端发的是**字符串**("10")⇒ 这里也必须是字符串,便于与 Playground 对拍。 if body["size"] != metasoDefaultSize { t.Errorf("metaso body size = %v (%T), want 字符串 %q", body["size"], body["size"], metasoDefaultSize) } if got := c.req.Header.Get("Accept"); got != metasoAcceptHeader { t.Errorf("metaso Accept = %q, want %q", got, metasoAcceptHeader) } }, } } // TestProviders_FixturePerProvider 是验收 ①:八家各一条 httptest 夹具(不打真上游)。 func TestProviders_FixturePerProvider(t *testing.T) { t.Parallel() for _, tc := range searchProviderCaseTable() { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, cap := fixtureServer(t, tc.fixture) p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) got, err := p.Search(context.Background(), Query{Text: "昔涟"}) if err != nil { t.Fatalf("%s Search: %v", tc.name, err) } if cap.req == nil { t.Fatalf("%s 没发出任何请求", tc.name) } if cap.req.Method != tc.method { t.Errorf("method = %q, want %q", cap.req.Method, tc.method) } if cap.req.URL.Path != tc.path { t.Errorf("path = %q, want %q", cap.req.URL.Path, tc.path) } if tc.authHeader != "" { if gotAuth := cap.req.Header.Get(tc.authHeader); gotAuth != tc.authValue { t.Errorf("认证头 %s = %q, want %q", tc.authHeader, gotAuth, tc.authValue) } } if len(got) != 1 { t.Fatalf("%s 结果条数 = %d, want 1(归一化失败): %+v", tc.name, len(got), got) } r0 := got[0] if r0.Title != tc.wantTitle || r0.Link != tc.wantLink || r0.Content != tc.wantContent { t.Errorf("%s 归一化结果 = %+v, want title=%q link=%q content=%q", tc.name, r0, tc.wantTitle, tc.wantLink, tc.wantContent) } if tc.checkRequest != nil { tc.checkRequest(t, cap) } }) } } // TestProviders_AllNormalizeToSameResult 是验收 ②:八家输出**归一化成同一 Result** // (同一结构体、同一语义),且共用同一套后处理(Finalize)。 func TestProviders_AllNormalizeToSameResult(t *testing.T) { t.Parallel() for _, tc := range searchProviderCaseTable() { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, tc.fixture) p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) got, err := p.Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("Search: %v", err) } for i, r := range got { if r.Link == "" { // 各家夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、 // 不是本判据的对象):夹具里链接丢了就说明归一化把 link 认错了。 t.Errorf("[%d] Link 为空(夹具里给了链接): %+v", i, r) } if r.Title == "" { t.Errorf("[%d] Title 为空(夹具里给了标题): %+v", i, r) } } if final := Finalize(got, Options{Limit: 1}); len(final) != 1 { t.Fatalf("Finalize 后条数 = %d, want 1", len(final)) } }) } } // TestProviders_EmptyBodyIsEmptyNotError 钉住各家对「200 + 空数组」的处置一致: // 不是错误,而是 0 条(由上层按「空结果 ≠ 成功」处置)。 func TestProviders_EmptyBodyIsEmptyNotError(t *testing.T) { t.Parallel() for _, tc := range searchProviderCaseTable() { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() srv, _ := fixtureServer(t, tc.emptyFixture()) p := tc.newP(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()}) got, err := p.Search(context.Background(), Query{Text: "q"}) if err != nil { t.Fatalf("空结果不是错误: %v", err) } if len(got) != 0 { t.Fatalf("条数 = %d, want 0", len(got)) } }) } } // emptyFixture 返回该家「0 条结果」的合法应答(候选数组为空)。 func (tc providerCase) emptyFixture() string { switch tc.name { case "searxng": return `{"results":[]}` case "zhipu": return `{"search_result":[]}` case "aliyun_iqs": return `{"pageItems":[]}` case "bocha": return `{"webPages":{"value":[]}}` case "volcengine": return `{"Result":{"WebResults":[]}}` case NameTencentWSA: return `{"Response":{"Pages":[],"Query":"q","RequestId":"r"}}` case NameMetaso: return `{"webpages":[],"credits":0}` default: return `{"references":[]}` } } // TestExtractResults_MissingFieldsTolerated 钉住「缺字段的容错」: // 缺 content 回落 snippet;全缺也不 panic、不报错;非对象条目跳过。 func TestExtractResults_MissingFieldsTolerated(t *testing.T) { t.Parallel() data := []byte(`{"results":[ {"title":"只有标题","url":"https://x.example/1"}, {"url":"https://x.example/2","snippet":"只有摘要"}, "not-an-object", {"title":"只有正文","url":"https://x.example/3","content":"正文"} ]}`) got, err := extractResults(data, searxngSpec()) if err != nil { t.Fatalf("缺字段不该报错: %v", err) } if len(got) != 3 { t.Fatalf("条数 = %d, want 3(非对象条目应被跳过)", len(got)) } if got[0].Content != "" || got[0].Title != "只有标题" { t.Errorf("缺 content/正文时应原样留空: %+v", got[0]) } if got[1].Content != "只有摘要" { t.Errorf("content 缺了必须回落 snippet: %+v", got[1]) } if got[2].Content != "正文" { t.Errorf("正文应原样带出: %+v", got[2]) } } // TestExtractResults_EmptyVsMalformed 钉住「空」与「解析不了」必须分开(design/27 §1.4)。 func TestExtractResults_EmptyVsMalformed(t *testing.T) { t.Parallel() empty, err := extractResults([]byte(`{"results":[]}`), searxngSpec()) if err != nil { t.Fatalf("空数组不是错误: %v", err) } if len(empty) != 0 { t.Fatalf("空数组应归一化成 0 条, got %d", len(empty)) } for _, bad := range []string{`not json`, `{"data":{}}`, `[]`, `{}`} { if _, err := extractResults([]byte(bad), searxngSpec()); !errors.Is(err, ErrBadResponse) { t.Errorf("输入 %q 应报 ErrBadResponse, got %v", bad, err) } } }