Files
Cyrene_v2/plugins/tools/io/search/registry_test.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 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)。
2026-10-07 13:42:41 +08:00

325 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"
"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)
}
}
}