Files
Cyrene_v2/cmd/cyrene/websearch_selection_test.go
T
AskaEth a30f4a49ff feat(search): provider 热重载 + primary/fallback(W3)
- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
2026-10-06 15:59:12 +08:00

283 lines
12 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 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)
}
}