// Package db tests — migration dry-run gate. // // This is the test that catches mig-N crash-loops before they reach prod. // The convention since t-paliad-098/099 is that paliad migrations land in // numeric order on a single trunk; the next deploy runs whichever ones are // pending against the live `public.paliad_schema_migrations` tracker. A // migration that compiles cleanly but fails on apply (typo, missing column, // wrong CHECK shape) crashes the Dokploy container loop before paliad.de // finishes binding :8080, and the only way to learn about it today is to // watch the deploy log. // // TestMigrations_DryRun closes that gap: for every *.up.sql in this // directory whose version is greater than the scratch DB's current tracker // version, it opens a transaction, runs the SQL, and ROLLBACKs. Any error // fails the test with the file name + Postgres error. Always non-destructive // — the ROLLBACK runs even on success, so the scratch DB stays at its // starting version. // // Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB // tests). Skipped without it. // // Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1. package db import ( "database/sql" "errors" "fmt" "os" "sort" "strconv" "strings" "testing" _ "github.com/lib/pq" ) // migration is one *.up.sql file from the embedded migrations FS. type migration struct { version int name string filename string } // TestMigrations_DryRun walks every pending *.up.sql in numeric order, // applies each inside its own BEGIN/ROLLBACK against the scratch DB, and // fails the test on the first SQL error. Reports per-file as a sub-test so // `go test -v` shows which migration failed. // // What "pending" means: greater than the scratch DB's current tracker // version (or 0 if the tracker doesn't exist yet). In CI against a fresh // scratch DB, every migration is pending and gets verified. On a developer // laptop whose scratch DB is already at HEAD, no migrations are pending and // the test logs the start version and passes — the protection only kicks in // the moment a new *.up.sql lands in the tree before the developer runs // `db.ApplyMigrations` against the same scratch DB. func TestMigrations_DryRun(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run") } conn, err := sql.Open("postgres", url) if err != nil { t.Fatalf("open: %v", err) } defer conn.Close() if err := conn.Ping(); err != nil { t.Fatalf("ping: %v", err) } // The paliad schema must exist before migration 001 runs against it, // mirroring the bootstrap step in ApplyMigrations. Without this, a // fresh scratch DB would fail migration 001's CREATE TABLE paliad.* // statements inside the BEGIN/ROLLBACK probe with "schema paliad does // not exist" — a false negative that distracts from real errors. if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil { t.Fatalf("ensure paliad schema: %v", err) } startVersion, dirty, err := currentTrackerVersion(conn) if err != nil { t.Fatalf("read tracker: %v", err) } if dirty { t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+ "or restore from backup); the dry-run cannot trust a dirty starting state", startVersion) } t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward", startVersion, startVersion+1) migs, err := loadPendingMigrations(startVersion) if err != nil { t.Fatalf("load migrations: %v", err) } if len(migs) == 0 { t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion) return } for _, m := range migs { t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) { body, err := migrationFS.ReadFile("migrations/" + m.filename) if err != nil { t.Fatalf("read %s: %v", m.filename, err) } tx, err := conn.Begin() if err != nil { t.Fatalf("begin: %v", err) } // Always rollback; the dry-run must not leave the scratch DB // at a different version than where it started. Rollback is // safe to call even after a failed Exec — Postgres aborts the // transaction internally on the first error. defer func() { _ = tx.Rollback() }() if _, err := tx.Exec(string(body)); err != nil { t.Fatalf("migration %s failed dry-run: %v", m.filename, err) } }) } } // currentTrackerVersion reads the latest version + dirty flag from the // `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the // tracker doesn't exist yet — that's the "fresh scratch DB" path. // // We don't use golang-migrate's API to read this because golang-migrate's // driver locks the tracker row on read; a test runner that calls this while // the developer has paliad running locally would race. A plain SELECT is // race-safe and matches what `psql` would show. func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) { const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1` row := conn.QueryRow(q) if scanErr := row.Scan(&version, &dirty); scanErr != nil { // Missing table → fresh DB → start at 0. lib/pq surfaces this // as `pq.Error.Code = "42P01"` (undefined_table); the simpler // sql.ErrNoRows fires if the table exists but is empty (also // fresh-DB-shaped). if errors.Is(scanErr, sql.ErrNoRows) { return 0, false, nil } if strings.Contains(scanErr.Error(), "does not exist") { return 0, false, nil } return 0, false, scanErr } return version, dirty, nil } // loadPendingMigrations returns every *.up.sql in the embedded FS whose // version is greater than startVersion, sorted by version ascending. A // filename like "098_submission_codes_prefix_and_rename.up.sql" yields // version=98, name="submission_codes_prefix_and_rename". func loadPendingMigrations(startVersion int) ([]migration, error) { entries, err := migrationFS.ReadDir("migrations") if err != nil { return nil, fmt.Errorf("read migrations dir: %w", err) } var out []migration for _, e := range entries { name := e.Name() if !strings.HasSuffix(name, ".up.sql") { continue } v, n, ok := parseMigrationName(name) if !ok { return nil, fmt.Errorf("unparseable migration filename: %s "+ "(expected NNN_description.up.sql)", name) } if v <= startVersion { continue } out = append(out, migration{version: v, name: n, filename: name}) } sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version }) return out, nil } // parseMigrationName splits "NNN_description.up.sql" into (NNN, description). // Returns ok=false on any deviation from that shape. func parseMigrationName(filename string) (version int, name string, ok bool) { base := strings.TrimSuffix(filename, ".up.sql") if base == filename { // suffix wasn't present return 0, "", false } underscore := strings.IndexByte(base, '_') if underscore <= 0 { return 0, "", false } v, err := strconv.Atoi(base[:underscore]) if err != nil { return 0, "", false } return v, base[underscore+1:], true }