主人报障:「为什么中文搜索接口……下拉菜单里全给的英文名?你甚至不愿意带个括号
把中文名写上去」。根因不是漏写文案,而是整条链路没有「显示名」的位置:字段
声明只有 optionsFrom,候选动作回的就是 KnownNames() 的纯 id 数组。
- 后端:候选改为对象数组 {value,label};label 表与接入位表同源(键用
NameSearXNG 等常量),未知 id 原样回。value 逐字等于 KnownNames()。
- 前端:下拉/多选「显示 label、写 value」;表格单元格按 value→label 索引显示,
查不到回落显示原值。多选从裸输入框改成「候选开关 + 落盘值输入框」。
解码器本就兼容裸字符串/{value,label}/{id} 三形状 ⇒ 其他插件零影响。
中文名逐字:SearXNG 本地兜底(searxng)/ 智谱 BigModel(zhipu)/
阿里云 IQS 信息查询(aliyun_iqs)/ 博查 Bocha(bocha)/
火山引擎·豆包搜索(volcengine)/ 百度千帆 AI 搜索(baidu_qianfan)。
验收(主会话复跑 + 亲自变异):go build ./... + vet 绿;search 包测试绿;
tsc --noEmit 0 错;新渲染测试 26 checks + 既有 plugin panel 55 / data 90 /
schema values 40 / channel config 70 全绿。我用 sed 把 values.ts 的
option.value 全换成 option.label ⇒ 渲染测试红(「点选把候选项的 id 追加到
降级链末尾」got="bocha, 智谱 BigModel(zhipu)")—— 这正是会把配置文件写坏的
错法;还原后 md5 一致且绿。
⚠️ 契约面变更(我在 Docs/api-client.md 登记):plugin.providers 候选响应形状
字符串数组 → 对象数组;label 仅供显示、value 才是落盘值。
⚠️ 已知边界:viewer 角色看不到 form 区块 ⇒ 表格对 viewer 仍显示裸 id(未做)。
⚠️ 真机浏览器点击未跑(项目无 jsdom)⇒ 建议刷新管理页点一次确认观感。
467 lines
19 KiB
Go
467 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// 本文件钉住切片 L1/PLG-2 的**执行面接线**:内置插件 web-search 的四个动作 +
|
||
// 下拉候选来源真的被登记到插件管理器上,并且真的能读写可变源、真发一次探针请求。
|
||
//
|
||
// 为什么在组合根测:处理器与可变源的**接线事实**(哪个文件、哪份默认值、哪个客户端)
|
||
// 只在这里成立;插件管理器的单测只能证明「登记面会路由」,证明不了「搜索页点得动」。
|
||
|
||
// actionTestEnv 是一个接好面板中枢 + 动作处理器的组合根夹具。
|
||
type actionTestEnv struct {
|
||
app *app
|
||
mgr *pluginmgr.Manager
|
||
hub *pluginmgr.Panels
|
||
// path 是可变源落点($DATA_DIR/config/plugins/web-search.json)。
|
||
path string
|
||
}
|
||
|
||
// newActionTestEnv 按组合根的**同一条路**接线:wirePanelHub(声明 + 动作处理器登记)。
|
||
// extra 是额外的 .env 行,用来控制代码默认值(如 WEB_SEARCH_PROVIDER)。
|
||
func newActionTestEnv(t *testing.T, extra ...string) *actionTestEnv {
|
||
t.Helper()
|
||
cfg := wsLoadConfig(t, extra...)
|
||
b := bus.New()
|
||
t.Cleanup(func() { _ = b.Close() })
|
||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||
t.Cleanup(func() { mgr.Close(context.Background()) })
|
||
|
||
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
|
||
wirePanelHub(a)
|
||
hub := mgr.Panels()
|
||
if hub == nil {
|
||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||
}
|
||
return &actionTestEnv{app: a, mgr: mgr, hub: hub, path: runtimeconfig.WebSearchConfigPath(cfg.DataDir)}
|
||
}
|
||
|
||
// dispatch 调一次动作(走生产同一条 Dispatch:声明 → 启用 → 处理器)。
|
||
func (e *actionTestEnv) dispatch(t *testing.T, action string, payload any) (any, error) {
|
||
t.Helper()
|
||
return e.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||
PluginID: search.PanelPluginID,
|
||
Action: action,
|
||
Payload: payload,
|
||
})
|
||
}
|
||
|
||
// mustDispatch 调一次动作并要求成功。
|
||
func (e *actionTestEnv) mustDispatch(t *testing.T, action string, payload any) any {
|
||
t.Helper()
|
||
out, err := e.dispatch(t, action, payload)
|
||
if err != nil {
|
||
t.Fatalf("dispatch %s: %v", action, err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// saveProvider 走保存动作(表单形状 `{values:{...}}`)。
|
||
func (e *actionTestEnv) saveProvider(t *testing.T, values map[string]any) any {
|
||
t.Helper()
|
||
return e.mustDispatch(t, search.ActionSaveProvider, map[string]any{"values": values})
|
||
}
|
||
|
||
// rowsOf 把列表动作的结果解成面板行。
|
||
func (e *actionTestEnv) rowsOf(t *testing.T) []search.ProviderRow {
|
||
t.Helper()
|
||
out := e.mustDispatch(t, search.ActionListProviders, map[string]any{})
|
||
m, ok := out.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("列表结果类型 = %T", out)
|
||
}
|
||
rows, ok := m["rows"].([]search.ProviderRow)
|
||
if !ok {
|
||
t.Fatalf("列表结果缺 rows: %#v", m)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// rowOf 取某一家在表格里的那一行(找不到则失败)。
|
||
func (e *actionTestEnv) rowOf(t *testing.T, provider string) search.ProviderRow {
|
||
t.Helper()
|
||
for _, r := range e.rowsOf(t) {
|
||
if r.Provider == provider {
|
||
return r
|
||
}
|
||
}
|
||
t.Fatalf("表格里没有 %q 这一行(rows = %+v)", provider, e.rowsOf(t))
|
||
return search.ProviderRow{}
|
||
}
|
||
|
||
// loadFile 读盘上的可变源(断言「真的落盘了」时用)。
|
||
func (e *actionTestEnv) loadFile(t *testing.T) runtimeconfig.WebSearchFile {
|
||
t.Helper()
|
||
f, found, err := runtimeconfig.LoadWebSearch(e.path)
|
||
if err != nil {
|
||
t.Fatalf("LoadWebSearch(%s): %v", e.path, err)
|
||
}
|
||
if !found {
|
||
t.Fatalf("可变源不在盘上: %s", e.path)
|
||
}
|
||
return f
|
||
}
|
||
|
||
// entryOf 取盘上某家的条目。
|
||
func (e *actionTestEnv) entryOf(t *testing.T, provider string) runtimeconfig.ProviderEntry {
|
||
t.Helper()
|
||
for _, p := range e.loadFile(t).Providers {
|
||
if p.Provider == provider {
|
||
return p
|
||
}
|
||
}
|
||
t.Fatalf("盘上没有 %q 条目", provider)
|
||
return runtimeconfig.ProviderEntry{}
|
||
}
|
||
|
||
// TestWirePanelHubRegistersBuiltinActions 是接线的主判据:内置插件的四个动作与
|
||
// 下拉候选来源都进了登记面,且每一个都能真的被 Dispatch 执行(不再回 501)。
|
||
func TestWirePanelHubRegistersBuiltinActions(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
|
||
want := append([]string(nil), search.ActionNames()...)
|
||
want = append(want, pluginmgr.OptionsActionName("plugin:providers"))
|
||
registered := env.hub.ActionHandlerNames(search.PanelPluginID)
|
||
sort.Strings(want)
|
||
if strings.Join(registered, ",") != strings.Join(want, ",") {
|
||
t.Fatalf("已登记的处理器 = %v, want %v", registered, want)
|
||
}
|
||
// 每个已登记的动作都必须**已声明**(否则登记它等于登记一个永远调不到的名字)。
|
||
for _, name := range registered {
|
||
if _, ok := env.hub.Action(search.PanelPluginID, name); !ok {
|
||
t.Errorf("动作 %q 已登记但没声明(永远不可达)", name)
|
||
}
|
||
}
|
||
// 声明里的每一个动作都必须有处理器 —— 否则面板上会有一个点了回 501 的按钮。
|
||
for _, name := range env.hub.DeclaredActions(search.PanelPluginID) {
|
||
if _, err := env.dispatch(t, name, nil); errors.Is(err, pluginmgr.ErrActionUnavailable) {
|
||
t.Errorf("声明过的动作 %q 没有处理器(会回 501)", name)
|
||
}
|
||
}
|
||
// 列表动作也必须真的执行(此时可变源还没配过 ⇒ 空表不是错误)。
|
||
if rows := env.rowsOf(t); len(rows) != 0 {
|
||
t.Fatalf("没配过任何一家时列表应为空,实际 %+v", rows)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchListProvidersShowsStoredEntries 锁定列表动作读的是**可变源**
|
||
// (而不是种子/默认值):写进盘里的条目与状态都如实出现,且不含任何凭据。
|
||
func TestWebSearchListProvidersShowsStoredEntries(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
env.saveProvider(t, map[string]any{
|
||
"provider": search.NameBocha, "apiKey": "sk-bocha-secret",
|
||
"baseUrl": "https://api.bocha.cn", "enabled": true,
|
||
})
|
||
|
||
rows := env.rowsOf(t)
|
||
byName := make(map[string]search.ProviderRow, len(rows))
|
||
for _, r := range rows {
|
||
byName[r.Provider] = r
|
||
}
|
||
if got := byName[search.NameBocha]; got.State != search.StateOK || !got.Enabled {
|
||
t.Fatalf("bocha 行 = %+v, want state=ok 且启用", got)
|
||
}
|
||
raw, err := json.Marshal(rows)
|
||
if err != nil {
|
||
t.Fatalf("marshal rows: %v", err)
|
||
}
|
||
if strings.Contains(string(raw), "sk-bocha-secret") {
|
||
t.Fatalf("列表动作泄漏了凭据: %s", raw)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchSaveProviderHonoursSecretSentinel 锁定写侧的三态哨兵语义
|
||
// (§5.4.2:空字符串 ≠ 不改动):
|
||
// - apiKey 缺位 ⇒ 原值保留(面板只持有掩码,留空必须真的不改);
|
||
// - apiKey 显式空串 ⇒ 清空;
|
||
// - enabled / baseUrl 缺位 ⇒ 保留。
|
||
func TestWebSearchSaveProviderHonoursSecretSentinel(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
env.saveProvider(t, map[string]any{
|
||
"provider": search.NameBocha, "apiKey": "sk-first",
|
||
"baseUrl": "https://api.bocha.cn", "enabled": true,
|
||
})
|
||
|
||
// 只改地址(apiKey / enabled 缺位)⇒ 凭据与启用态一个都不许动。
|
||
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "baseUrl": "https://mirror.bocha.cn"})
|
||
got := env.entryOf(t, search.NameBocha)
|
||
if got.APIKey != "sk-first" {
|
||
t.Fatalf("apiKey 缺位时被改动了: %q(哨兵语义失效 = 一次保存静默清空凭据)", got.APIKey)
|
||
}
|
||
if got.BaseURL != "https://mirror.bocha.cn" || !got.IsEnabled() {
|
||
t.Fatalf("条目 = %+v, want 新地址 + 仍启用", got)
|
||
}
|
||
|
||
// 显式停用 + 显式清空凭据(前端只有勾了清除才会送出空串)。
|
||
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": "", "enabled": false})
|
||
got = env.entryOf(t, search.NameBocha)
|
||
if got.APIKey != "" {
|
||
t.Fatalf("显式空串必须清空凭据,得到 %q", got.APIKey)
|
||
}
|
||
if got.IsEnabled() {
|
||
t.Fatal("显式 enabled=false 必须落盘(停用态)")
|
||
}
|
||
if state := env.rowOf(t, search.NameBocha).State; state != search.StateDisabled {
|
||
t.Fatalf("停用后 state = %q, want %q", state, search.StateDisabled)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchReauthProviderReturnsMaskedValues 锁定 reauth「把该条带回表单」:
|
||
// 返回显式 values(前端 formValuesOfResult 只认 values/formValues),
|
||
// 且 apiKey 只给**掩码** —— 读侧永不回明文(§5.4.2)。
|
||
func TestWebSearchReauthProviderReturnsMaskedValues(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
env.saveProvider(t, map[string]any{
|
||
"provider": search.NameBocha, "apiKey": "sk-abcdefgh",
|
||
"baseUrl": "https://api.bocha.cn", "enabled": true,
|
||
})
|
||
|
||
out := env.mustDispatch(t, search.ActionReauthProvider, map[string]any{
|
||
"row": map[string]any{"provider": search.NameBocha, "state": search.StateOK},
|
||
})
|
||
m, ok := out.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("reauth 结果类型 = %T", out)
|
||
}
|
||
values, ok := m["values"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("reauth 结果缺 values: %#v", m)
|
||
}
|
||
if values["provider"] != search.NameBocha || values["baseUrl"] != "https://api.bocha.cn" {
|
||
t.Fatalf("回填的字段 = %#v", values)
|
||
}
|
||
if values["enabled"] != true {
|
||
t.Fatalf("enabled 回填 = %#v, want true", values["enabled"])
|
||
}
|
||
masked, _ := values["apiKey"].(string)
|
||
if masked == "" || masked == "sk-abcdefgh" {
|
||
t.Fatalf("apiKey 回填 = %q,必须是掩码(读侧无明文)", masked)
|
||
}
|
||
if !strings.HasSuffix(masked, "efgh") {
|
||
t.Fatalf("掩码口径应与宿主的 maskToken 同源(尾 4 位),得到 %q", masked)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchTestProviderHitsRealHTTP 是探针的判据:**真发一次请求**到盘上那条
|
||
// 配置指的地址,用真应答区分「通了」与「没通」。
|
||
func TestWebSearchTestProviderHitsRealHTTP(t *testing.T) {
|
||
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()
|
||
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
env.saveProvider(t, map[string]any{
|
||
"provider": search.NameSearXNG, "baseUrl": srv.URL + "/search", "enabled": true,
|
||
})
|
||
|
||
out := env.mustDispatch(t, search.ActionTestProvider, map[string]any{
|
||
"row": map[string]any{"provider": search.NameSearXNG},
|
||
})
|
||
res, ok := out.(search.ProbeResult)
|
||
if !ok {
|
||
t.Fatalf("探针结果类型 = %T", out)
|
||
}
|
||
if !res.OK || res.Count != 1 {
|
||
t.Fatalf("探针结论 = %+v, want ok 且 1 条", res)
|
||
}
|
||
select {
|
||
case r := <-hits:
|
||
if r.URL.Path != "/search" || !strings.Contains(r.URL.RawQuery, "q=cyrene") {
|
||
t.Fatalf("上游收到的请求 = %s?%s(探针必须真的搜一次)", r.URL.Path, r.URL.RawQuery)
|
||
}
|
||
default:
|
||
t.Fatal("上游一个请求都没收到 —— 探针没真发请求")
|
||
}
|
||
}
|
||
|
||
// TestWebSearchTestProviderReportsFailureHonestly 锁定失败口径:上游拒了凭据 ⇒
|
||
// 结构化非 2xx(码 = 探针码,文案固定不含上游原文),绝不回一句假的「已完成」。
|
||
func TestWebSearchTestProviderReportsFailureHonestly(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"bad key sk-should-not-leak"}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
env.saveProvider(t, map[string]any{
|
||
"provider": search.NameSearXNG, "baseUrl": srv.URL, "enabled": true,
|
||
})
|
||
|
||
_, err := env.dispatch(t, search.ActionTestProvider, map[string]any{
|
||
"row": map[string]any{"provider": search.NameSearXNG},
|
||
})
|
||
ae, ok := pluginmgr.AsActionError(err)
|
||
if !ok {
|
||
t.Fatalf("探针失败 err = %v,期望结构化 ActionError", err)
|
||
}
|
||
if ae.Code != search.ProbeUnauthorized {
|
||
t.Fatalf("失败码 = %q, want %q", ae.Code, search.ProbeUnauthorized)
|
||
}
|
||
if ae.Status != http.StatusBadGateway {
|
||
t.Fatalf("失败状态 = %d, want 502(上游失败)", ae.Status)
|
||
}
|
||
if strings.Contains(ae.Message, "sk-should-not-leak") || ae.Message == "" {
|
||
t.Fatalf("失败文案 = %q(不得含上游原文,也不得为空)", ae.Message)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchTestProviderNotConfiguredIsClientError 锁定「没配齐」归 400:
|
||
// 这是调用方/配置的问题,不是上游不可用(前端据此区分该改配置还是该重试)。
|
||
func TestWebSearchTestProviderNotConfiguredIsClientError(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
// 存一条**没配齐**的(显式清空 key):探针必须在发请求**之前**就报 not_configured,
|
||
// 绝不拿一个没有凭据的配置去戳上游。
|
||
env.saveProvider(t, map[string]any{"provider": search.NameBocha, "apiKey": ""})
|
||
|
||
_, err := env.dispatch(t, search.ActionTestProvider, map[string]any{
|
||
"row": map[string]any{"provider": search.NameBocha},
|
||
})
|
||
ae, ok := pluginmgr.AsActionError(err)
|
||
if !ok || ae.Code != search.ProbeNotConfigured || ae.Status != http.StatusBadRequest {
|
||
t.Fatalf("err = %#v, want 400 not_configured", err)
|
||
}
|
||
}
|
||
|
||
// TestWebSearchActionsRejectUnknownProviderAndBadPayload 锁定动作的两类调用方错误
|
||
// 都被**显式拒绝**(不静默存一条永远选不中的配置,也不把类型错当成空值)。
|
||
func TestWebSearchActionsRejectUnknownProviderAndBadPayload(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
|
||
cases := []struct {
|
||
name string
|
||
action string
|
||
payload any
|
||
wantCode string
|
||
}{
|
||
{"保存未登记的家", search.ActionSaveProvider,
|
||
map[string]any{"values": map[string]any{"provider": "tavily", "apiKey": "sk-x"}}, actionCodeUnknownProvider},
|
||
{"保存缺 provider", search.ActionSaveProvider,
|
||
map[string]any{"values": map[string]any{"apiKey": "sk-x"}}, pluginmgr.ActionCodeInvalidPayload},
|
||
{"保存缺 values 对象", search.ActionSaveProvider,
|
||
map[string]any{"apiKey": "sk-x"}, pluginmgr.ActionCodeInvalidPayload},
|
||
{"保存 enabled 类型错", search.ActionSaveProvider,
|
||
map[string]any{"values": map[string]any{"provider": search.NameBocha, "enabled": "yes"}}, pluginmgr.ActionCodeInvalidPayload},
|
||
{"探针未登记的家", search.ActionTestProvider,
|
||
map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider},
|
||
{"探针缺 provider", search.ActionTestProvider, map[string]any{}, pluginmgr.ActionCodeInvalidPayload},
|
||
{"reauth 未登记的家", search.ActionReauthProvider,
|
||
map[string]any{"row": map[string]any{"provider": "tavily"}}, actionCodeUnknownProvider},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
_, err := env.dispatch(t, tc.action, tc.payload)
|
||
ae, ok := pluginmgr.AsActionError(err)
|
||
if !ok {
|
||
t.Fatalf("err = %v,期望结构化 ActionError", err)
|
||
}
|
||
if ae.Code != tc.wantCode || ae.Status != http.StatusBadRequest {
|
||
t.Fatalf("err = %+v, want code=%q status=400", ae, tc.wantCode)
|
||
}
|
||
})
|
||
}
|
||
// 被拒的保存不得在盘上留下痕迹(没写成功时文件可能根本不存在 —— 那也是「没留下」)。
|
||
if f, found, err := runtimeconfig.LoadWebSearch(env.path); err == nil && found {
|
||
for _, p := range f.Providers {
|
||
if p.Provider == "tavily" {
|
||
t.Fatal("未登记的家被写进了可变源")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestWebSearchOptionsActionFeedsDeclaredOptionsFrom 锁死「声明的下拉一定拉得到候选」:
|
||
// 声明里每个 optionsFrom 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
|
||
//
|
||
// 候选的形状是 `{value,label}`(切片 LBL-1):这里连**显示名非空**一起钉住 —— 只有值的
|
||
// 候选会让面板下拉与表格里全是英文 id(主人 2026-10-06 报障的那个形状)。
|
||
func TestWebSearchOptionsActionFeedsDeclaredOptionsFrom(t *testing.T) {
|
||
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
|
||
sources := declaredOptionsSources(search.AdminPanels())
|
||
if len(sources) == 0 {
|
||
t.Fatal("声明里一个 optionsFrom 都没有:这条判据失去了意义")
|
||
}
|
||
for _, src := range sources {
|
||
name := pluginmgr.OptionsActionName(src)
|
||
if name == "" {
|
||
t.Fatalf("optionsFrom %q 映射不出动作名", src)
|
||
}
|
||
if _, ok := env.hub.Action(search.PanelPluginID, name); !ok {
|
||
t.Errorf("候选来源 %q(动作 %s)没进声明索引", src, name)
|
||
}
|
||
out, err := env.dispatch(t, name, map[string]any{})
|
||
if err != nil {
|
||
t.Errorf("候选来源 %q 调不动: %v", src, err)
|
||
continue
|
||
}
|
||
options, _ := out.(map[string]any)["options"].([]search.ProviderOption)
|
||
if len(options) == 0 {
|
||
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
|
||
}
|
||
got, want := optionValues(options), optionValues(search.ProviderOptions())
|
||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||
t.Errorf("候选值 = %v, want %v(value 逐字等于 KnownNames())", got, want)
|
||
}
|
||
for _, o := range options {
|
||
if strings.TrimSpace(o.Label) == "" {
|
||
t.Errorf("候选来源 %q 的 %q 没有显示名(下拉里会是空白项)", src, o.Value)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// optionValues 取出结构化候选的**值**(落盘真源),丢掉显示名。
|
||
func optionValues(opts []search.ProviderOption) []string {
|
||
out := make([]string, 0, len(opts))
|
||
for _, o := range opts {
|
||
out = append(out, o.Value)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestWebSearchActionsUnwiredAnswers501 锁定降级口径:**没有配置**(测试夹具/装配未完成)
|
||
// 时不登记任何处理器 ⇒ 已声明的动作诚实回 501(ErrActionUnavailable),
|
||
// 绝不假装成功地回一份空列表。
|
||
func TestWebSearchActionsUnwiredAnswers501(t *testing.T) {
|
||
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
|
||
b := bus.New()
|
||
defer b.Close()
|
||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
|
||
defer mgr.Close(context.Background())
|
||
|
||
// cfg 为 nil ⇒ 组合根拿不到数据目录,动作登记被跳过(只告警)。
|
||
wirePanelHub(&app{log: testLog(), bus: b, plugins: mgr})
|
||
hub := mgr.Panels()
|
||
if got := hub.ActionHandlerNames(search.PanelPluginID); len(got) != 0 {
|
||
t.Fatalf("没有配置时不该登记任何处理器,实际 %v", got)
|
||
}
|
||
_, err := hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||
PluginID: search.PanelPluginID, Action: search.ActionListProviders,
|
||
})
|
||
if !errors.Is(err, pluginmgr.ErrActionUnavailable) {
|
||
t.Fatalf("err = %v, want ErrActionUnavailable(未登记处理器必须诚实回 501)", err)
|
||
}
|
||
}
|