接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。
- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
(UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
.env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
计数不变(14 + 25 + 40 = 79)。
253 lines
9.7 KiB
Go
253 lines
9.7 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// 本文件钉住切片 WS1-B 的组合根接线语义(design/27 §3):
|
||
// - 选定 provider 未登记 / 未配置 ⇒ **启动报错**(不许静默回落);
|
||
// - 后端显式关闭(off / 空)⇒ web-search **不进注册面**;
|
||
// - 缺 key 的家**不出现在可用清单**里;
|
||
// - 选定的云 provider 真的被用上(配置 → provider → 请求)且如实标注 provider。
|
||
|
||
// wsNewTools 走组合根的同一入口构造 io 工具集,返回它的错误(用于启动期断言)。
|
||
func wsNewTools(t *testing.T, cfg *config.Config) error {
|
||
t.Helper()
|
||
a := &app{cfg: cfg, log: testLog()}
|
||
_, err := iotools.New(cfg, a.ioToolOptions()...)
|
||
return err
|
||
}
|
||
|
||
// TestWireIOTools_ProviderOffOmitsWebSearch 钉住 WEB_SEARCH_PROVIDER 的关闭语义:
|
||
// 空 / off / none ⇒ 不注册工具(模型看不见),且**不报错**(是「少一把工具」)。
|
||
func TestWireIOTools_ProviderOffOmitsWebSearch(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range []struct{ name, line string }{
|
||
{"显式留空", "WEB_SEARCH_PROVIDER="},
|
||
{"off", "WEB_SEARCH_PROVIDER=off"},
|
||
{"大写 NONE", "WEB_SEARCH_PROVIDER=NONE"},
|
||
} {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
cfg := wsLoadConfig(t, tc.line)
|
||
a := wsSurfaceApp(t, cfg, &buf)
|
||
if wsHasTool(a, "web-search") {
|
||
t.Fatalf("后端关闭时 web-search 不得进注册面,实际 = %v", toolNames(a))
|
||
}
|
||
for _, name := range []string{"file", "image-crop", "http", "web-fetch", "knowledge", "document-read"} {
|
||
if !wsHasTool(a, name) {
|
||
t.Errorf("%q 不得因 web-search 关闭而消失", name)
|
||
}
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, webSearchProviderEnv) {
|
||
t.Fatalf("关闭必须留下带键名的 WARN: %q", out)
|
||
}
|
||
if !strings.Contains(out, "web-search tool not registered") {
|
||
t.Fatalf("WARN 必须说清工具没注册: %q", out)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_UnavailableSelectionFailsStartup 钉住「未登记 / 未配置」两条都
|
||
// **启动报错**,且绝不静默回落到配好的 searxng 兜底。
|
||
func TestWireIOTools_UnavailableSelectionFailsStartup(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
lines []string
|
||
wantErr error
|
||
}{
|
||
{"未登记的名字(tavily)", []string{"WEB_SEARCH_PROVIDER=tavily"}, search.ErrUnknownProvider},
|
||
{"未登记的名字(typo)", []string{"WEB_SEARCH_PROVIDER=searxgn"}, search.ErrUnknownProvider},
|
||
{"已登记但缺 key(zhipu)", []string{"WEB_SEARCH_PROVIDER=zhipu"}, search.ErrNotConfigured},
|
||
{"已登记但缺 key(bocha)", []string{"WEB_SEARCH_PROVIDER=bocha"}, search.ErrNotConfigured},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := wsLoadConfig(t, tc.lines...)
|
||
err := wsNewTools(t, cfg)
|
||
if err == nil {
|
||
t.Fatal("选定不可用的 provider 必须启动报错(默认 searxng 兜底配好了也不许悄悄换过去)")
|
||
}
|
||
if !errors.Is(err, tc.wantErr) {
|
||
t.Fatalf("err = %v, want errors.Is(..., %v)", err, tc.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_AvailableListExcludesUnconfigured 钉住「未配置的家不出现在可用清单里」:
|
||
// 启动日志的 available 只列已配齐的家。
|
||
func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER=bocha",
|
||
"WEB_SEARCH_BOCHA_API_KEY=test-bocha-key",
|
||
)
|
||
a := wsSurfaceApp(t, cfg, &buf)
|
||
if !wsHasTool(a, "web-search") {
|
||
t.Fatalf("已配置的 provider 应让工具进注册面,实际 = %v", toolNames(a))
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, "web-search providers configured") {
|
||
t.Fatalf("启动应如实列出可用清单: %q", out)
|
||
}
|
||
if !strings.Contains(out, search.NameBocha) {
|
||
t.Fatalf("可用清单里应有 bocha: %q", out)
|
||
}
|
||
for _, absent := range []string{
|
||
search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan,
|
||
search.NameTencentWSA, search.NameMetaso,
|
||
} {
|
||
if strings.Contains(out, absent) {
|
||
t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out)
|
||
}
|
||
}
|
||
// 密钥绝不进日志(只列名字)。
|
||
if strings.Contains(out, "test-bocha-key") {
|
||
t.Fatalf("密钥进了日志: %q", out)
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_SelectedCloudProviderIsUsed 是「配置 → provider → 请求」的真透传断言:
|
||
// 选定的 bocha 真的带 Bearer 打到配置的 base URL 上,结果里 provider 也是 bocha。
|
||
func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) {
|
||
t.Parallel()
|
||
const key = "test-bocha-secret-key"
|
||
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.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
cfg := wsLoadConfig(t,
|
||
"WEB_SEARCH_PROVIDER=bocha",
|
||
"WEB_SEARCH_BOCHA_API_KEY="+key,
|
||
"WEB_SEARCH_BOCHA_BASE_URL="+srv.URL,
|
||
)
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
tl, err := a.caps.Get("web-search")
|
||
if err != nil {
|
||
t.Fatalf("web-search 不在注册面里: %v", err)
|
||
}
|
||
exec := tl.Value.(interface {
|
||
Execute(context.Context, any) (any, error)
|
||
})
|
||
res, err := exec.Execute(context.Background(), map[string]any{"query": "昔涟"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if m, ok := res.(map[string]any); !ok || m["provider"] != search.NameBocha {
|
||
t.Fatalf("provider 必须如实是 bocha: %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, want Bearer <key>(密钥必须真的被用上)", r.Header.Get("Authorization"))
|
||
}
|
||
default:
|
||
t.Fatal("选定的云 provider 一个请求都没发 —— 配置没接到 provider 上")
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归:
|
||
// 八家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。
|
||
func TestWireIOTools_ProviderVectorSummary(t *testing.T) {
|
||
t.Parallel()
|
||
want := map[string]bool{
|
||
search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true,
|
||
search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true,
|
||
search.NameTencentWSA: true, search.NameMetaso: true,
|
||
}
|
||
got := search.KnownNames()
|
||
if len(got) != len(want) {
|
||
t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan/tencent_wsa/metaso)",
|
||
got, len(want))
|
||
}
|
||
for _, n := range got {
|
||
if !want[n] {
|
||
t.Errorf("登记了计划外的 provider %q", n)
|
||
}
|
||
}
|
||
// 本期明确不实现的 B 档:必须**未登记**(选它 ⇒ 启动报错,而不是静默回落)。
|
||
for _, n := range []string{"tavily", "exa", "firecrawl", "serper", "serpapi"} {
|
||
if search.Known(n) {
|
||
t.Errorf("%q 本期不实现,不该是已登记的名字(设计 §2 B 档)", n)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestWireIOTools_RealSearXNG_ProviderEndToEnd 是 WS1-B 的**真机**端到端:
|
||
// 默认配置(一个 WEB_SEARCH_* 键都不写)⇒ 组合根接线 ⇒ 注册面里的 web-search
|
||
// ⇒ 走 provider 注册表打本机兜底 SearXNG,真的拿到结果,并**如实标注 provider=searxng**。
|
||
//
|
||
// 它只读外部服务,不碰后端进程、不碰仓库根产物。本机兜底的上游只剩 `mwmbl`
|
||
// (英文爬取索引,无中文索引)⇒ 中文查询会「200 但 0 条」,那是外部覆盖面,不是代码红:
|
||
// 探针(裸 HTTP,与工具实现无关)用**同一个查询**先探测,0 条就 skip;
|
||
// 探针有结果而工具没有 = 真红。
|
||
func TestWireIOTools_RealSearXNG_ProviderEndToEnd(t *testing.T) {
|
||
t.Parallel()
|
||
// mwmbl 是英文索引:中文查询在本机兜底上恒为 0 条(分册 08 实测),
|
||
// 因此真机 e2e 用英文查询,才能真的检验「配置 → provider → 结果」这条链。
|
||
const query = "cyrene"
|
||
cfg := wsLoadConfig(t)
|
||
upstream := wsSearxngResultCount(cfg.WebSearch.Endpoint, query)
|
||
if upstream < 0 {
|
||
t.Skipf("本机兜底 SearXNG 不可达(%s),跳过真机 e2e", cfg.WebSearch.Endpoint)
|
||
}
|
||
if upstream == 0 {
|
||
t.Skipf("兜底 SearXNG 当前产不出结果(上游引擎被限流/CAPTCHA),跳过真机 e2e")
|
||
}
|
||
|
||
a := wsSurfaceApp(t, cfg, nil)
|
||
if !wsHasTool(a, "web-search") {
|
||
t.Fatalf("默认配置下 web-search 必须在注册面里,实际工具面 = %v", toolNames(a))
|
||
}
|
||
tl, err := a.caps.Get("web-search")
|
||
if err != nil {
|
||
t.Fatalf("caps.Get(web-search): %v", err)
|
||
}
|
||
exec := tl.Value.(interface {
|
||
Execute(context.Context, any) (any, error)
|
||
})
|
||
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
|
||
if err != nil {
|
||
t.Fatalf("真机查询失败(探针报了 %d 条,工具却报错): %v", upstream, err)
|
||
}
|
||
m, ok := got.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("返回值类型 = %T", got)
|
||
}
|
||
count, _ := m["count"].(int)
|
||
if count == 0 {
|
||
t.Fatalf("裸 HTTP 探针拿到 %d 条、工具却拿到 0 条 —— 这不再是外部降级: %v", upstream, m)
|
||
}
|
||
if m["provider"] != search.NameSearXNG {
|
||
t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", m["provider"], search.NameSearXNG)
|
||
}
|
||
t.Logf("真机 e2e(WS1-B,端点 %s,query=%q):provider=%v 返回 %d 条结果",
|
||
cfg.WebSearch.Endpoint, query, m["provider"], count)
|
||
}
|