feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)
- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404 - web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/ testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600) - 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」 - 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行 缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback (按 DECISIONS-2026-10-06 #1/#2 补)。
This commit is contained in:
1 parent
a150211468
commit
2e1c3ed3fd
13 files changed
+2043
-204
No files matched your search
@@ -0,0 +1,448 @@
|
||||
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 映射出的动作名都**已登记且已声明**(否则前端只能降级成文本框)。
|
||||
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"].([]string)
|
||||
if len(options) == 0 {
|
||||
t.Errorf("候选来源 %q 回了空候选(表单下拉会空着)", src)
|
||||
}
|
||||
if strings.Join(options, ",") != strings.Join(search.ProviderOptions(), ",") {
|
||||
t.Errorf("候选 = %v, want %v", options, search.ProviderOptions())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user