Files
Cyrene_v2/plugins/tools/io/search/registry_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

257 lines
9.4 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"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// TestRegistry_KnownAndKnownNames 覆盖「哪些名字是本期接入位」:认识的家(含未配置的)
// 与不认识的家必须分得开 —— 这正是启动期「未注册 vs 未配置」两种报错的判据。
func TestRegistry_KnownAndKnownNames(t *testing.T) {
t.Parallel()
for _, name := range []string{
NameSearXNG, NameZhipu, NameAliyunIQS, NameBocha, NameVolcengine, NameBaiduQianfan,
"Zhipu", " bocha ",
} {
if !Known(name) {
t.Errorf("Known(%q) = false, want true(大小写/空白应不敏感)", name)
}
}
for _, name := range []string{"", "tavily", "exa", "firecrawl", "serper", "serpapi", "nope"} {
if Known(name) {
t.Errorf("Known(%q) = true, want false(本期未登记)", name)
}
}
names := KnownNames()
if len(names) != 6 {
t.Fatalf("KnownNames() = %v, want 六家", names)
}
}
// TestRegistry_Configured 钉住「配齐」的判定口径:searxng 无 key、看 base URL;
// 云家看 API key。它是「未配置的家不出现在可用清单里」的唯一判据。
func TestRegistry_Configured(t *testing.T) {
t.Parallel()
cases := []struct {
name string
provider string
key, base string
wantConfigured bool
}{
{"searxng 有端点无 key 也算配置齐", NameSearXNG, "", "http://127.0.0.1:38471", true},
{"searxng 没端点 = 未配置", NameSearXNG, "whatever", " ", false},
{"云家有 key = 配置齐", NameZhipu, "k", "", true},
{"云家没 key = 未配置", NameZhipu, " ", "https://open.bigmodel.cn", false},
{"未登记的名字一律未配置", "tavily", "k", "https://api.tavily.com", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Configured(tc.provider, tc.key, tc.base); got != tc.wantConfigured {
t.Fatalf("Configured(%q,%q,%q) = %v, want %v", tc.provider, tc.key, tc.base, got, tc.wantConfigured)
}
})
}
}
// TestRegistry_BuildOnlyRegistersConfigured 钉住 fail-closed:未配置的家根本不进注册表,
// 因而**不出现在可用清单里**(design/27 §1.2)。
func TestRegistry_BuildOnlyRegistersConfigured(t *testing.T) {
t.Parallel()
wires := []Wire{
{Name: NameBocha, APIKey: "k", BaseURL: "https://api.bocha.cn", Configured: true},
{Name: NameZhipu, APIKey: "", BaseURL: "https://open.bigmodel.cn", Configured: false},
{Name: "tavily", APIKey: "k", BaseURL: "https://api.tavily.com", Configured: true},
{Name: NameBocha, APIKey: "k2", BaseURL: "https://api.bocha.cn", Configured: true}, // 重复
}
reg := Build(wires, &http.Client{}, defaultMaxBytes, nil)
names := reg.Names()
if len(names) != 1 || names[0] != NameBocha {
t.Fatalf("可用清单 = %v, want [bocha](未配置的家与未登记的名字都不得出现)", names)
}
if _, ok := reg.Lookup(NameZhipu); ok {
t.Fatal("缺 key 的家不得出现在注册表里")
}
if _, ok := reg.Lookup("tavily"); ok {
t.Fatal("未登记的名字不得出现在注册表里")
}
if p, ok := reg.Lookup(" BOCHA "); !ok || p.Name() != NameBocha {
t.Fatalf("Lookup 应大小写/空白不敏感, got %v %v", p, ok)
}
}
// TestRegistry_EmptyAndNilSafe 覆盖零值路径:空注册表 / nil 注册表不得 panic。
func TestRegistry_EmptyAndNilSafe(t *testing.T) {
t.Parallel()
reg := Build(nil, &http.Client{}, 0, nil)
if len(reg.Names()) != 0 {
t.Fatalf("空接线表应得到空注册表: %v", reg.Names())
}
if _, ok := reg.Lookup("zhipu"); ok {
t.Fatal("空注册表不该命中任何名字")
}
var nilReg *Registry
if _, ok := nilReg.Lookup("zhipu"); ok {
t.Fatal("nil 注册表应安全返回 false")
}
if len(nilReg.Names()) != 0 {
t.Fatal("nil 注册表应返回空清单")
}
}
// TestProviders_ErrorClassification 是验收 ⑤:鉴权 / 限流 / 额度 / 上游 各一条。
// 用同一家 provider + 不同应答,确认分类**如实区分**(错误分类决定降级链是否前进)。
func TestProviders_ErrorClassification(t *testing.T) {
t.Parallel()
cases := []struct {
name string
status int
body string
wantErr error
}{
{"鉴权失败 401", http.StatusUnauthorized, `{"error":"bad key"}`, ErrUnauthorized},
{"限流 429", http.StatusTooManyRequests, `{"error":"too many"}`, ErrRateLimited},
{"额度/欠费(403 + 官方错误码)", http.StatusForbidden,
`{"code":"Retrieval.Arrears","message":"账户欠费"}`, ErrQuotaExceeded},
{"未开通(403 + 官方错误码)", http.StatusForbidden,
`{"code":"Retrieval.NotActivate"}`, ErrQuotaExceeded},
{"上游 500", http.StatusInternalServerError, `{}`, ErrUpstream},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
defer srv.Close()
p := newZhipu(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client()})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, want errors.Is(..., %v)", err, tc.wantErr)
}
})
}
}
// TestProviders_TransportFailures 覆盖非 HTTP 状态类的失败:网络 / 读取上限 /
// 没有客户端 / 注入守卫被调用。它们同样要**如实分类**,不许混成一团。
func TestProviders_TransportFailures(t *testing.T) {
t.Parallel()
t.Run("网络失败", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close() // 关掉之后再打 ⇒ 连接被拒
p := newZhipu(Config{APIKey: testKey, BaseURL: url, Client: &http.Client{}})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrNetwork) {
t.Fatalf("err = %v, want ErrNetwork", err)
}
})
t.Run("响应超上限", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"search_result":[{"title":"很长"}]}`))
}))
defer srv.Close()
p := newZhipu(Config{APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 8})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrResponseTooLarge) {
t.Fatalf("err = %v, want ErrResponseTooLarge", err)
}
})
t.Run("没有 HTTP 客户端", func(t *testing.T) {
t.Parallel()
p := newZhipu(Config{APIKey: testKey, BaseURL: "https://open.bigmodel.cn"})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
})
t.Run("注入的 SSRF 守卫被调用", func(t *testing.T) {
t.Parallel()
called := false
blocked := errors.New("blocked by guard")
p := newZhipu(Config{
APIKey: testKey, BaseURL: "https://open.bigmodel.cn", Client: &http.Client{},
Guard: func(context.Context, string) error { called = true; return blocked },
})
if _, err := p.Search(context.Background(), Query{Text: "q"}); !errors.Is(err, blocked) {
t.Fatalf("err = %v, want wrapped guard error", err)
}
if !called {
t.Fatal("注入的守卫没被调用 —— SSRF 判定成了死代码")
}
})
}
// TestClassifyStatus 是状态码分类的表驱动(含业务码优先于状态码)。
func TestClassifyStatus(t *testing.T) {
t.Parallel()
cases := []struct {
status int
code string
wantErr error
}{
{http.StatusUnauthorized, "", ErrUnauthorized},
{http.StatusForbidden, "", ErrUnauthorized},
{http.StatusPaymentRequired, "", ErrQuotaExceeded},
{http.StatusTooManyRequests, "", ErrRateLimited},
{http.StatusInternalServerError, "", ErrUpstream},
{http.StatusBadRequest, "", ErrUpstream},
// 业务码优先:403 也可能是额度(阿里云官方错误码表确证)。
{http.StatusForbidden, "Retrieval.Arrears", ErrQuotaExceeded},
{http.StatusForbidden, "retrieval.throttling.user", ErrRateLimited},
{http.StatusForbidden, "Retrieval.NotAuthorised", ErrUnauthorized},
}
for _, tc := range cases {
if got := classifyStatus(tc.status, tc.code); !errors.Is(got, tc.wantErr) {
t.Errorf("classifyStatus(%d,%q) = %v, want %v", tc.status, tc.code, got, tc.wantErr)
}
}
}
// TestUpstreamCode 覆盖业务错误码的候选位置抽取。
func TestUpstreamCode(t *testing.T) {
t.Parallel()
cases := []struct {
body string
want string
}{
{`{"code":"Retrieval.Arrears"}`, "Retrieval.Arrears"},
{`{"error":{"code":"abc"}}`, "abc"},
{`{"ErrorCode":"xyz"}`, "xyz"},
{`{"message":"no code here"}`, ""},
{`not json`, ""},
{``, ""},
}
for _, tc := range cases {
if got := upstreamCode([]byte(tc.body)); got != tc.want {
t.Errorf("upstreamCode(%q) = %q, want %q", tc.body, got, tc.want)
}
}
}
// TestUnavailable 钉住降级链的判据:**空结果不是「不可用」**(不该前进一格)。
func TestUnavailable(t *testing.T) {
t.Parallel()
for _, err := range []error{
ErrNotConfigured, ErrUnauthorized, ErrRateLimited, ErrQuotaExceeded,
ErrNetwork, ErrUpstream, ErrResponseTooLarge, ErrBadResponse,
} {
if !Unavailable(err) {
t.Errorf("Unavailable(%v) = false, want true(该家不可用 ⇒ 前进一格)", err)
}
}
for _, err := range []error{nil, ErrNoResults, context.Canceled, context.DeadlineExceeded} {
if Unavailable(err) {
t.Errorf("Unavailable(%v) = true, want false(不是「不可用」)", err)
}
}
}