package config import ( "os" "path/filepath" "strconv" "strings" "testing" ) // 本文件钉住切片 WS1-B 的搜索配置键(design/27 §3): // WEB_SEARCH_PROVIDER / _FALLBACK / _MAX_RESULTS 与各家独立一组键 // (WEB_SEARCH__API_KEY / _BASE_URL),以及 ResolveProvider 的关闭语义; // 末尾还钉住 `.env.example` 的取值与代码默认值同口径(模板写错就是一句假话)。 // 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=10、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 != 10 { t.Fatalf("WEB_SEARCH_MAX_RESULTS 默认值 = %d, want 10", 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) } }) } } // TestWebSearchMaxResults_EnvExampleMatchesCodeDefault 是「配置事实 ↔ 模板」守卫: // `.env.example` 是给人照抄的模板,它给出的取值必须等于代码默认值 —— 模板写 5 而代码默认 // 已改成 10(2026-10-07 的口径调整漏改了模板),照抄模板的人拿到的就不是默认行为。 // // ⚠️ 只读模板;`.env` 已冻结(基础键不加/不改/不删),本用例不碰它。 func TestWebSearchMaxResults_EnvExampleMatchesCodeDefault(t *testing.T) { cfg, err := wsTestLoad(t) if err != nil { t.Fatalf("Load: %v", err) } raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) if err != nil { t.Fatalf("read .env.example: %v", err) } const key = "WEB_SEARCH_MAX_RESULTS" value, err := strconv.Atoi(wsEnvExampleValue(t, string(raw), key)) if err != nil { t.Fatalf(".env.example 的 %s 取值不是整数: %v", key, err) } if value != cfg.WebSearch.MaxResults { t.Fatalf(".env.example 的 %s = %d,代码默认 = %d(模板与代码必须同口径)", key, value, cfg.WebSearch.MaxResults) } } // wsEnvExampleValue 取 `.env.example` 里某个键的**生效**取值(跳过注释行)。 func wsEnvExampleValue(t *testing.T, raw, key string) string { t.Helper() for _, line := range strings.Split(raw, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") { continue } if rest, ok := strings.CutPrefix(line, key+"="); ok { return strings.TrimSpace(rest) } } t.Fatalf(".env.example 里没有生效的 %s=…(模板必须给出可照抄的取值)", key) return "" }