接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。
- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
(UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
.env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
计数不变(14 + 25 + 40 = 79)。
325 lines
13 KiB
Go
325 lines
13 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,
|
||
NameTencentWSA, NameMetaso,
|
||
"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) != 8 {
|
||
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 注册表应返回空清单")
|
||
}
|
||
}
|
||
|
||
// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**:
|
||
// 「有 `.env` 字段的家」那几个值**逐字等于** `.env` 的标签默认值
|
||
// (`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的基础键);
|
||
// 没有 `.env` 字段的家(tencent_wsa / metaso)只在这里给出唯一出处。
|
||
//
|
||
// 为什么值得一条逐字表:接入位表是本片新增的「这家没配 baseUrl ⇒ 回落哪个地址」的
|
||
// **唯一出处**,而 `.env` 侧那几个键仍然存在、仍然参与播种。两处一旦漂移,同一家 provider
|
||
// 会因为「值从哪来」指向两个不同的上游 —— 只有逐字对得上的表拦得住这种不一致。
|
||
func TestRegistry_DefaultBaseURLMatchesEnvDefaults(t *testing.T) {
|
||
t.Parallel()
|
||
want := map[string]string{
|
||
NameZhipu: "https://open.bigmodel.cn",
|
||
NameAliyunIQS: "https://cloud-iqs.aliyuncs.com",
|
||
NameBocha: "https://api.bocha.cn",
|
||
NameVolcengine: "https://open.feedcoopapi.com",
|
||
NameBaiduQianfan: "https://qianfan.baidubce.com",
|
||
// 没有 `.env` 字段的两家:默认地址只来自本表(登记在 registry.go 的接入位表里)。
|
||
NameTencentWSA: "https://api.wsa.cloud.tencent.com",
|
||
NameMetaso: "https://metaso.cn",
|
||
}
|
||
for name, base := range want {
|
||
if got := DefaultBaseURL(name); got != base {
|
||
t.Errorf("DefaultBaseURL(%q) = %q, want %q(必须与 .env 的标签默认值逐字一致)", name, got, base)
|
||
}
|
||
}
|
||
// searxng 没有默认基地址:它的地址为空本身就是「关掉这家」(组合根按 ResolveEndpoint
|
||
// 裁决整把工具的存亡),回落一个本机地址会让「清空地址」变成假话。
|
||
if got := DefaultBaseURL(NameSearXNG); got != "" {
|
||
t.Errorf("DefaultBaseURL(%q) = %q, want 空串", NameSearXNG, got)
|
||
}
|
||
// 未登记的接入位:不猜地址。
|
||
if got := DefaultBaseURL("tavily"); got != "" {
|
||
t.Errorf("未登记的接入位不该有默认地址, got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestRegistry_DescriptorsMatchDefinitions 钉住「只读描述」与接入位表同源:
|
||
// 组合根按它逐家投影配置,两处一旦不同步,加一家 provider 就会「漏在注册面外」。
|
||
func TestRegistry_DescriptorsMatchDefinitions(t *testing.T) {
|
||
t.Parallel()
|
||
desc := Descriptors()
|
||
if len(desc) != len(KnownNames()) {
|
||
t.Fatalf("Descriptors() 有 %d 家,KnownNames() 有 %d 家(两者必须同一张表)",
|
||
len(desc), len(KnownNames()))
|
||
}
|
||
byName := make(map[string]Descriptor, len(desc))
|
||
for _, d := range desc {
|
||
if _, dup := byName[d.Name]; dup {
|
||
t.Fatalf("Descriptors() 里 %q 出现了两次", d.Name)
|
||
}
|
||
byName[d.Name] = d
|
||
}
|
||
for _, name := range KnownNames() {
|
||
d, ok := byName[name]
|
||
if !ok {
|
||
t.Fatalf("KnownNames() 里的 %q 不在 Descriptors() 里", name)
|
||
}
|
||
if d.NeedKey != (name != NameSearXNG) {
|
||
t.Errorf("Descriptor(%q).NeedKey = %v(只有 searxng 不需要 key)", name, d.NeedKey)
|
||
}
|
||
if d.DefaultBaseURL != DefaultBaseURL(name) {
|
||
t.Errorf("Descriptor(%q).DefaultBaseURL = %q, DefaultBaseURL(%q) = %q(必须同源)",
|
||
name, d.DefaultBaseURL, name, DefaultBaseURL(name))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|