package store_test import ( "context" "database/sql" "errors" "path/filepath" "strings" "testing" "github.com/pressly/goose/v3" "git.avsdev.uk/Go/job-search/internal/store" ) // Run with: go test -tags sqlite_fts5 ./... func tempDBPath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "migrate.db") } func newMigrator(t *testing.T) (*goose.Provider, *sql.DB) { t.Helper() db, err := store.OpenDB(filepath.Join(t.TempDir(), "migrate.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) m, err := store.NewMigrator(db) if err != nil { t.Fatal(err) } return m, db } func version(t *testing.T, m *goose.Provider) int64 { t.Helper() v, err := m.GetDBVersion(context.Background()) if err != nil { t.Fatal(err) } return v } func TestOpenRequiresMigration(t *testing.T) { ctx := context.Background() path := tempDBPath(t) if _, err := store.Open(ctx, path); !errors.Is(err, store.ErrNeedsMigration) { t.Fatalf("fresh database: err = %v, want ErrNeedsMigration", err) } db, err := store.OpenDB(path) if err != nil { t.Fatal(err) } defer db.Close() m, err := store.NewMigrator(db) if err != nil { t.Fatal(err) } if _, err := m.UpByOne(ctx); err != nil { t.Fatal(err) } if _, err := store.Open(ctx, path); !errors.Is(err, store.ErrNeedsMigration) { t.Fatalf("partly migrated: err = %v, want ErrNeedsMigration", err) } if _, err := m.Up(ctx); err != nil { t.Fatal(err) } s, err := store.Open(ctx, path) if err != nil { t.Fatalf("fully migrated: %v", err) } s.Close() } func TestOpenRejectsNewerSchema(t *testing.T) { ctx := context.Background() path := tempDBPath(t) s, err := store.Open(ctx, path, store.WithAutoMigrate()) if err != nil { t.Fatal(err) } s.Close() // Simulate a migration applied by a newer build. db, err := store.OpenDB(path) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec( `INSERT INTO goose_db_version (version_id, is_applied) VALUES (9999, 1)`, ); err != nil { t.Fatal(err) } for name, opts := range map[string][]store.Option{ "check": nil, "auto-migrate": {store.WithAutoMigrate()}, } { _, err := store.Open(ctx, path, opts...) if !errors.Is(err, store.ErrSchemaTooNew) { t.Errorf("%s: err = %v, want ErrSchemaTooNew", name, err) } } } func TestMigrateDownAllAndUpAgain(t *testing.T) { ctx := context.Background() path := tempDBPath(t) db, err := store.OpenDB(path) if err != nil { t.Fatal(err) } defer db.Close() m, err := store.NewMigrator(db) if err != nil { t.Fatal(err) } if _, err := m.Up(ctx); err != nil { t.Fatal(err) } if v, _ := m.GetDBVersion(ctx); v != store.LatestVersion(m) { t.Fatalf("after up: version %d, want %d", v, store.LatestVersion(m)) } if _, err := m.DownTo(ctx, 0); err != nil { t.Fatalf("down to 0: %v", err) } var tables int query := ` SELECT count(*) FROM sqlite_master WHERE type IN ('table', 'index', 'trigger') AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'` if err := db.QueryRow(query).Scan(&tables); err != nil { t.Fatal(err) } if tables != 0 { t.Errorf("after down to 0: %d schema objects remain", tables) } if _, err := m.Up(ctx); err != nil { t.Fatalf("up again: %v", err) } s, err := store.Open(ctx, path) if err != nil { t.Fatalf("open after round trip: %v", err) } s.Close() } func TestUpAllThenUpToDate(t *testing.T) { ctx := context.Background() m, _ := newMigrator(t) latest := store.LatestVersion(m) results, err := store.StepUp(ctx, m, 0) if err != nil { t.Fatal(err) } if got := len(results); got != int(latest) { t.Fatalf("applied %d migrations, want %d.", got, latest) } results, err = store.StepUp(ctx, m, 0) if err != nil { t.Fatal(err) } if len(results) != 0 { t.Fatalf("expected no further migrations, got %d.", len(results)) } } func TestUpBySteps(t *testing.T) { ctx := context.Background() m, _ := newMigrator(t) nToApply := 2 _, err := store.StepUp(ctx, m, nToApply) if err != nil { t.Fatal(err) } if v := version(t, m); v != int64(nToApply) { t.Fatalf("after 2 steps: version %d", v) } // More steps than pending stops at the latest version. _, err = store.StepUp(ctx, m, 50) if err != nil { t.Fatal(err) } if v := version(t, m); v != store.LatestVersion(m) { t.Fatalf("after 50 steps: version %d", v) } } func TestDownBySteps(t *testing.T) { ctx := context.Background() m, _ := newMigrator(t) _, err := store.StepUp(ctx, m, 0) if err != nil { t.Fatal(err) } _, err = store.StepDown(ctx, m, 2, false) if err != nil { t.Fatal(err) } if v, want := version(t, m), store.LatestVersion(m)-2; v != want { t.Fatalf("after 2 down steps: version %d, want %d", v, want) } } func TestDownAllRequiresConfirmation(t *testing.T) { ctx := context.Background() m, _ := newMigrator(t) _, err := store.StepUp(ctx, m, 0) if err != nil { t.Fatal(err) } hasErrored := false for name, steps := range map[string]int{ "steps 0": 0, "more steps than applied": 99, } { _, err := store.StepDown(ctx, m, steps, false) if !errors.Is(err, store.ErrWouldEmptyDatabase) { t.Errorf("%s: %s", name, err) hasErrored = true } if v := version(t, m); v != store.LatestVersion(m) { t.Errorf("%s: version changed to %d", name, v) hasErrored = true } } if hasErrored { t.FailNow() } _, err = store.StepDown(ctx, m, 0, true) if err != nil { t.Fatal(err) } if v := version(t, m); v != 0 { t.Fatalf("after confirmed down: version %d", v) } } func TestDownOnEmptyDatabase(t *testing.T) { m, _ := newMigrator(t) results, err := store.StepDown(context.Background(), m, 0, false) if err != nil { t.Fatal(err) } if len(results) != 0 { t.Fatalf("expected no further migrations, got %d.", len(results)) } } func TestNewerDatabaseIsRefused(t *testing.T) { ctx := context.Background() m, db := newMigrator(t) if _, err := store.StepUp(ctx, m, 0); err != nil { t.Fatal(err) } if _, err := db.Exec( `INSERT INTO goose_db_version (version_id, is_applied) VALUES (9999, 1)`, ); err != nil { t.Fatal(err) } if _, err := store.StepUp(ctx, m, 0); err == nil || !strings.Contains(err.Error(), "newer build") { t.Errorf("up: err = %v, want a newer-build error", err) } if _, err := store.StepDown(ctx, m, 1, false); err == nil || !strings.Contains(err.Error(), "newer build") { t.Errorf("down: err = %v, want a newer-build error", err) } } func TestFailedMigrationIsRolledBack(t *testing.T) { ctx := context.Background() m, db := newMigrator(t) if _, err := store.StepUp(ctx, m, 1); err != nil { t.Fatal(err) } // Occupy migration 4's table name with a view. Its CREATE TABLE IF NOT // EXISTS then skips, and the CREATE INDEX that follows fails because // views can't be indexed, part-way through the migration. if _, err := db.Exec( `CREATE VIEW classification_codes AS SELECT 1 AS x`, ); err != nil { t.Fatal(err) } _, err := store.StepUp(ctx, m, 0) if err == nil { t.Fatal("expected migration 4 to fail") } if v := version(t, m); v != 3 { t.Errorf("version after failure: %d, want 3", v) } var indexes int query := `SELECT count(*) FROM sqlite_master WHERE name LIKE 'classification_codes_%'` db.QueryRow(query).Scan(&indexes) if indexes != 0 { t.Errorf("failed migration left %d objects behind", indexes) } }