- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
(本片自己不开路由)
⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
另有 design/28 的相关注册留给主会话。
190 lines
6.5 KiB
Go
190 lines
6.5 KiB
Go
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)
|
||
}
|
||
}
|