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.
171 lines
5.6 KiB
Go
171 lines
5.6 KiB
Go
// Boot smoke test — assert paliad reaches a serving state.
|
|
//
|
|
// Three checks against TEST_DATABASE_URL:
|
|
//
|
|
// 1. db.ApplyMigrations does not panic and returns nil.
|
|
// 2. The migration tracker (public.paliad_schema_migrations) advances to
|
|
// the highest *.up.sql version on disk — no migrations were silently
|
|
// skipped, no "dirty=true" stragglers left behind.
|
|
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
|
|
//
|
|
// This is the lightweight cousin of the migration dry-run gate
|
|
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
|
|
// errors before merge; this smoke confirms the apply+bind path the
|
|
// container actually runs at boot. Together they cover the mig-098 /
|
|
// mig-099 class of crash-loops end-to-end.
|
|
//
|
|
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
|
|
//
|
|
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
|
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/auth"
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
"mgit.msbls.de/m/paliad/internal/handlers"
|
|
)
|
|
|
|
func TestBootSmoke(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping boot smoke")
|
|
}
|
|
|
|
// (1) Apply migrations end-to-end. The same code path the prod
|
|
// container runs at boot before `http.ListenAndServe`. A regression
|
|
// like mig-098's digit-regex would surface here as a non-nil error.
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("db.ApplyMigrations: %v", err)
|
|
}
|
|
|
|
// (2) Assert the tracker advanced to the highest *.up.sql version we
|
|
// embed. If a migration was silently skipped or the tracker is dirty,
|
|
// the prod container would crash-loop — this turns that into a test
|
|
// failure with a precise reason.
|
|
expected := highestEmbeddedMigrationVersion(t)
|
|
got, dirty := readTrackerVersion(t, url)
|
|
if dirty {
|
|
t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got)
|
|
}
|
|
if got != expected {
|
|
t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+
|
|
"A migration was skipped or applied out of order.",
|
|
got, expected)
|
|
}
|
|
|
|
// (3) Mount the public handlers (the same Register call main() makes,
|
|
// minus the DB-backed Services bundle which the /healthz route doesn't
|
|
// need) and assert /healthz returns 200. This is the bind-and-serve
|
|
// half of the smoke: catches a regression that would make /healthz
|
|
// 404 or break the mux registration order.
|
|
//
|
|
// We deliberately do not boot the full main() — that would require
|
|
// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open
|
|
// listening socket and a real auth client. The /healthz handler is
|
|
// auth-independent by design, and Register registers it on the outer
|
|
// mux before any DB-backed route, so this minimal setup exercises the
|
|
// exact code path main() takes.
|
|
mux := http.NewServeMux()
|
|
authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret"))
|
|
handlers.Register(mux, authClient, "", nil)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String())
|
|
}
|
|
if body := strings.TrimSpace(rec.Body.String()); body != "ok" {
|
|
t.Errorf("GET /healthz: body=%q; want \"ok\"", body)
|
|
}
|
|
}
|
|
|
|
// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql
|
|
// file in internal/db/migrations/ on disk. Used as the expected tracker
|
|
// version after a clean apply. We read from disk (not the embed.FS in
|
|
// the db package — it's unexported) since the test runs from the repo.
|
|
func highestEmbeddedMigrationVersion(t *testing.T) int {
|
|
t.Helper()
|
|
root, err := repoRoot()
|
|
if err != nil {
|
|
t.Fatalf("locate repo root: %v", err)
|
|
}
|
|
dir := filepath.Join(root, "internal", "db", "migrations")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("read migrations dir %s: %v", dir, err)
|
|
}
|
|
var versions []int
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasSuffix(name, ".up.sql") {
|
|
continue
|
|
}
|
|
base := strings.TrimSuffix(name, ".up.sql")
|
|
underscore := strings.IndexByte(base, '_')
|
|
if underscore <= 0 {
|
|
continue
|
|
}
|
|
v, err := strconv.Atoi(base[:underscore])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
versions = append(versions, v)
|
|
}
|
|
if len(versions) == 0 {
|
|
t.Fatalf("no *.up.sql files found in %s", dir)
|
|
}
|
|
sort.Ints(versions)
|
|
return versions[len(versions)-1]
|
|
}
|
|
|
|
// readTrackerVersion fetches the lone row from the tracker. golang-migrate
|
|
// keeps exactly one row; if we ever see zero or more, that's the dirty-state
|
|
// the test is designed to flag.
|
|
func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) {
|
|
t.Helper()
|
|
conn, err := sql.Open("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`)
|
|
if err := row.Scan(&version, &dirty); err != nil {
|
|
t.Fatalf("read tracker: %v", err)
|
|
}
|
|
return version, dirty
|
|
}
|
|
|
|
// repoRoot walks upward from the test binary's working directory until it
|
|
// finds a go.mod. `go test` runs in the package dir, so we typically have
|
|
// to climb a couple of levels.
|
|
func repoRoot() (string, error) {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
return dir, nil
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return "", os.ErrNotExist
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|