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