Files
AskaEth 120d926cf8 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 的相关注册留给主会话。
2026-10-06 09:04:04 +08:00

190 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}