Files
Cyrene_v2/cmd/cyrene/wire_web_search_panel_test.go
T
AskaEth ce9af42945
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 新增腾讯云·元宝搜索与秘塔搜索两家 provider
接入位表六家扩到八家,端点形状与错误码一字未改(契约 v0.87,仅两处名单)。

- tencent_wsa:POST {base}/SearchPro + Authorization Bearer,零签名。
  两条官方口径决定实现:HTTP 状态码恒 200(判失败只能读 Response.Error.Code);
  Pages 是「JSON 字符串数组」需二次解析。错误码按官方表分类
  (UnauthorizedOperation/AuthFailure.* = 凭据、ResourceUnavailable = 欠费、
  RequestLimitExceeded* = 限流),文案只带 code 不带上游 Message。
- metaso:POST {base}/api/v1/search,响应 schema 仅第三方来源且互相打架,
  实现走宽松解析(数组候选与字段名回退),逐处标「未实测」。
- 零新 env 键:两家只走 $DATA_DIR/config/plugins/web-search.json,
  .env / .env.example 未动;组合根 websearch_providers.go 零功能改动。
- 契约 v0.87:values.provider 合法闭集与 plugin.providers 候选各加两条,
  计数不变(14 + 25 + 40 = 79)。
2026-10-07 13:42:41 +08:00

297 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
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)
}
}