package plugin_test import ( "context" "database/sql" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "testing" _ "modernc.org/sqlite" "github.com/synapbus/synapbus/internal/plugin" "github.com/synapbus/synapbus/internal/plugin/plugintest" ) // --- helpers --- func nopHostFactory(t *testing.T, db *sql.DB) func(string, plugin.CapabilityContext) plugin.Host { t.Helper() return func(name string, _ plugin.CapabilityContext) plugin.Host { return plugin.Host{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)).With("plugin", name), DB: db, Events: plugin.NewEventBus(), Config: json.RawMessage(`{}`), DataDir: t.TempDir(), Secrets: plugintest.NewScopedSecrets(name), } } } // --- test doubles --- type capabilityPlugin struct { name string actions []string onInit func(context.Context, plugin.Host) error onStart func(context.Context) error } func (p *capabilityPlugin) Name() string { return p.name } func (p *capabilityPlugin) Version() string { return "0.1.0" } func (p *capabilityPlugin) Init(ctx context.Context, host plugin.Host) error { if p.onInit != nil { return p.onInit(ctx, host) } return nil } func (p *capabilityPlugin) Migrations() []plugin.Migration { return []plugin.Migration{{ Version: 1, Name: "001_initial", SQL: `CREATE TABLE IF NOT EXISTS plugin_` + p.name + `_t (id INTEGER);`, }} } func (p *capabilityPlugin) Actions() []plugin.ActionRegistration { out := make([]plugin.ActionRegistration, 0, len(p.actions)) for _, name := range p.actions { n := name out = append(out, plugin.ActionRegistration{ Name: n, RequiredScope: plugin.ScopeRead, Handler: func(ctx context.Context, args map[string]any) (any, error) { return map[string]string{"from": p.name, "action": n}, nil }, }) } return out } func (p *capabilityPlugin) RegisterRoutes(r plugin.Router) { r.Get("/ping", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"plugin":"` + p.name + `","ok":true}`)) }) } func (p *capabilityPlugin) WebPanels() []plugin.PanelManifest { return []plugin.PanelManifest{{ID: p.name, Title: p.name, Route: "/ui/plugins/" + p.name, Scope: "member"}} } func (p *capabilityPlugin) PanelHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("

" + p.name + "

")) }) } func (p *capabilityPlugin) Start(ctx context.Context) error { if p.onStart != nil { return p.onStart(ctx) } return nil } func (p *capabilityPlugin) Shutdown(ctx context.Context) error { return nil } // --- tests --- func TestInitAll_HappyPath(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } defer db.Close() reg, err := plugin.NewRegistry( []plugin.Plugin{ &capabilityPlugin{name: "alpha", actions: []string{"alpha_say"}}, &capabilityPlugin{name: "beta", actions: []string{"beta_hello"}}, }, &plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{ "alpha": {Enabled: true}, "beta": {Enabled: true}, }}, ) if err != nil { t.Fatalf("NewRegistry: %v", err) } if err := reg.InitAll(context.Background(), nopHostFactory(t, db)); err != nil { t.Fatalf("InitAll: %v", err) } plugintest.HasAction(t, reg, "alpha_say") plugintest.HasAction(t, reg, "beta_hello") plugintest.PluginStarted(t, reg, "alpha") plugintest.PluginStarted(t, reg, "beta") plugintest.HasMigration(t, db, "alpha", 1) plugintest.HasMigration(t, db, "beta", 1) plugintest.HasPanel(t, reg, "alpha") plugintest.HasPanel(t, reg, "beta") } func TestInitAll_FailurePerPluginIsolated(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } defer db.Close() reg, err := plugin.NewRegistry( []plugin.Plugin{ &capabilityPlugin{name: "good", actions: []string{"good_hi"}}, &capabilityPlugin{ name: "bad", actions: []string{"bad_hi"}, onInit: func(context.Context, plugin.Host) error { return errors.New("boom") }, }, }, &plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{ "good": {Enabled: true}, "bad": {Enabled: true}, }}, ) if err != nil { t.Fatalf("NewRegistry: %v", err) } _ = reg.InitAll(context.Background(), nopHostFactory(t, db)) plugintest.PluginStarted(t, reg, "good") plugintest.HasAction(t, reg, "good_hi") plugintest.PluginFailed(t, reg, "bad") if _, ok := reg.Action("bad_hi"); ok { t.Fatalf("failed plugin's action must not be registered") } } func TestInitAll_PanicIsolated(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } defer db.Close() reg, _ := plugin.NewRegistry( []plugin.Plugin{ &capabilityPlugin{name: "good", actions: []string{"ok"}}, &capabilityPlugin{ name: "panicky", actions: []string{"never"}, onInit: func(context.Context, plugin.Host) error { panic("bad") }, }, }, &plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{ "good": {Enabled: true}, "panicky": {Enabled: true}, }}, ) _ = reg.InitAll(context.Background(), nopHostFactory(t, db)) plugintest.PluginStarted(t, reg, "good") plugintest.PluginFailed(t, reg, "panicky") } func TestInitAll_DisabledPluginRegistersNothing(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } defer db.Close() reg, _ := plugin.NewRegistry( []plugin.Plugin{&capabilityPlugin{name: "off", actions: []string{"off_hi"}}}, &plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{"off": {Enabled: false}}}, ) _ = reg.InitAll(context.Background(), nopHostFactory(t, db)) if _, ok := reg.Action("off_hi"); ok { t.Fatalf("disabled plugin must not register actions") } s, _ := reg.Status().Get("off") if s.Status != plugin.StatusDisabled { t.Fatalf("expected status disabled, got %q", s.Status) } } func TestInitAll_RouteMountsAreRegistered(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatal(err) } defer db.Close() reg, _ := plugin.NewRegistry( []plugin.Plugin{&capabilityPlugin{name: "routes", actions: []string{"r"}}}, &plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{"routes": {Enabled: true}}}, ) _ = reg.InitAll(context.Background(), nopHostFactory(t, db)) mounts := reg.RouteMounts() if len(mounts) != 1 { t.Fatalf("expected 1 route mount, got %d", len(mounts)) } // Serve through a stub router to verify wiring. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/ping", nil) router := newStubRouter() for _, m := range mounts { m.Setup(router) } router.routes["GET /ping"](rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } } // stubRouter is a tiny plugin.Router implementation for tests. type stubRouter struct{ routes map[string]http.HandlerFunc } func newStubRouter() *stubRouter { return &stubRouter{routes: map[string]http.HandlerFunc{}} } func (s *stubRouter) Handle(p string, h http.Handler) { s.routes["ANY "+p] = h.ServeHTTP } func (s *stubRouter) Method(m, p string, h http.Handler) { s.routes[m+" "+p] = h.ServeHTTP } func (s *stubRouter) Get(p string, h http.HandlerFunc) { s.routes["GET "+p] = h } func (s *stubRouter) Post(p string, h http.HandlerFunc) { s.routes["POST "+p] = h } func (s *stubRouter) Put(p string, h http.HandlerFunc) { s.routes["PUT "+p] = h } func (s *stubRouter) Delete(p string, h http.HandlerFunc) { s.routes["DELETE "+p] = h }