feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)

- 新增 $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 的相关注册留给主会话。
This commit is contained in:
AskaEth committed 2026-10-06 09:04:04 +08:00
1 parent 2d21aa553e
commit 120d926cf8
11 files changed
+1811 -41

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
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)
}
}