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