package plugins import ( "context" "encoding/json" "errors" "testing" ) // testPlugin is a no-op plugin used to exercise the manager's persistence // without reaching for a real connector (the builtins register themselves from // a package that imports this one, so they are not available here). type testPlugin struct{ name string } func (t *testPlugin) Descriptor() Descriptor { return Descriptor{Name: t.name} } func (t *testPlugin) Init(context.Context, map[string]string) error { return nil } func (t *testPlugin) Shutdown(context.Context) error { return nil } func (t *testPlugin) HealthCheck(context.Context) Health { return Health{Status: "ok"} } func (t *testPlugin) Invoke(context.Context, string, json.RawMessage) (json.RawMessage, error) { return nil, nil } func init() { for _, n := range []string{"test-a", "test-b"} { Register(n, func() Plugin { return &testPlugin{name: n} }) } } // seeded returns a memory store already holding doc. func seeded(doc string) *memoryStore { return &memoryStore{data: []byte(doc), found: true} } // loadedManager returns a Manager that has successfully read doc. func loadedManager(t *testing.T, store Store) *Manager { t.Helper() m := NewManager(store) if err := m.Load(context.Background()); err != nil { t.Fatalf("Load: %v", err) } if !m.Ready() { t.Fatal("manager should be ready after a successful load") } return m } // A document holding "null" (or nothing) used to decode to a nil map, which // made the next save panic with "assignment to entry in nil map". func TestLoadNullDocumentDoesNotPanic(t *testing.T) { for _, doc := range []string{"null", "", " \n", "{}"} { m := loadedManager(t, seeded(doc)) if _, err := m.Upsert(context.Background(), "test-a", true, nil); err != nil { t.Fatalf("Upsert after %q document: %v", doc, err) } if v, _ := m.Get("test-a"); !v.Enabled { t.Fatalf("plugin not enabled after save (document was %q)", doc) } } } // A null entry for a single plugin left a nil *record that Load dereferenced. func TestLoadNullRecordIsDropped(t *testing.T) { m := loadedManager(t, seeded(`{"test-a":null,"test-b":{"enabled":true}}`)) if v, _ := m.Get("test-b"); !v.Enabled { t.Fatal("test-b should still be enabled") } if v, _ := m.Get("test-a"); v.Enabled { t.Fatal("test-a should not be enabled") } } // An unreachable store must leave the Manager unloaded and refusing to write, // so an outage cannot overwrite settings that were never read. func TestUnreachableStoreRefusesEveryMutation(t *testing.T) { store := &memoryStore{failLoad: errNotReady} m := NewManager(store) if err := m.Load(context.Background()); !IsNotReady(err) { t.Fatalf("expected a not-ready error, got %v", err) } if m.Ready() { t.Fatal("manager must not report ready after a failed load") } ctx := context.Background() if _, err := m.Upsert(ctx, "test-a", true, nil); !IsNotReady(err) { t.Fatalf("Upsert should refuse while unloaded, got %v", err) } if err := m.RegisterExternal(ctx, "ext", "http://example.test", ""); !IsNotReady(err) { t.Fatalf("RegisterExternal should refuse while unloaded, got %v", err) } if err := m.Remove(ctx, "test-a"); !IsNotReady(err) { t.Fatalf("Remove should refuse while unloaded, got %v", err) } if store.data != nil { t.Fatal("nothing may be written to a store that was never read") } } // A document that does not parse must not be replaced by an empty one — the // read problem must stay a read problem rather than becoming data loss. func TestUnreadableDocumentIsNeverOverwritten(t *testing.T) { original := `{"test-a":{"enabled":true,"config":{"user":"me"}}}garbage` store := seeded(original) m := NewManager(store) err := m.Load(context.Background()) if err == nil { t.Fatal("Load should report an unreadable document") } if m.Ready() { t.Fatal("manager must not report ready after an unreadable document") } if _, err := m.Upsert(context.Background(), "test-a", true, nil); !IsNotReady(err) { t.Fatalf("Upsert should refuse, got %v", err) } if string(store.data) != original { t.Fatalf("the unreadable document was modified: %s", store.data) } } // A failed save must roll back, so callers never see a change that vanishes. func TestUpsertRollsBackWhenSaveFails(t *testing.T) { store := seeded(`{"test-a":{"enabled":true,"config":{"user":"first"}}}`) m := loadedManager(t, store) store.failSave = errors.New("database is gone") _, err := m.Upsert(context.Background(), "test-a", false, map[string]string{"user": "second"}) if err == nil { t.Fatal("expected an error when the store cannot be written") } if !IsPersist(err) { t.Fatalf("error should be classified as a persist failure, got %v", err) } v, _ := m.Get("test-a") if !v.Enabled || v.Config["user"] != "first" { t.Fatalf("failed save leaked into memory: enabled=%v user=%q", v.Enabled, v.Config["user"]) } } // A newly created record must not survive a save that failed. func TestUpsertRollsBackNewRecord(t *testing.T) { store := seeded(`{}`) m := loadedManager(t, store) store.failSave = errors.New("database is gone") if _, err := m.Upsert(context.Background(), "test-a", true, nil); !IsPersist(err) { t.Fatalf("expected persist failure, got %v", err) } if v, _ := m.Get("test-a"); v.Enabled { t.Fatal("a record from a failed save is still present") } } // Enable/disable state must survive a restart — the whole point of the store. func TestStateSurvivesReload(t *testing.T) { store := seeded(`{}`) m := loadedManager(t, store) if _, err := m.Upsert(context.Background(), "test-a", true, map[string]string{"user": "me"}); err != nil { t.Fatal(err) } // Restart against the same store. m2 := loadedManager(t, store) v, ok := m2.Get("test-a") if !ok || !v.Enabled || v.Config["user"] != "me" { t.Fatalf("state lost across restart: ok=%v enabled=%v config=%v", ok, v.Enabled, v.Config) } } // External plugins are registered and removed through the same store. func TestExternalPluginRoundTrip(t *testing.T) { store := seeded(`{}`) m := loadedManager(t, store) ctx := context.Background() if err := m.RegisterExternal(ctx, "ext", "example.test/api", "ACME"); err != nil { t.Fatalf("RegisterExternal: %v", err) } m2 := loadedManager(t, store) if v, ok := m2.Get("ext"); !ok || v.BaseURL != "http://example.test/api" { t.Fatalf("external plugin did not persist: ok=%v baseURL=%q", ok, v.BaseURL) } if err := m2.Remove(ctx, "ext"); err != nil { t.Fatalf("Remove: %v", err) } m3 := loadedManager(t, store) if _, ok := m3.Get("ext"); ok { t.Fatal("removed external plugin came back") } }