Files
Cyrene_v2/cmd/cyrene/websearch_actions_config_test.go
AskaEth 2e1c3ed3fd 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 补)。
2026-10-06 14:12:23 +08:00

89 lines
3.7 KiB
Go

package main
// 本文件钉住搜索提供商动作面的**危险/边界结局**(切片 L1/PLG-2):损坏配置不许被覆盖、
// 装配期要如实点名「声明了却没实现」的动作。它们与主用例集分开成文件的理由只有一条:
// `wire_plugin_actions_test.go` 已接近「文件 ≤500 行」的上限(AGENTS §10)。
import (
"bytes"
"context"
"net/http"
"os"
"path/filepath"
"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/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// TestWebSearchActionConfigUnreadableIsReported 锁定「文件存在但不合法」的口径:
// 结构化失败(500 config_unreadable),**不覆盖**原文件 —— 损坏的配置可以人工救回。
func TestWebSearchActionConfigUnreadableIsReported(t *testing.T) {
env := newActionTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
const broken = `{"version":1,"providers":[{"provider":"bocha"},{"provider":"BOCHA"}]}`
wsWriteRawPanel(t, env.path, broken)
_, err := env.dispatch(t, search.ActionListProviders, map[string]any{})
ae, ok := pluginmgr.AsActionError(err)
if !ok || ae.Code != actionCodeConfigUnreadable || ae.Status != http.StatusInternalServerError {
t.Fatalf("err = %#v, want 500 config_unreadable", err)
}
if _, err := env.dispatch(t, search.ActionSaveProvider, map[string]any{
"values": map[string]any{"provider": search.NameBocha, "apiKey": "sk-x"},
}); err == nil {
t.Fatal("配置不合法时必须拒绝保存(否则会静默盖掉可救回的原文件)")
}
raw, rerr := os.ReadFile(env.path)
if rerr != nil {
t.Fatalf("read: %v", rerr)
}
if string(raw) != broken {
t.Fatalf("原文件被改动了:%q", raw)
}
}
// TestWarnImpliedButMissingNamesGaps 锁定装配期的**核对**:声明里有、登记里没有的动作
// 必须在日志里点名(否则用户只会看到一个点了回 501 的按钮)。这里直接覆盖核对函数本身。
func TestWarnImpliedButMissingNamesGaps(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())
var buf bytes.Buffer
log := logger.New(logger.Options{Out: &buf, Level: "debug"})
wirePanelHub(&app{cfg: cfg, log: log, bus: b, plugins: mgr})
hub := mgr.Panels()
// 模拟「插件声明新增了一个动作,但宿主忘了给它接线」:补一条声明(Declare 是整体
// 替换语义 ⇒ 原有四个动作 + 候选来源仍在),只多一个没人实现的名字。
extra := sdk.Panel{
ID: "extra", Title: "额外页",
Sections: []sdk.Section{{
Type: sdk.SectionTable, ID: "t", Title: "额外表",
Source: &sdk.Source{Action: "plugin.notWired"},
Columns: []sdk.Column{{Key: "k", Label: "K"}},
}},
}
if _, err := hub.Declare(search.PanelPluginID, append(search.AdminPanels(), extra)); err != nil {
t.Fatalf("Declare: %v", err)
}
warnImpliedButMissing(hub, log)
out := buf.String()
if !strings.Contains(out, "plugin.notWired") || !strings.Contains(out, "no handler") {
t.Fatalf("声明了却没实现的动作必须被点名(否则只剩一个回 501 的按钮): %q", out)
}
// 已登记的动作不得被误报。
if idx := strings.Index(out, "action=plugin.listProviders"); idx >= 0 &&
strings.Contains(out[idx:], "no handler") {
t.Fatalf("已登记的动作被误报成缺处理器: %q", out)
}
}