主人定调「先把原生的做进插件,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),并有「未登记⇒启动报错」的防回归断言
257 lines
9.4 KiB
Go
257 lines
9.4 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|