Files
job-search/internal/store/migrate_test.go
T
bwilliams e19c8c2b69 Imports split into three groups: standard library, third-party, then this project's own packages
gci is configured with the sections standard, default and localmodule, and
goimports with the module path as its local prefix, so both formatters
agree.
2026-10-14 17:40:06 +01:00

323 lines
7.2 KiB
Go

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 {
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()},
} {
if _, err := store.Open(ctx, path, opts...); !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
if err := db.QueryRow(`
SELECT count(*) FROM sqlite_master
WHERE type IN ('table', 'index', 'trigger')
AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`).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
db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name LIKE 'classification_codes_%'`).
Scan(&indexes)
if indexes != 0 {
t.Errorf("failed migration left %d objects behind", indexes)
}
}