package main import ( "bytes" "context" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // 本文件钉住 PLG-4:web-search 的**可变源**(`$DATA_DIR/config/plugins/web-search.json`) // 在组合根里的语义 —— 「先读可变源、缺失回落 `.env` 播种」(design/09 §5.7 E): // `.env` 从此只是**引导面**(缺失时播种一次),面板文件才是配置面。 // // 单独成文件的理由:`wire_web_search_test.go` 已经接近文件行数上限(AGENTS §10), // 而这一组是另一片(PLG-4)的判据,混进去会同时踩两条纪律。 // wsPanelPath 返回某份配置对应的可变源落点。 func wsPanelPath(cfg *config.Config) string { return runtimeconfig.WebSearchConfigPath(cfg.DataDir) } // wsWriteRawPanel 手写一份面板配置(建好父目录;用于模拟人工救回 / 损坏文件)。 func wsWriteRawPanel(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write: %v", err) } } // TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative 是可变源的主判据: // - 文件缺失 ⇒ 由 `.env` 播种落盘(本次行为与 `.env` 一致),且**凭据不进日志**; // - 播种之后 ⇒ 面板文件是**唯一来源**(`.env` 后来改成关闭也不影响它)。 func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) { t.Parallel() dir := t.TempDir() path := runtimeconfig.WebSearchConfigPath(dir) first := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-seed", "WEB_SEARCH_BOCHA_BASE_URL=https://seed.example", ) first.DataDir = dir var buf bytes.Buffer if !wsHasTool(wsSurfaceApp(t, first, &buf), "web-search") { t.Fatal("首次启动(.env 配好 bocha)应在注册面里") } out := buf.String() if !strings.Contains(out, "已按 .env 播种") { t.Fatalf("播种必须留下可诊断日志: %q", out) } if strings.Contains(out, "sk-seed") { t.Fatalf("播种日志泄漏了凭据: %q", out) } f, found, err := runtimeconfig.LoadWebSearch(path) if err != nil || !found { t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err) } if f.Provider != search.NameBocha { t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha) } // 播种内容 = `.env` 字段表那张表(searxng + 5 家云家)。**没有 env 字段的家** // (tencent_wsa / metaso)不进播种:`.env` 已冻结(不加键),它们只从面板文件配。 seededNames := envSearchProviders(first.WebSearch) if len(f.Providers) != len(seededNames) { t.Fatalf("播种必须列全 `.env` 字段表里的家:%d 条, want %d", len(f.Providers), len(seededNames)) } for _, absent := range []string{search.NameTencentWSA, search.NameMetaso} { for _, e := range f.Providers { if strings.EqualFold(e.Provider, absent) { t.Fatalf("没有 env 字段的家 %s 不得被播种(它只从面板文件配)", absent) } } } seeded := false for _, e := range f.Providers { if e.Provider != search.NameBocha { continue } seeded = true if e.APIKey != "sk-seed" || e.BaseURL != "https://seed.example" || !e.IsEnabled() { t.Fatalf("播种的 bocha 条目 = %+v,want .env 的凭据/地址/启用", e) } } if !seeded { t.Fatal("播种里没有 bocha 条目") } // 第二次启动:.env 明确关掉搜索 —— 面板文件仍是唯一来源(.env 只在缺失时播种一次)。 second := wsLoadConfig(t, "WEB_SEARCH_PROVIDER=") second.DataDir = dir if !wsHasTool(wsSurfaceApp(t, second, nil), "web-search") { t.Fatal(".env 改成关闭后,面板文件(bocha 已配)仍应是唯一来源") } } // TestWireIOTools_PanelConfigWinsOverEnv 钉住「面板优先」不是一句口号:同一份 `.env` 下, // 面板把 searxng 指到假上游,查询就必须真的打到那里(`.env` 的默认端点 127.0.0.1:38471 // 根本没被用)。 func TestWireIOTools_PanelConfigWinsOverEnv(t *testing.T) { t.Parallel() hits := make(chan *http.Request, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits <- r w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"title":"A","url":"http://a.example","content":"alpha"}]}`)) })) defer srv.Close() cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameSearXNG, MaxResults: 5, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: srv.URL + "/search", Enabled: runtimeconfig.EnableFlag(true)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } a := wsSurfaceApp(t, cfg, nil) tl, err := a.caps.Get("web-search") if err != nil { t.Fatalf("web-search 不在注册面里: %v", err) } exec, ok := tl.Value.(interface { Execute(context.Context, any) (any, error) }) if !ok { t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value) } if _, err := exec.Execute(context.Background(), map[string]any{"query": "cyrene"}); err != nil { t.Fatalf("Execute: %v", err) } select { case r := <-hits: if r.URL.Path != "/search" { t.Errorf("请求路径 = %q, want /search(面板里那个端点必须被原样使用)", r.URL.Path) } case <-time.After(2 * time.Second): t.Fatal("面板里的端点一个请求都没收到 —— 可变源没被读进去(还在用 .env 的默认端点)") } } // TestWireIOTools_PanelConfigCorruptFallsBackToEnv:非法文件 ⇒ 记 WARN、回落 `.env`、 // **不覆盖**原文件(损坏的配置可以人工救回)。 func TestWireIOTools_PanelConfigCorruptFallsBackToEnv(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-env", ) path := wsPanelPath(cfg) const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}` wsWriteRawPanel(t, path, broken) var buf bytes.Buffer if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") { t.Fatal("面板配置非法时必须回落 .env(这份 .env 里 bocha 是配好的)") } if out := buf.String(); !strings.Contains(out, "面板配置非法") { t.Fatalf("非法文件必须留下可诊断的 WARN: %q", out) } raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } if string(raw) != broken { t.Fatalf("回落 .env 时不得覆盖原文件:%q", raw) } } // TestWireIOTools_PanelConfigUnknownProviderIsWarned 钉住「配了却不生效必须说出来」: // 面板文件里出现本期没登记的家 ⇒ 一条带名字的 WARN(绝不静默忽略),且不拖累整把工具。 func TestWireIOTools_PanelConfigUnknownProviderIsWarned(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameSearXNG, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"}, {Provider: "tavily", APIKey: "sk-not-impl"}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } var buf bytes.Buffer if !wsHasTool(wsSurfaceApp(t, cfg, &buf), "web-search") { t.Fatal("未登记的家不该把整把工具拖下水(它只是被忽略)") } out := buf.String() if !strings.Contains(out, "未登记的提供商") || !strings.Contains(out, "tavily") { t.Fatalf("未登记的家必须留下带名字的 WARN: %q", out) } if strings.Contains(out, "sk-not-impl") { t.Fatalf("WARN 泄漏了凭据: %q", out) } } // TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv 钉住「凭据以文件为准」: // 面板文件里**没有**这一家(或它的 key 为空)= 这家没配,**不得**从 `.env` 的旧 key 复活 // —— 否则「我在面板里删了它」会变成一句假话。主 provider 此时没配齐 ⇒ 按既有语义启动报错。 func TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha, "WEB_SEARCH_BOCHA_API_KEY=sk-env", "WEB_SEARCH_BOCHA_BASE_URL=https://env.example", ) // 面板文件里只留 searxng:bocha 在文件里**不存在**。 if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameBocha, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } err := wsNewTools(t, cfg) if !errors.Is(err, search.ErrNotConfigured) { t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err) } } // TestWireIOTools_PanelSeedNormalizesOffSentinel 钉住播种时的关闭哨兵归一化: // `WEB_SEARCH_ENDPOINT=off` 落盘应写成**空地址**(语义相同:不出这家),而不是把 // 字面量 "off" 当成地址存进面板(面板里不该出现一个不是地址的地址)。 func TestWireIOTools_PanelSeedNormalizesOffSentinel(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG, "WEB_SEARCH_ENDPOINT=off") if wsHasTool(wsSurfaceApp(t, cfg, nil), "web-search") { t.Fatal("端点显式关闭 ⇒ web-search 不得进注册面") } f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg)) if err != nil || !found { t.Fatalf("播种后文件必须在盘上: found=%v err=%v", found, err) } for _, e := range f.Providers { if e.Provider != search.NameSearXNG { continue } if e.BaseURL != "" { t.Fatalf("关闭哨兵必须落成空串,得到 %q", e.BaseURL) } return } t.Fatal("播种里没有 searxng 条目") } // TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured 钉住「停用态真的生效」: // 文件里把 searxng 停用(enabled=false)而它又是主 provider ⇒ 工具不进注册面 // (与 `.env` 里把端点写成 off 的既有语义一致,绝不静默回落到 .env 的端点)。 func TestWireIOTools_PanelConfigDisabledProviderIsNotConfigured(t *testing.T) { t.Parallel() cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), runtimeconfig.WebSearchFile{ Version: runtimeconfig.WebSearchVersion, Provider: search.NameSearXNG, Providers: []runtimeconfig.ProviderEntry{ {Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: runtimeconfig.EnableFlag(false)}, }, }); err != nil { t.Fatalf("写面板配置: %v", err) } var buf bytes.Buffer a := wsSurfaceApp(t, cfg, &buf) if wsHasTool(a, "web-search") { t.Fatalf("searxng 被停用后 web-search 不得进注册面,实际 = %v", toolNames(a)) } for _, name := range []string{"file", "http", "web-fetch", "knowledge", "document-read"} { if !wsHasTool(a, name) { t.Errorf("%q 不得因 web-search 停用而消失", name) } } if out := buf.String(); !strings.Contains(out, "web-search tool not registered") { t.Fatalf("关闭必须留一条可诊断的 WARN: %q", out) } }