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 (密钥必须真的被用上)", 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) }