Files
Cyrene_v2/plugins/tools/io/search/providers_test.go
T
AskaEth 25aa50e532
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 智谱 provider 按官方 schema 加固(请求补确证字段 + 错误码分档)
官方文档逐页确证后落地的四件(分册 11 的「官方 schema 确证(2026-10-07)」):
- 请求补 search_intent:false(官方标 required,缺发可能触发 1213)与
  content_size:medium;count 仅 1–50 透传,越界不发该字段;
  search_query >70 rune 直接报错且**不发请求**;
- 响应按官方收窄:摘要只认 snippet/summary/abstract 候选(官方响应**没有**
  snippet),publish_date 入 Published;
- 错误码分档:1000/1001/1003⇒unauthorized、1113⇒quota_exceeded、
  1302/1305⇒rate_limited。🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 只看状态码
  分不开「该充值」与「该退避」,必须读 error.code(码同时进日志文案);
- media/icon/refer 当前一律不投影(Result 没有「来源站点」位;要投影应给
  Result 加字段 + 同步契约,不许塞进 Snippet —— 否则摘要位会被站点名恒填满)。

另修两处过期注释(providers_test.go 仍写「只发 search_engine/search_query」)。

验收(主会话复跑 + 亲自变异):go vet 绿;search 包与调用方 io 包全绿;
我用 -overlay 删掉 1113⇒quota_exceeded 映射 ⇒ TestZhipuErrorCodeMapping 变红
(欠费被误报成限流、探针码 rate_limited)。

⚠️ 候选键容错暂未删除:官方已排除的键保留到**真机实测通过后**再收窄(现无 key)。
⚠️ 真机未跑:仍只打 httptest,官方真实响应形态未实测。
2026-10-07 09:59:24 +08:00

388 lines
14 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/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)
}
},
}
}
// 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)
}
}
}