go-bananas/internal/store/store_test.go

718 lines
20 KiB
Go
Raw Normal View History

2026-10-07 15:11:35 +02:00
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)
}
}