Files
paliad/internal/db/migrate_test.go
mAi 586ba29b86 feat(test): migration dry-run gate + boot smoke (Slice 1)
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.
2026-05-19 12:41:15 +02:00

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
}