package main // 本文件钉住切片 W3 的另外三条判据(`DECISIONS-2026-10-06` 裁决 #2 / 点 ③ / 点 ④): // // - **主 provider / 降级链可配**:两个文件级字段(`primary` / `fallback`)经表单落到 // 可变源,回填(reauth)也带着它们;没配齐的主家**写盘之前**就拒掉; // - **主 provider 解析口径**(配了可变源 / 只配 env / 都没配)各选谁; // - **探针失败如实**:非 2xx + 结构化码,不存在「200 带 ok:false」这条路径。 // // 夹具与假上游在 websearch_testenv_test.go;热重载本体在 websearch_reload_test.go。 import ( "bytes" "context" "encoding/json" "net/http" "strings" "testing" pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // --- ① 面板校验:选一个没配齐的主家当场拒掉 --------------------------------- // TestWebSearchSelectionRejectsUnusablePrimary 钉住「写盘之前就拦」:主 provider 选了 // 一家没配齐的(缺 key)必须回 400 primary_not_configured,且**盘上一个字节都没变** // (这不是「存下来再说」,那份配置会让后端下次起不来)。 func TestWebSearchSelectionRejectsUnusablePrimary(t *testing.T) { env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) before := env.rawFile(t) _, err := env.save(map[string]any{ "provider": search.NameSearXNG, "enabled": true, "primary": search.NameZhipu, }) ae, ok := pluginmgr.AsActionError(err) if !ok || ae.Code != actionCodePrimaryNotConfigured || ae.Status != http.StatusBadRequest { t.Fatalf("err = %#v, want 400 %s", err, actionCodePrimaryNotConfigured) } if got := env.rawFile(t); !bytes.Equal(got, before) { t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got) } // 未登记的名字:与条目级保存同一个码同一句口径。 _, err = env.save(map[string]any{ "provider": search.NameSearXNG, "enabled": true, "primary": "tavily", }) if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider { t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider) } // 降级链里的未登记名字同样拒(它永远不会被选中,存下去就是假话)。 _, err = env.save(map[string]any{ "provider": search.NameSearXNG, "enabled": true, "fallback": []any{"tavily"}, }) if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != actionCodeUnknownProvider { t.Fatalf("err = %#v, want %s", err, actionCodeUnknownProvider) } // 形状错(不是数组)⇒ 400,绝不静默当空数组清空降级链。 _, err = env.save(map[string]any{ "provider": search.NameSearXNG, "enabled": true, "fallback": 42, }) if ae, ok := pluginmgr.AsActionError(err); !ok || ae.Code != pluginmgr.ActionCodeInvalidPayload { t.Fatalf("err = %#v, want %s", err, pluginmgr.ActionCodeInvalidPayload) } } // TestWebSearchSelectionAcceptsConfiguredCloudFallback 钉住「降级链允许放还没配齐的家」: // 未配置的家在运行期会被跳过(不是错误),所以面板存得下去;而主家必须是配齐的。 // 同时钉住归一化:去空白、去重、丢空项。 func TestWebSearchSelectionAcceptsConfiguredCloudFallback(t *testing.T) { up := newWSFakeSearxng(t, "a") env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+up.url, ) env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "fallback": []any{search.NameZhipu, " bocha ", search.NameZhipu, ""}, }) if got := env.loadFile(t).Fallback; strings.Join(got, ",") != search.NameZhipu+","+search.NameBocha { t.Fatalf("降级链落盘 = %v, want [zhipu bocha]", got) } } // TestWebSearchReauthEchoesSelection:表单回填必须带上两个文件级字段,否则用户 // 「清空降级链」在前端的三态写回里会落成「缺位 = 不改动」。 func TestWebSearchReauthEchoesSelection(t *testing.T) { up := newWSFakeSearxng(t, "a") env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT="+up.url, ) env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "primary": search.NameSearXNG, "fallback": []any{search.NameBocha}, }) out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionReauthProvider, Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}}, }) if err != nil { t.Fatalf("reauth: %v", err) } values, _ := out.(map[string]any)["values"].(map[string]any) if values == nil { t.Fatalf("reauth 结果缺 values: %#v", out) } if values[search.FieldPrimary] != search.NameSearXNG { t.Fatalf("回填的 primary = %v, want %q", values[search.FieldPrimary], search.NameSearXNG) } fb, ok := values[search.FieldFallback].([]string) if !ok || strings.Join(fb, ",") != search.NameBocha { t.Fatalf("回填的 fallback = %#v, want [%s]", values[search.FieldFallback], search.NameBocha) } } // --- ② 主 provider 解析口径(三情形 + 清空)--------------------------------- // TestWebSearchPrimaryResolutionFromEnvOnly:**只配 `.env`**(可变源文件缺失)⇒ 选 `.env` // 那家,来源标 `env`,且播种真的落了盘。 func TestWebSearchPrimaryResolutionFromEnvOnly(t *testing.T) { var out bytes.Buffer cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-env", ) wsSurfaceApp(t, cfg, &out) if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha { t.Fatalf("primary = %q, want %q(.env 是唯一来源)", got, search.NameBocha) } if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourceEnv { t.Fatalf("configSource = %q, want %q", got, webSearchSourceEnv) } if _, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)); err != nil || !found { t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err) } } // TestWebSearchPrimaryResolutionPanelWins:**配了可变源** ⇒ 选文件里那家(`.env` 说的 // 另一家不作数),来源标 panel。 func TestWebSearchPrimaryResolutionPanelWins(t *testing.T) { var out bytes.Buffer cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameZhipu, "WEB_SEARCH_ZHIPU_API_KEY=sk-env-zhipu", ) if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameBocha, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } a := wsSurfaceApp(t, cfg, &out) if !wsHasTool(a, webSearchToolName) { t.Fatalf("面板里 bocha 已配齐,工具应在注册面里: %v", toolNames(a)) } if got := wsLogField(t, out.String(), "primary"); got != search.NameBocha { t.Fatalf("primary = %q, want %q(面板文件是唯一来源,.env 说的 zhipu 不作数)", got, search.NameBocha) } if got := wsLogField(t, out.String(), "configSource"); got != webSearchSourcePanel { t.Fatalf("configSource = %q, want %q", got, webSearchSourcePanel) } } // TestWebSearchPrimaryResolutionDefaultsToSearxng:**都没配** ⇒ 代码默认 searxng // (本机兜底端点),工具照常在注册面里。 func TestWebSearchPrimaryResolutionDefaultsToSearxng(t *testing.T) { var out bytes.Buffer cfg := wsLoadConfig(t) a := wsSurfaceApp(t, cfg, &out) if !wsHasTool(a, webSearchToolName) { t.Fatalf("都没配时代码默认(searxng 本机兜底)应当生效: %v", toolNames(a)) } if got := wsLogField(t, out.String(), "primary"); got != search.NameSearXNG { t.Fatalf("primary = %q, want %q(代码默认值)", got, search.NameSearXNG) } } // TestWebSearchPrimaryResolutionClearedFileMeansOff:文件里把 provider 清空 ⇒ **关闭** // (不是回落 `.env`)——「清空 = 关」是文件形状里写死的语义,面板与 `.env` 不许有两套。 func TestWebSearchPrimaryResolutionClearedFileMeansOff(t *testing.T) { var out bytes.Buffer cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-env", ) if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, // 显式清空主 provider:哨兵语义 = 关掉整把工具。 Provider: "off", Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameBocha, APIKey: "sk-panel", Enabled: runtimeconfig.EnableFlag(true)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } a := wsSurfaceApp(t, cfg, &out) if wsHasTool(a, webSearchToolName) { t.Fatalf("文件里显式关闭(off)⇒ 工具不得进注册面: %v", toolNames(a)) } if out := out.String(); !strings.Contains(out, "web-search tool not registered") { t.Fatalf("关闭必须留下可诊断的 WARN: %q", out) } } // --- ③ 探针失败如实:非 2xx + 结构化码,绝不「200 带 ok:false」---------------- // TestWebSearchProbeFailuresAreStructuredNon2xx 钉住裁决 #4(点 ④):每一次探针失败都必须是 // **结构化非 2xx**。前端行内动作只在失败时显示文案,把「探不通」放进 200 的 data 里 // 等于让界面报一句假成功。 // // 探针的成功/失败分类由 search.Probe 给出(那里已有单测),这里查的是**动作面**有没有 // 把失败原样交出去:码稳定、状态码落在 4xx/5xx、文案不带上游原文。 func TestWebSearchProbeFailuresAreStructuredNon2xx(t *testing.T) { cases := []struct { name string status int body string wantCode string want int }{ {"凭据被拒", http.StatusUnauthorized, `{"error":"bad key sk-leak"}`, search.ProbeUnauthorized, http.StatusBadGateway}, {"被限流", http.StatusTooManyRequests, `{}`, search.ProbeRateLimited, http.StatusBadGateway}, {"上游 5xx", http.StatusInternalServerError, `{}`, search.ProbeUpstream, http.StatusBadGateway}, {"应答不是预期结构", http.StatusOK, `not json`, search.ProbeBadResponse, http.StatusBadGateway}, {"连通但零结果", http.StatusOK, `{"results":[]}`, search.ProbeNoResults, http.StatusBadGateway}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { up := newWSFakeUpstream(t, tc.status, tc.body) env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) env.mustSave(t, map[string]any{ "provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, }) out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionTestProvider, Payload: map[string]any{"row": map[string]any{"provider": search.NameSearXNG}}, }) if err == nil { // 「200 带 ok:false」正是这条判据要堵的路:要么成功(ok=true 的结果), // 要么结构化失败,没有第三种。 if res, ok := out.(search.ProbeResult); ok && !res.OK { t.Fatalf("探针失败却回了成功响应(200 + ok:false):%+v", res) } t.Fatalf("探针失败却回了 200(结果 = %#v)", out) } ae, ok := pluginmgr.AsActionError(err) if !ok { t.Fatalf("err = %v,期望结构化 ActionError", err) } if ae.Code != tc.wantCode { t.Fatalf("失败码 = %q, want %q", ae.Code, tc.wantCode) } if ae.Status != tc.want { t.Fatalf("失败状态 = %d, want %d(非 2xx 才算如实)", ae.Status, tc.want) } raw, _ := json.Marshal(map[string]any{"code": ae.Code, "message": ae.Message}) if strings.Contains(string(raw), "sk-leak") { t.Fatalf("失败文案泄漏了上游原文: %s", raw) } }) } } // TestWebSearchProbeNotConfiguredIs400:没配齐是**调用方/配置**的问题(400), // 不是上游不可用(502)—— 前端据此区分「该改配置」还是「该重试」。 func TestWebSearchProbeNotConfiguredIs400(t *testing.T) { env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) _, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{ PluginID: search.PanelPluginID, Action: search.ActionTestProvider, Payload: map[string]any{"row": map[string]any{"provider": search.NameZhipu}}, }) ae, ok := pluginmgr.AsActionError(err) if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest { t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured) } } // TestWebSearchSaveProviderAndPrimaryInOneSubmit 是 2026-10-07 真机报障的回归: // 面板上第一次给某家填 key、又在**同一次提交**里把它选为主 provider,必须**一次成功**。 // // 缺陷原形:主 provider 的可用性校验拿的是**盘上的旧文件**(那条 provider 还没 key) // ⇒ 整笔(**连同刚填的 key**)被 400 primary_not_configured 回滚,界面上表现为 // 「怎么填都存不进去」,而盘上文件 mtime 一动不动。修法:校验移到「本次条目已并进 // 文件之后」(websearch_action_data.go 的 saveProvider 先 UpsertProviderEntry 再 check)。 func TestWebSearchSaveProviderAndPrimaryInOneSubmit(t *testing.T) { env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) // 前置:盘上那条 zhipu 没有 key —— 面板第一次配置它时正是这个状态。 if e, ok := findProviderEntry(env.loadFile(t), search.NameZhipu); !ok || strings.TrimSpace(e.APIKey) != "" { t.Fatalf("前置不成立:盘上 zhipu 应存在且尚无 key(found=%v, key=%q)", ok, e.APIKey) } if _, err := env.save(map[string]any{ "provider": search.NameZhipu, "apiKey": "test-key-001", "enabled": true, "primary": search.NameZhipu, }); err != nil { t.Fatalf("同一次提交里填 key + 选它当主家被拒(键与选择必须能一起落盘): %v", err) } after := env.loadFile(t) if got := strings.ToLower(strings.TrimSpace(after.Provider)); got != search.NameZhipu { t.Fatalf("盘上 primary = %q, want %q", got, search.NameZhipu) } entry, ok := findProviderEntry(after, search.NameZhipu) if !ok || entry.APIKey != "test-key-001" { t.Fatalf("盘上 zhipu 条目 = %+v, want 带本次填的 key", entry) } // 反向护栏:**真的没配齐**的主家仍必须被拒,且盘上一个字节都不动。 // (本次修复只改「校验看哪一份文件」,不改判据。) beforeReject := env.rawFile(t) _, err := env.save(map[string]any{ "provider": search.NameBocha, "enabled": true, "primary": search.NameBocha, }) ae, ok := pluginmgr.AsActionError(err) if !ok || ae.Code != actionCodePrimaryNotConfigured { t.Fatalf("err = %#v, want 400 %s(bocha 这次没填 key)", err, actionCodePrimaryNotConfigured) } if got := env.rawFile(t); !bytes.Equal(got, beforeReject) { t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", beforeReject, got) } }