718 lines
20 KiB
Go
718 lines
20 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|