Files
Cyrene_v2/plugins/tools/io/search/providers_test.go
T
AskaEth 69a3b73777 feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

381 lines
13 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.
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)
}
}
}