Slice 1 of docs/design-paliad-test-strategy-2026-05-19.md — the test infrastructure that would have caught mig 098 (digit-regex) and mig 099 (missing audit_reason) before the deploy hit prod. Three new files + one route addition: - Makefile: `make verify-migrations` (alias `verify-mig`) runs the per-migration dry-run + boot smoke against TEST_DATABASE_URL. Fails fast with a clear error if TEST_DATABASE_URL is unset so CI can't silently pass a missing env var. `make test` and `make test-go` cover the rest of the short / full Go suites. - internal/db/migrate_test.go (TestMigrations_DryRun): walks every pending *.up.sql in numeric order, applies each inside its own BEGIN..ROLLBACK transaction, fails on the first SQL error with the file name + Postgres error. "Pending" = greater than the scratch DB's current tracker version, so fresh-DB CI runs verify everything while developer scratch DBs only re-verify the new pending migration. Always non-destructive — the rollback runs even on success. - cmd/server/main_smoke_test.go (TestBootSmoke): boots the apply path end-to-end, asserts (a) db.ApplyMigrations returns nil, (b) the tracker advanced to the highest *.up.sql version on disk with dirty=false, (c) GET /healthz on the registered mux returns 200. The dry-run catches per-migration syntax errors; this catches the apply+bind path the container actually runs. - internal/handlers/handlers.go: adds a GET /healthz public route — a no-auth, no-DB liveness probe. Used by the boot smoke; also safe for any future orchestrator or uptime check. Both live-DB tests gate on TEST_DATABASE_URL and skip cleanly without it, matching the rest of paliad's live-DB test pattern. Verification: go build ./... clean, go vet ./... clean, go test -short ./internal/... ./cmd/... clean (all packages pass, live-DB tests skip), bun run build clean (2436 i18n keys unchanged). Per CLAUDE.md inventor → coder gate, NOT self-merged.
199 lines
7.0 KiB
Go
199 lines
7.0 KiB
Go
// 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
|
|
}
|