Files
Cyrene_v2/internal/config/websearch_providers_test.go
T
AskaEth 69a3b73777 feat(search): 原生 provider 层(searxng/智谱/阿里IQS/博查/火山/百度)+ 降级链(契约无改动)
主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。
- 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次**
- 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义)
- 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」)
- 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉)
- searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变)
- 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
2026-10-05 22:38:20 +08:00

129 lines
4.7 KiB
Go
Raw 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 config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3):
// WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键
// (WEB_SEARCH_<NAME>_API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义。
// wsTestLoad 写一份只含必填键加 extra 行的 .env,加载出配置。
func wsTestLoad(t *testing.T, extra ...string) (*Config, error) {
t.Helper()
lines := append([]string{"JWT_SECRET=" + strings.Repeat("s", 40)}, extra...)
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
return Load(path)
}
// TestWebSearchProvider_Defaults 钉住三个新键的默认值:provider=searxng、
// maxResults=5、fallback 为空(= 只管手选那一家)。
func TestWebSearchProvider_Defaults(t *testing.T) {
cfg, err := wsTestLoad(t)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WebSearch.Provider != "searxng" {
t.Fatalf("WEB_SEARCH_PROVIDER 默认值 = %q, want searxng", cfg.WebSearch.Provider)
}
if cfg.WebSearch.MaxResults != 5 {
t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 5", cfg.WebSearch.MaxResults)
}
if len(cfg.WebSearch.Fallback) != 0 {
t.Fatalf("WEB_SEARCH_FALLBACK 未配置时应为空, got %v", cfg.WebSearch.Fallback)
}
// 云家的 base URL 有默认值、key 一律为空(未配置 ⇒ 该家不出现在可用清单里)。
if cfg.WebSearch.ZhipuBaseURL == "" || cfg.WebSearch.BochaBaseURL == "" ||
cfg.WebSearch.AliyunIQSBaseURL == "" || cfg.WebSearch.VolcengineBaseURL == "" ||
cfg.WebSearch.BaiduQianfanBaseURL == "" {
t.Fatalf("各家的 BASE_URL 必须有默认值: %+v", cfg.WebSearch)
}
if cfg.WebSearch.ZhipuAPIKey != "" || cfg.WebSearch.BochaAPIKey != "" ||
cfg.WebSearch.AliyunIQSAPIKey != "" || cfg.WebSearch.VolcengineAPIKey != "" ||
cfg.WebSearch.BaiduQianfanAPIKey != "" {
t.Fatalf("各家 API key 未配置时必须为空: %+v", cfg.WebSearch)
}
}
// TestWebSearchProvider_EnvOverrides 钉住新键都能被 .env 覆盖(含逗号分隔的降级链)。
func TestWebSearchProvider_EnvOverrides(t *testing.T) {
cfg, err := wsTestLoad(t,
"WEB_SEARCH_PROVIDER=Bocha",
"WEB_SEARCH_FALLBACK=zhipu,bocha,searxng",
"WEB_SEARCH_MAX_RESULTS=9",
"WEB_SEARCH_ZHIPU_API_KEY=zk",
"WEB_SEARCH_ZHIPU_BASE_URL=https://zhipu.example",
"WEB_SEARCH_ALIYUN_IQS_API_KEY=ak",
"WEB_SEARCH_ALIYUN_IQS_BASE_URL=https://iqs.example",
"WEB_SEARCH_BOCHA_API_KEY=bk",
"WEB_SEARCH_BOCHA_BASE_URL=https://bocha.example",
"WEB_SEARCH_VOLCENGINE_API_KEY=vk",
"WEB_SEARCH_VOLCENGINE_BASE_URL=https://volc.example",
"WEB_SEARCH_BAIDU_QIANFAN_API_KEY=qk",
"WEB_SEARCH_BAIDU_QIANFAN_BASE_URL=https://qianfan.example",
)
if err != nil {
t.Fatalf("Load: %v", err)
}
ws := cfg.WebSearch
if ws.Provider != "Bocha" {
t.Fatalf("Provider = %q(大小写应原样保留,比较时再归一化)", ws.Provider)
}
if got := ws.Fallback; len(got) != 3 || got[0] != "zhipu" || got[2] != "searxng" {
t.Fatalf("Fallback = %v, want [zhipu bocha searxng]", got)
}
if ws.MaxResults != 9 {
t.Fatalf("MaxResults = %d, want 9", ws.MaxResults)
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuAPIKey, "AliyunIQS": ws.AliyunIQSAPIKey, "Bocha": ws.BochaAPIKey,
"Volcengine": ws.VolcengineAPIKey, "BaiduQianfan": ws.BaiduQianfanAPIKey,
} {
if got == "" {
t.Errorf("%s 的 API key 没被读进来", name)
}
}
for name, got := range map[string]string{
"Zhipu": ws.ZhipuBaseURL, "AliyunIQS": ws.AliyunIQSBaseURL, "Bocha": ws.BochaBaseURL,
"Volcengine": ws.VolcengineBaseURL, "BaiduQianfan": ws.BaiduQianfanBaseURL,
} {
if !strings.HasSuffix(got, ".example") {
t.Errorf("%s 的 BASE_URL 没被覆盖: %q", name, got)
}
}
}
// TestWebSearchConfig_ResolveProvider 是关闭语义的表驱动:空 / off / none ⇒ 关闭
// (组合根据此不注册工具);其余 ⇒ 选定名。
func TestWebSearchConfig_ResolveProvider(t *testing.T) {
cases := []struct {
name string
value string
wantDisable bool
wantSel string
}{
{"默认 searxng", "searxng", false, "searxng"},
{"大写 Zhipu", " Zhipu ", false, "Zhipu"},
{"空串 = 关闭", "", true, ""},
{"off", "off", true, ""},
{"none", "NONE", true, ""},
{"带空白的 None", " none ", true, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := WebSearchConfig{Provider: tc.value}.ResolveProvider()
if got.Disabled != tc.wantDisable || got.Selected != tc.wantSel {
t.Fatalf("ResolveProvider(%q) = %+v, want disabled=%v selected=%q",
tc.value, got, tc.wantDisable, tc.wantSel)
}
})
}
}