package database_test import ( "context" "errors" "path/filepath" "testing" "sneak.berlin/go/simplexcalc/internal/database" ) // open returns a migrated scratch database in a directory the test // framework removes afterwards. func open(t *testing.T) *database.Database { t.Helper() db, err := database.NewForTest(t.Context(), t.TempDir()) if err != nil { t.Fatalf("opening test database: %v", err) } t.Cleanup(func() { closeErr := db.Close() if closeErr != nil { t.Errorf("closing test database: %v", closeErr) } }) return db } // TestMigrationsApplyFromClean is the claim the healthcheck and the // container both rest on: an empty directory becomes a usable schema // with no operator step in between. func TestMigrationsApplyFromClean(t *testing.T) { t.Parallel() db := open(t) versions, err := db.AppliedVersions(t.Context()) if err != nil { t.Fatalf("reading applied versions: %v", err) } // 000 (the ledger) and 001 (widgets), which is every file the // schema directory currently embeds. if len(versions) != 2 || versions[0] != 0 || versions[1] != 1 { t.Fatalf("applied versions = %v, want [0 1]", versions) } } // TestMigrationsAreIdempotent: a restart re-runs Migrate against a // database that already has the schema, and must change nothing. A // migration runner that fails here takes the service down on every // second start. func TestMigrationsAreIdempotent(t *testing.T) { t.Parallel() dir := t.TempDir() ctx := t.Context() first, err := database.NewForTest(ctx, dir) if err != nil { t.Fatalf("first open: %v", err) } _, err = first.CreateWidget(ctx, "survivor", 1) if err != nil { t.Fatalf("creating widget: %v", err) } err = first.Close() if err != nil { t.Fatalf("closing: %v", err) } second, err := database.NewForTest(ctx, dir) if err != nil { t.Fatalf("reopening and re-migrating: %v", err) } defer func() { _ = second.Close() }() versions, err := second.AppliedVersions(ctx) if err != nil { t.Fatalf("reading applied versions: %v", err) } if len(versions) != 2 { t.Errorf("re-running migrations changed the ledger: %v", versions) } // The data has to still be there: a migration runner that "fixes" // an already-migrated database by recreating tables is worse than // one that fails. count, err := second.CountWidgets(ctx) if err != nil { t.Fatalf("counting: %v", err) } if count != 1 { t.Errorf("widget count = %d after reopen, want 1", count) } } // TestWidgetRoundTrip exercises the query layer against the real // schema, including the timestamp format shared between Go and the SQL // DEFAULT. func TestWidgetRoundTrip(t *testing.T) { t.Parallel() db := open(t) ctx := t.Context() created, err := db.CreateWidget(ctx, "widget one", 4096) if err != nil { t.Fatalf("creating widget: %v", err) } if created.ID == "" { t.Error("created widget has no id") } widgets, err := db.ListWidgets(ctx, 10) if err != nil { t.Fatalf("listing widgets: %v", err) } if len(widgets) != 1 { t.Fatalf("listed %d widgets, want 1", len(widgets)) } got := widgets[0] if got.ID != created.ID || got.Name != "widget one" || got.SizeBytes != 4096 { t.Errorf("round trip lost data: %+v", got) } if got.CreatedAt.IsZero() { t.Error("created_at did not survive the round trip") } } // TestListWidgetsRespectsLimit: the index query is bounded, and the // bound has to actually bind. func TestListWidgetsRespectsLimit(t *testing.T) { t.Parallel() db := open(t) ctx := t.Context() for range 5 { _, err := db.CreateWidget(ctx, "w", 1) if err != nil { t.Fatalf("creating widget: %v", err) } } widgets, err := db.ListWidgets(ctx, 2) if err != nil { t.Fatalf("listing widgets: %v", err) } if len(widgets) != 2 { t.Errorf("limit 2 returned %d rows", len(widgets)) } } // TestParseMigrationVersion covers the naming contract the schema // directory has to keep. A file this rejects is a file that would // otherwise be silently skipped. func TestParseMigrationVersion(t *testing.T) { t.Parallel() good := map[string]int{ "000.sql": 0, "001_widgets.sql": 1, "017_thing.sql": 17, } for name, want := range good { got, err := database.ParseMigrationVersion(name) if err != nil { t.Errorf("%s: unexpected error %v", name, err) continue } if got != want { t.Errorf("%s: version = %d, want %d", name, got, want) } } for _, name := range []string{"widgets.sql", "_001.sql", "v1_widgets.sql"} { _, err := database.ParseMigrationVersion(name) if err == nil { t.Errorf("%s: wanted a rejection, got none", name) } } } // TestOpenCreatesFile: Open must produce a database at the path it was // given, not somewhere else. func TestOpenCreatesFile(t *testing.T) { t.Parallel() dir := t.TempDir() db, err := database.Open(t.Context(), filepath.Join(dir, "explicit.db")) if err != nil { t.Fatalf("opening: %v", err) } defer func() { _ = db.Close() }() err = db.PingContext(t.Context()) if err != nil && !errors.Is(err, context.Canceled) { t.Errorf("pinging the opened database: %v", err) } }