主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。 - 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次** - 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义) - 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」) - 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉) - searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变) - 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
381 lines
13 KiB
Go
381 lines
13 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(),
|
||
}
|
||
}
|
||
|
||
// 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)。
|
||
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)。
|
||
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)
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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 == "" {
|
||
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":[]}}`
|
||
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)
|
||
}
|
||
}
|
||
}
|