接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。
- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
(UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
.env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
计数不变(14 + 25 + 40 = 79)。
457 lines
17 KiB
Go
457 lines
17 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|