package search import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) // 本文件钉住「测试连通」探针(design/09 §5.2 示例的 plugin.testProvider)的结论语义: // 真发一次请求、空结果 ≠ 不通、凭据绝不进结果、失败必须可分类。 // wsProbeServer 起一个只说博查形状的假上游,把收到的请求送进 got 供断言。 func wsProbeServer(t *testing.T, status int, body string) (*httptest.Server, chan *http.Request) { t.Helper() got := make(chan *http.Request, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got <- r w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write([]byte(body)) })) t.Cleanup(srv.Close) return srv, got } // 判据 ①:通了 ⇒ OK + 真实条数;且真的打到了那家的固定路径上,凭据真的带上了。 func TestProbeOKHitsProviderWithCredential(t *testing.T) { t.Parallel() const key = "probe-secret-key" srv, got := wsProbeServer(t, http.StatusOK, `{"webPages":{"value":[{"name":"标题","url":"https://a.example/1","summary":"正文"}]}}`) res := Probe(context.Background(), NameBocha, Config{ APIKey: key, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 1 << 20, }, 1) if !res.OK || res.Count != 1 { t.Fatalf("探针结论 = %+v, want OK/count=1", res) } if res.Provider != NameBocha { t.Fatalf("provider = %q, want %q", res.Provider, NameBocha) } if res.ErrorCode != "" || res.Error != "" { t.Fatalf("成功时不该带错误码/文案:%+v", res) } select { case r := <-got: if r.URL.Path != "/v1/web-search" { t.Errorf("请求路径 = %q, want /v1/web-search(各家固定路径)", r.URL.Path) } if r.Header.Get("Authorization") != "Bearer "+key { t.Errorf("Authorization = %q(凭据必须真的被用上)", r.Header.Get("Authorization")) } default: t.Fatal("探针一个请求都没发 —— 连通性是发出来的,不是算出来的") } wsAssertNoSecret(t, res, key, srv.URL) } // 判据 ②:空结果 ≠ 不通 —— 报 no_results,既不算成功、也不算「这家不可用」。 func TestProbeEmptyResultsIsNoResultsNotFailure(t *testing.T) { t.Parallel() srv, _ := wsProbeServer(t, http.StatusOK, `{"webPages":{"value":[]}}`) res := Probe(context.Background(), NameBocha, Config{ APIKey: "k", BaseURL: srv.URL, Client: srv.Client(), }, 1) if res.OK { t.Fatalf("空结果不得算成功:%+v", res) } if res.ErrorCode != ProbeNoResults { t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNoResults) } } // 判据 ③:失败必须可分类,且**文案里不许出现上游 URL/凭据**。 func TestProbeFailureClassesAndNoLeak(t *testing.T) { t.Parallel() cases := []struct { name string status int body string want string }{ {"凭据被拒", http.StatusUnauthorized, `{"error":{"code":"unauthorized"}}`, ProbeUnauthorized}, {"限流", http.StatusTooManyRequests, `{}`, ProbeRateLimited}, {"上游 5xx", http.StatusBadGateway, `{}`, ProbeUpstream}, {"应答不是预期结构", http.StatusOK, `{"unexpected":true}`, ProbeBadResponse}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() const key = "probe-secret-key" srv, _ := wsProbeServer(t, tc.status, tc.body) res := Probe(context.Background(), NameBocha, Config{ APIKey: key, BaseURL: srv.URL, Client: srv.Client(), }, 1) if res.OK { t.Fatalf("失败不得报 OK:%+v", res) } if res.ErrorCode != tc.want { t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error) } if res.Error == "" { t.Fatal("失败必须带一句可读文案(错误码与文案分离,前端可本地化)") } wsAssertNoSecret(t, res, key, srv.URL) }) } } // 判据 ④:名字没登记 / 没配齐 / 没客户端,三类都别去发请求,且分类准确。 func TestProbePreflightRejectsBeforeSending(t *testing.T) { t.Parallel() cases := []struct { name string prov string cfg Config want string }{ {"没登记的名字", "tavily", Config{APIKey: "k", BaseURL: "https://t.example"}, ProbeUnknownProvider}, {"云家缺 key", NameBocha, Config{BaseURL: "https://b.example"}, ProbeNotConfigured}, {"缺接口地址", NameBocha, Config{APIKey: "k"}, ProbeNotConfigured}, {"没有 http 客户端", NameBocha, Config{APIKey: "k", BaseURL: "https://b.example"}, ProbeNotConfigured}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() res := Probe(context.Background(), tc.prov, tc.cfg, 1) if res.ErrorCode != tc.want { t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error) } if res.OK || res.Count != 0 { t.Fatalf("发不出去的探针不得报成功:%+v", res) } }) } } // 判据 ⑤:网络层失败归 network(连不上也有稳定错误码,不是一团未知)。 func TestProbeNetworkFailureIsClassified(t *testing.T) { t.Parallel() srv, _ := wsProbeServer(t, http.StatusOK, `{}`) url := srv.URL srv.Close() // 关掉:后续请求必然连不上 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() res := Probe(ctx, NameBocha, Config{APIKey: "k", BaseURL: url, Client: &http.Client{}}, 1) if res.ErrorCode != ProbeNetwork { t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNetwork) } } // 判据 ⑥:错误码 ↔ 文案一一对应,且未分类有兜底(不许出现「空文案的失败」)。 func TestProbeMessagesCoverEveryCode(t *testing.T) { t.Parallel() for _, code := range []string{ ProbeUnknownProvider, ProbeNotConfigured, ProbeUnauthorized, ProbeRateLimited, ProbeQuotaExceeded, ProbeNetwork, ProbeUpstream, ProbeBadResponse, ProbeNoResults, ProbeUnknown, } { if msg := probeMessage(code); strings.TrimSpace(msg) == "" { t.Errorf("错误码 %q 没有文案", code) } } if ProbeErrorCode(nil) != "" { t.Fatal("nil 错误没有错误码") } } // wsAssertNoSecret 断言探针结论里既没有凭据、也没有上游 URL(两者都可能出现在上游 // 错误体或网络错误里,而这份结构会进面板响应)。 func wsAssertNoSecret(t *testing.T, res ProbeResult, secret, upstreamURL string) { t.Helper() raw, err := json.Marshal(res) if err != nil { t.Fatalf("marshal: %v", err) } if strings.Contains(string(raw), secret) { t.Fatalf("探针结论泄漏了凭据: %s", raw) } if strings.Contains(string(raw), upstreamURL) { t.Fatalf("探针结论泄漏了上游地址: %s", raw) } }