Files
Cyrene_v2/plugins/tools/io/search/registry_test.go
T
AskaEth bc1dee3f56
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(search): provider 配置面去 env 化 —— 加新家不再需要动 .env
动因:`.env` 已冻结(基础键不加/不改/不删),而「加一家 provider」原本会逼出
新的 env 键(一家一个 struct 字段 + searchProviderVector 逐家读 + 逐家写死的
switch)⇒ 想接腾讯云元宝/秘塔就只能破红线。面板本来就该是插件配置的一等来源
($DATA_DIR/config/plugins/web-search.json),故让**文件成为权威**。

- plugins/tools/io/search/registry.go:接入位表加 defaultBaseURL(「没配 baseUrl
  回落代码默认值」从此只有一个出处);新增 Descriptor(s)/DefaultBaseURL。
- cmd/cyrene/websearch_providers.go(新):按接入位表逐家算出生效投影 ——
  加一家时这里**零改动**,这正是本片的核心;顺手把 wire_io_tools.go 从 430 行
  降到 342(它逼近 §10 的 500 行上限)。
- cmd/cyrene/wire_plugin_actions.go / websearch_action_data.go /
  websearch_selection.go:基地址判定统一走「文件值 → 接入位默认值」的进位链;
  checkPrimaryUsable 判据不放松(真没配齐仍 400 primary_not_configured)。

🔴 **一处对我任务书的纠正(子代理提出、我采纳)**:我要求「既有 6 家在文件里
没有该条目时仍回落 .env」。仓库现状**没有**这层回落,且有回归用例把它钉死
(TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv:「文件里没有
的家不得从 .env 复活」)。本片按「不改变既有行为」实现 —— 我的要求才是错的:
文件一旦存在就是权威,env 只在文件缺失/为空时播种。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;search 包与
cmd/cyrene 全绿;我用 -overlay 让文件里的 apiKey 被忽略 ⇒ 4 条用例变红
(含「无 env 字段的家只认文件也能进注册表」这条核心判据)。

⚠️ 新家仍未接:腾讯云元宝(tencent_wsa)/ 秘塔(metaso)两片随后串行落地。
2026-10-07 10:18:58 +08:00

320 lines
12 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 注册表应返回空清单")
}
}
// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**逐字等于
// `.env` 的标签默认值(`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的
// 基础键)。
//
// 为什么值得一条逐字表:接入位表是本片新增的「这家没配 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",
}
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)
}
}
}