package store import ( "context" "encoding/json" "errors" "path/filepath" "strings" "testing" "time" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/seed" ) // newTestStore returns a store backed by a temporary on-disk database, so that // the test exercises the same file-based code path as production including WAL. func newTestStore(t *testing.T) *Store { t.Helper() path := filepath.Join(t.TempDir(), "test.db") store, err := Open(context.Background(), Options{Path: path}) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { store.Close() }) return store } // sampleTheme returns a minimal valid theme for store tests. func sampleTheme(slug string) *domain.Theme { return &domain.Theme{ Slug: slug, Name: "Sample " + slug, Description: "a theme used in tests", Status: domain.StatusDraft, DefaultAppearance: domain.AppearanceLight, Tokens: domain.TokenDoc{ "color": map[string]any{ "semantic": map[string]any{ "background": map[string]any{"$type": "color", "$value": "#ffffff"}, "primary": map[string]any{"$type": "color", "$value": "#4f46e5"}, }, }, }, Meta: domain.ThemeMeta{"tags": []string{"test"}}, CreatedBy: "tester", } } func TestOpenAppliesSchema(t *testing.T) { store := newTestStore(t) for _, table := range []string{"themes", "theme_versions", "users", "audit_log", "settings", "schema_migrations"} { var name string err := store.DB().QueryRowContext(t.Context(), `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name) if err != nil { t.Errorf("table %s missing: %v", table, err) } } } func TestCreateAndGetTheme(t *testing.T) { ctx := context.Background() store := newTestStore(t) theme := sampleTheme("acme") if err := store.Create(ctx, theme, "initial"); err != nil { t.Fatalf("Create: %v", err) } loaded, err := store.Get(ctx, "acme") if err != nil { t.Fatalf("Get: %v", err) } if loaded.ID == "" { t.Error("expected an assigned id") } if loaded.Version != 1 { t.Errorf("version = %d, want 1", loaded.Version) } if loaded.Tokens["color"] == nil { t.Error("tokens did not round-trip") } if loaded.System { t.Error("a user-created theme must not be flagged as built in") } } func TestCreateRejectsDuplicateSlug(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("first Create: %v", err) } err := store.Create(ctx, sampleTheme("acme"), "") if !errors.Is(err, domain.ErrConflict) { t.Fatalf("duplicate slug error = %v, want ErrConflict", err) } } func TestCreateRejectsReservedSlug(t *testing.T) { ctx := context.Background() store := newTestStore(t) err := store.Create(ctx, sampleTheme("api"), "") if !errors.Is(err, domain.ErrInvalid) { t.Fatalf("reserved slug error = %v, want ErrInvalid", err) } } func TestCreateRejectsInvalidStatus(t *testing.T) { ctx := context.Background() store := newTestStore(t) theme := sampleTheme("acme") theme.Status = "sideways" if err := store.Create(ctx, theme, ""); !errors.Is(err, domain.ErrInvalid) { t.Fatalf("invalid status error = %v, want ErrInvalid", err) } } func TestUpdateBumpsVersionAndRecordsHistory(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), "created"); err != nil { t.Fatalf("Create: %v", err) } name := "Renamed" updated, err := store.Update(ctx, "acme", UpdateFields{Name: &name, Message: "renamed"}) if err != nil { t.Fatalf("Update: %v", err) } if updated.Version != 2 { t.Errorf("version = %d, want 2", updated.Version) } if updated.Name != name { t.Errorf("name = %q, want %q", updated.Name, name) } versions, err := store.Versions(ctx, "acme", 10) if err != nil { t.Fatalf("Versions: %v", err) } if len(versions) != 2 { t.Fatalf("got %d versions, want 2", len(versions)) } // Newest first. if versions[0].Version != 2 || versions[0].Message != "renamed" { t.Errorf("unexpected newest version: %+v", versions[0]) } } func TestPartialUpdateLeavesOtherFieldsAlone(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } description := "updated description" if _, err := store.Update(ctx, "acme", UpdateFields{Description: &description}); err != nil { t.Fatalf("Update: %v", err) } loaded, err := store.Get(ctx, "acme") if err != nil { t.Fatalf("Get: %v", err) } if loaded.Name != "Sample acme" { t.Errorf("name changed to %q by a description-only update", loaded.Name) } if loaded.Description != description { t.Errorf("description = %q, want %q", loaded.Description, description) } } func TestRenameChangesSlugAndKeepsHistory(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } newSlug := "acme-v2" if _, err := store.Update(ctx, "acme", UpdateFields{Slug: &newSlug}); err != nil { t.Fatalf("Update: %v", err) } if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) { t.Errorf("old slug should be gone, got %v", err) } if _, err := store.Get(ctx, "acme-v2"); err != nil { t.Errorf("new slug should resolve: %v", err) } } func TestDeleteRefusesBuiltInThemes(t *testing.T) { ctx := context.Background() store := newTestStore(t) if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { t.Fatalf("Seed: %v", err) } err := store.Delete(ctx, "midnight") if !errors.Is(err, domain.ErrImmutable) { t.Fatalf("deleting a built-in theme = %v, want ErrImmutable", err) } } func TestDeleteRemovesThemeAndCascadesHistory(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } if err := store.Delete(ctx, "acme"); err != nil { t.Fatalf("Delete: %v", err) } if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) { t.Errorf("theme should be gone, got %v", err) } var versions int if err := store.DB().QueryRowContext(t.Context(), `SELECT COUNT(*) FROM theme_versions WHERE slug = ?`, "acme").Scan(&versions); err != nil { t.Fatalf("count versions: %v", err) } if versions != 0 { t.Errorf("history rows survived deletion: %d", versions) } } func TestDeleteUnknownThemeReportsNotFound(t *testing.T) { store := newTestStore(t) if err := store.Delete(context.Background(), "nope"); !errors.Is(err, domain.ErrNotFound) { t.Fatalf("error = %v, want ErrNotFound", err) } } func TestDuplicateProducesIndependentDraft(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } copied, err := store.Duplicate(ctx, "acme", "acme-copy", "tester") if err != nil { t.Fatalf("Duplicate: %v", err) } if copied.Status != domain.StatusDraft { t.Errorf("status = %q, want draft", copied.Status) } if copied.Version != 1 { t.Errorf("version = %d, want 1", copied.Version) } if copied.System { t.Error("a duplicate must not remain flagged as built in") } // Mutating the copy must not touch the original. name := "Changed" if _, err := store.Update(ctx, "acme-copy", UpdateFields{Name: &name}); err != nil { t.Fatalf("Update copy: %v", err) } original, err := store.Get(ctx, "acme") if err != nil { t.Fatalf("Get original: %v", err) } if original.Name == "Changed" { t.Error("mutating the duplicate changed the original") } } func TestRollbackAppendsRatherThanRewritesHistory(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } name := "Second" if _, err := store.Update(ctx, "acme", UpdateFields{Name: &name}); err != nil { t.Fatalf("Update: %v", err) } restored, err := store.Rollback(ctx, "acme", 1, "tester") if err != nil { t.Fatalf("Rollback: %v", err) } if restored.Name != "Sample acme" { t.Errorf("name = %q, want the version 1 value", restored.Name) } if restored.Version != 3 { t.Errorf("version = %d, want 3; a rollback must append", restored.Version) } versions, err := store.Versions(ctx, "acme", 10) if err != nil { t.Fatalf("Versions: %v", err) } if len(versions) != 3 { t.Errorf("got %d versions, want 3 (history must be append-only)", len(versions)) } } func TestDiffVersions(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } tokens := sampleTheme("acme").Tokens tokens["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = map[string]any{"$type": "color", "$value": "#ff0000"} if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "recolour"}); err != nil { t.Fatalf("Update: %v", err) } diff, err := store.DiffVersions(ctx, "acme", 1, 2) if err != nil { t.Fatalf("DiffVersions: %v", err) } if len(diff.Changed) != 1 { t.Fatalf("got %d changed tokens, want 1: %+v", len(diff.Changed), diff.Changed) } change := diff.Changed[0] if change.Path != "color.semantic.primary" { t.Errorf("path = %q", change.Path) } if change.From != "#4f46e5" || change.To != "#ff0000" { t.Errorf("change = %s -> %s", change.From, change.To) } } // TestDiffVersionsOfIdenticalRevisionsMarshalAsLists pins the wire contract for a // diff with nothing in it. // // Two revisions with no differences is an ordinary outcome, not an edge case: a save // that only changed a description, or an operator comparing the wrong pair. The JSON // still has to carry three lists, because that is what the API promises and what a // typed client reads the length of. func TestDiffVersionsOfIdenticalRevisionsMarshalAsLists(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } // A second save of the same token document, so the versions differ only in their // message and the diff has nothing to report. tokens := sampleTheme("acme").Tokens if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "no-op save"}); err != nil { t.Fatalf("Update: %v", err) } diff, err := store.DiffVersions(ctx, "acme", 1, 2) if err != nil { t.Fatalf("DiffVersions: %v", err) } if diff.Added == nil || diff.Removed == nil || diff.Changed == nil { t.Fatalf("empty lists must be non-nil: %+v", diff) } if len(diff.Added)+len(diff.Removed)+len(diff.Changed) != 0 { t.Errorf("identical revisions should diff to nothing: %+v", diff) } // The assertion that matters to a client: null would break every consumer that // reads a length, and JSON is the contract, not the Go struct. encoded, err := json.Marshal(diff) if err != nil { t.Fatalf("Marshal: %v", err) } for _, field := range []string{`"added":[]`, `"removed":[]`, `"changed":[]`} { if !strings.Contains(string(encoded), field) { t.Errorf("diff JSON missing %s: %s", field, encoded) } } for _, field := range []string{`"added":null`, `"removed":null`, `"changed":null`} { if strings.Contains(string(encoded), field) { t.Errorf("diff JSON has %s, which a typed client cannot read a length from: %s", field, encoded) } } } func TestListFiltersAndSorts(t *testing.T) { ctx := context.Background() store := newTestStore(t) for _, slug := range []string{"alpha", "beta", "gamma"} { if err := store.Create(ctx, sampleTheme(slug), ""); err != nil { t.Fatalf("Create %s: %v", slug, err) } } published := domain.StatusPublished if _, err := store.Update(ctx, "beta", UpdateFields{Status: &published}); err != nil { t.Fatalf("Update: %v", err) } page, err := store.List(ctx, domain.ThemeQuery{Status: domain.StatusPublished}) if err != nil { t.Fatalf("List: %v", err) } if page.Total != 1 || len(page.Items) != 1 || page.Items[0].Slug != "beta" { t.Errorf("published filter returned %+v", page) } page, err = store.List(ctx, domain.ThemeQuery{Search: "alph"}) if err != nil { t.Fatalf("List search: %v", err) } if page.Total != 1 || page.Items[0].Slug != "alpha" { t.Errorf("search returned %+v", page) } page, err = store.List(ctx, domain.ThemeQuery{Sort: "name", Order: "asc"}) if err != nil { t.Fatalf("List sorted: %v", err) } if len(page.Items) != 3 || page.Items[0].Slug != "alpha" { t.Errorf("sorted page = %+v", page.Items) } } func TestListIgnoresUnknownSortColumn(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil { t.Fatalf("Create: %v", err) } // An unknown sort must fall back to the default rather than reaching SQL. if _, err := store.List(ctx, domain.ThemeQuery{Sort: "slug; DROP TABLE themes"}); err != nil { t.Fatalf("List: %v", err) } } func TestListSearchTreatsWildcardsLiterally(t *testing.T) { ctx := context.Background() store := newTestStore(t) for _, slug := range []string{"alpha", "beta"} { if err := store.Create(ctx, sampleTheme(slug), ""); err != nil { t.Fatalf("Create: %v", err) } } page, err := store.List(ctx, domain.ThemeQuery{Search: "%"}) if err != nil { t.Fatalf("List: %v", err) } if page.Total != 0 { t.Errorf("a literal %% matched %d themes; wildcards must be escaped", page.Total) } } func TestListRuntimeExcludesDrafts(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("draft-one"), ""); err != nil { t.Fatalf("Create: %v", err) } published := domain.StatusPublished if err := store.Create(ctx, sampleTheme("live-one"), ""); err != nil { t.Fatalf("Create: %v", err) } if _, err := store.Update(ctx, "live-one", UpdateFields{Status: &published}); err != nil { t.Fatalf("Update: %v", err) } themes, err := store.ListRuntime(ctx) if err != nil { t.Fatalf("ListRuntime: %v", err) } if len(themes) != 1 || themes[0].Slug != "live-one" { t.Errorf("ListRuntime returned %d themes, want only the published one", len(themes)) } } func TestSeedIsIdempotent(t *testing.T) { ctx := context.Background() store := newTestStore(t) inserted, _, err := store.Seed(ctx, seed.Themes()) if err != nil { t.Fatalf("Seed: %v", err) } if inserted == 0 { t.Fatal("first seed inserted nothing") } // Declared here rather than alongside the first call, so the first call's // "refreshed" is not an assignment that is immediately overwritten. var refreshed int inserted, refreshed, err = store.Seed(ctx, seed.Themes()) if err != nil { t.Fatalf("second Seed: %v", err) } if inserted != 0 || refreshed != 0 { t.Errorf("second seed inserted=%d refreshed=%d, want 0/0", inserted, refreshed) } for _, slug := range seed.Slugs() { theme, err := store.Get(ctx, slug) if err != nil { t.Errorf("built-in %s missing: %v", slug, err) continue } if !theme.System { t.Errorf("theme %s is not flagged as built in", slug) } } } func TestSeedLeavesEditedBuiltInsAlone(t *testing.T) { ctx := context.Background() store := newTestStore(t) if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { t.Fatalf("Seed: %v", err) } // Simulate an operator having edited the built-in theme. custom := "operator's midnight" if _, err := store.Update(ctx, "midnight", UpdateFields{Name: &custom}); err != nil { t.Fatalf("Update: %v", err) } editedVersion, err := store.Get(ctx, "midnight") if err != nil { t.Fatalf("Get: %v", err) } // Re-seeding must not revert the operator's work. if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { t.Fatalf("re-Seed: %v", err) } after, err := store.Get(ctx, "midnight") if err != nil { t.Fatalf("Get after reseed: %v", err) } if after.Name != custom { t.Errorf("name = %q, want the operator's %q", after.Name, custom) } if after.Version != editedVersion.Version { t.Errorf("version = %d, want %d; a reseed must not bump an edited theme", after.Version, editedVersion.Version) } } func TestAuditRoundTrip(t *testing.T) { ctx := context.Background() store := newTestStore(t) entry := &domain.AuditEntry{ Actor: "alice", ActorEmail: "alice@example.com", Action: domain.ActionThemePublish, EntityType: "theme", EntityID: "acme", Summary: "published acme", Detail: map[string]any{"version": 3}, RemoteIP: "10.0.0.1", } if err := store.AppendAudit(ctx, entry); err != nil { t.Fatalf("AppendAudit: %v", err) } entries, total, err := store.ListAudit(ctx, AuditFilter{EntityID: "acme"}) if err != nil { t.Fatalf("ListAudit: %v", err) } if total != 1 || len(entries) != 1 { t.Fatalf("got %d entries (total %d), want 1", len(entries), total) } if entries[0].Detail["version"] != float64(3) { t.Errorf("detail = %v", entries[0].Detail) } if _, _, err := store.ListAudit(ctx, AuditFilter{Actor: "bob"}); err != nil { t.Fatalf("ListAudit by actor: %v", err) } } func TestPurgeAudit(t *testing.T) { ctx := context.Background() store := newTestStore(t) old := store.Now().Add(-90 * 24 * time.Hour) if err := store.AppendAudit(ctx, &domain.AuditEntry{At: old, Action: domain.ActionAuthLogin, Actor: "old"}); err != nil { t.Fatalf("AppendAudit: %v", err) } if err := store.AppendAudit(ctx, &domain.AuditEntry{Action: domain.ActionAuthLogin, Actor: "new"}); err != nil { t.Fatalf("AppendAudit: %v", err) } removed, err := store.PurgeAudit(ctx, store.Now().Add(-30*24*time.Hour)) if err != nil { t.Fatalf("PurgeAudit: %v", err) } if removed != 1 { t.Errorf("removed %d entries, want 1", removed) } } func TestSettings(t *testing.T) { ctx := context.Background() store := newTestStore(t) value, err := store.Setting(ctx, domain.SettingDefaultTheme, "fallback") if err != nil || value != "fallback" { t.Fatalf("Setting default = %q, %v; want the fallback", value, err) } if err := store.SetSetting(ctx, domain.SettingDefaultTheme, "midnight", "tester"); err != nil { t.Fatalf("SetSetting: %v", err) } value, err = store.Setting(ctx, domain.SettingDefaultTheme, "fallback") if err != nil || value != "midnight" { t.Fatalf("Setting = %q, %v; want midnight", value, err) } } func TestUpsertUserOverwritesRoles(t *testing.T) { ctx := context.Background() store := newTestStore(t) identity := &domain.Identity{ Subject: "sub-1", Email: "a@example.com", Name: "A", Roles: []domain.Role{domain.RoleAdmin}, Method: "oidc", } if err := store.UpsertUser(ctx, identity); err != nil { t.Fatalf("UpsertUser: %v", err) } identity.Roles = []domain.Role{domain.RoleViewer} if err := store.UpsertUser(ctx, identity); err != nil { t.Fatalf("UpsertUser again: %v", err) } users, err := store.ListUsers(ctx, 10) if err != nil { t.Fatalf("ListUsers: %v", err) } if len(users) != 1 { t.Fatalf("got %d users, want 1 (upsert must not duplicate)", len(users)) } if len(users[0].Roles) != 1 || users[0].Roles[0] != string(domain.RoleViewer) { t.Errorf("roles = %v, want the latest value", users[0].Roles) } } func TestStats(t *testing.T) { ctx := context.Background() store := newTestStore(t) if _, _, err := store.Seed(ctx, seed.Themes()); err != nil { t.Fatalf("Seed: %v", err) } stats, err := store.Stats(ctx) if err != nil { t.Fatalf("Stats: %v", err) } if stats.Themes != len(seed.Slugs()) { t.Errorf("themes = %d, want %d", stats.Themes, len(seed.Slugs())) } if stats.DatabaseSize <= 0 { t.Error("expected a non-zero database size") } } func TestConcurrentReadsAndWrites(t *testing.T) { ctx := context.Background() store := newTestStore(t) if err := store.Create(ctx, sampleTheme("shared"), ""); err != nil { t.Fatalf("Create: %v", err) } // WAL is supposed to let readers run while a writer holds the lock. This // test would deadlock or error without it. errs := make(chan error, 20) done := make(chan struct{}) go func() { for i := 0; i < 10; i++ { name := "name" if _, err := store.Update(ctx, "shared", UpdateFields{Name: &name}); err != nil { errs <- err return } } close(done) }() for i := 0; i < 10; i++ { if _, err := store.Get(ctx, "shared"); err != nil { errs <- err break } } select { case <-done: case err := <-errs: t.Fatalf("concurrent access failed: %v", err) } close(errs) for err := range errs { t.Errorf("concurrent access: %v", err) } }