package plugin import ( "context" "errors" "fmt" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件锁定切片 L1/PLG-2 的**执行面接线**(design/09 §5.2.1 动作分发): // - 登记面按 (pluginId, action) 精确路由,一个插件登记的处理器不替另一个插件作答; // - 登记**不**放宽声明闸门:没声明的名字即使有处理器也 404; // - 未登记处理器仍诚实回 ErrActionUnavailable(501),绝不假装执行成功; // - 登记本身的错误(空命名空间 / 空名字 / nil 处理器)当场拒,不静默半登记。 // registerPanel 给 id 登记一份最简声明(一个读来源动作 + 一个写动作)。 func registerPanel(t *testing.T, hub *Panels, pluginID string, readAction, writeAction string) { t.Helper() enabled := true panel := sdk.Panel{ ID: "p", Title: "P", Order: 1, When: &sdk.When{Enabled: &enabled}, Sections: []sdk.Section{ {Type: sdk.SectionForm, ID: "f", Title: "F", Fields: []sdk.Field{{Key: "k", Type: sdk.FieldText}}, Submit: &sdk.Submit{Action: writeAction}}, {Type: sdk.SectionTable, ID: "t", Title: "T", Source: &sdk.Source{Action: readAction}, Columns: []sdk.Column{{Key: "k"}}}, }, } if _, err := hub.Declare(pluginID, []sdk.Panel{panel}); err != nil { t.Fatalf("Declare(%s): %v", pluginID, err) } } // TestPanels_RegisterActionsRoutesByPluginAndAction 锁定登记面的精确路由: // 同名动作在两个插件下各走各的处理器,返回结果互不串台。 func TestPanels_RegisterActionsRoutesByPluginAndAction(t *testing.T) { t.Parallel() hub, _ := newTestHub(t) registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider") registerPanel(t, hub, "beta", "plugin.listProviders", "plugin.saveProvider") for _, id := range []string{"alpha", "beta"} { id := id if err := hub.RegisterActions(id, map[string]ActionHandler{ "plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) { return "from-" + r.PluginID, nil }, }); err != nil { t.Fatalf("RegisterActions(%s): %v", id, err) } } for _, id := range []string{"alpha", "beta"} { got, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: id, Action: "plugin.listProviders"}) if err != nil { t.Fatalf("Dispatch(%s): %v", id, err) } if got != "from-"+id { t.Fatalf("Dispatch(%s) = %v,期望走自己的处理器", id, got) } } // 只登记了读动作:同插件的写动作仍未接线(501),不因「这个插件登记过东西」而放行。 if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"}); !errors.Is(err, ErrActionUnavailable) { t.Fatalf("未单独登记的动作 err = %v,期望 ErrActionUnavailable", err) } } // TestPanels_RegisteredHandlerCannotBypassDeclaration 锁定登记的从属地位: // 处理器存在但动作**没声明** ⇒ 仍然 404(且处理器一次都不跑)。 func TestPanels_RegisteredHandlerCannotBypassDeclaration(t *testing.T) { t.Parallel() hub, _ := newTestHub(t) registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider") ran := 0 if err := hub.RegisterActions("alpha", map[string]ActionHandler{ "plugin.nukeEverything": func(_ context.Context, _ ActionRequest) (any, error) { ran++ return "should-not-run", nil }, }); err != nil { t.Fatalf("RegisterActions: %v", err) } if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.nukeEverything"}); !errors.Is(err, ErrActionNotDeclared) { t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err) } if ran != 0 { t.Fatalf("未声明动作的处理器跑了 %d 次(声明闸门被绕过)", ran) } // 停用的插件同理:声明还在,但不再分发。 if err := hub.RegisterActions("alpha", map[string]ActionHandler{ "plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil }, }); err != nil { t.Fatalf("RegisterActions: %v", err) } hub.SetEnabled("alpha", false) if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"}); !errors.Is(err, ErrPluginDisabled) { t.Fatalf("停用后 err = %v,期望 ErrPluginDisabled", err) } } // TestPanels_RegisterActionsRejectsInvalidRegistration 锁定登记期的形状约束: // 空命名空间 / 空名字 / nil 处理器一律当场拒(静默忽略会让「点了没反应」重新变成谜)。 func TestPanels_RegisterActionsRejectsInvalidRegistration(t *testing.T) { t.Parallel() hub, _ := newTestHub(t) ok := func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil } cases := []struct { name string pluginID string handlers map[string]ActionHandler }{ {"空命名空间", "", map[string]ActionHandler{"plugin.listProviders": ok}}, {"非法命名空间", "not a key", map[string]ActionHandler{"plugin.listProviders": ok}}, {"空处理器表", "alpha", map[string]ActionHandler{}}, {"空动作名", "alpha", map[string]ActionHandler{" ": ok}}, {"nil 处理器", "alpha", map[string]ActionHandler{"plugin.listProviders": nil}}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() err := hub.RegisterActions(tc.pluginID, tc.handlers) if !errors.Is(err, ErrInvalidActionRegistration) { t.Fatalf("err = %v,期望 ErrInvalidActionRegistration", err) } }) } // 被拒的登记不得留下任何痕迹(半登记最坏:看起来接好了,实际没人执行)。 if names := hub.ActionHandlerNames("alpha"); len(names) != 0 { t.Fatalf("被拒的登记留下了处理器: %v", names) } } // TestPanels_RegisterActionsMergesAndOverwrites 锁定重复登记的语义: // 第二次登记是**前缀合并**,同名覆盖(插件可分批登记自己的动作面)。 func TestPanels_RegisterActionsMergesAndOverwrites(t *testing.T) { t.Parallel() hub, _ := newTestHub(t) registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider") first := func(_ context.Context, _ ActionRequest) (any, error) { return "v1", nil } second := func(_ context.Context, _ ActionRequest) (any, error) { return "v2", nil } if err := hub.RegisterActions("alpha", map[string]ActionHandler{ "plugin.listProviders": first, "plugin.saveProvider": first, }); err != nil { t.Fatalf("RegisterActions: %v", err) } if err := hub.RegisterActions("alpha", map[string]ActionHandler{ "plugin.listProviders": second, }); err != nil { t.Fatalf("RegisterActions(merge): %v", err) } if got := hub.ActionHandlerNames("alpha"); strings.Join(got, ",") != "plugin.listProviders,plugin.saveProvider" { t.Fatalf("合并后的处理器表 = %v", got) } out, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"}) if err != nil || out != "v2" { t.Fatalf("同名覆盖后 Dispatch = (%v,%v),期望 (v2,nil)", out, err) } out, err = hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"}) if err != nil || out != "v1" { t.Fatalf("未覆盖的那条 Dispatch = (%v,%v),期望 (v1,nil)", out, err) } } // TestManager_RegisterActionsRequiresAttachedHub 锁定管理器侧入口的降级口径: // 面板中枢未接线时**响亮失败**(组合根会以为动作接好了,而端点在 501)。 func TestManager_RegisterActionsRequiresAttachedHub(t *testing.T) { t.Parallel() mgr := NewManager(nil, t.TempDir()+"/plugins.json", nil) err := mgr.RegisterActions("alpha", map[string]ActionHandler{ "plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil }, }) if !errors.Is(err, ErrInvalidActionRegistration) { t.Fatalf("未接线中枢时 err = %v,期望 ErrInvalidActionRegistration", err) } hub, herr := NewPanels(nil) if herr != nil { t.Fatalf("NewPanels: %v", herr) } mgr.AttachPanels(hub) if err := mgr.RegisterActions("alpha", map[string]ActionHandler{ "plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil }, }); err != nil { t.Fatalf("接线后 RegisterActions: %v", err) } if names := hub.ActionHandlerNames("alpha"); strings.Join(names, ",") != "plugin.listProviders" { t.Fatalf("管理器登记没有落到中枢上: %v", names) } } // TestActionErrorCarriesStructuredFailure 锁定结构化失败的形状与可判类 // (§5.4.6:错误码与文案分离;病因只进日志、不进响应)。 func TestActionErrorCarriesStructuredFailure(t *testing.T) { t.Parallel() cause := errors.New("disk full") err := fmt.Errorf("save failed: %w", NewActionError("config_write_failed", "写配置失败", 500, cause)) ae, ok := AsActionError(err) if !ok { t.Fatalf("AsActionError 没认出结构化失败: %v", err) } if ae.Code != "config_write_failed" || ae.Message != "写配置失败" || ae.Status != 500 { t.Fatalf("ActionError = %+v", ae) } if !errors.Is(err, cause) { t.Fatalf("err 链必须穿到病因(errors.Is(cause) 为假): %v", err) } if _, ok := AsActionError(context.Canceled); ok { t.Fatalf("普通错误不得被当成结构化失败") } }