// 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. paliad.applied_migrations covers every on-disk *.up.sql — no // migration was silently skipped, no version is missing. The set // contract is stronger than the old single-counter check: applied // set must EQUAL on-disk set, not just reach the max version. // 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, plus the mig-103 parallel-merge // skip-hole that t-paliad-218 closed (m/paliad#44). // // 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 and // docs/design-migration-runner-applied-set-2026-05-20.md §6. 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 applied set equals the on-disk set. The new runner // tracks applied state per-migration; a silently-skipped version // would surface as a row missing from paliad.applied_migrations even // though max(version) matches. Comparing sets — not just max — // catches the failure mode the t-paliad-218 post-mortem documented. onDisk := embeddedMigrationVersions(t) applied := appliedMigrationVersions(t, url) if missing := setDiff(onDisk, applied); len(missing) > 0 { t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+ "(a migration was skipped — investigate before deploying)", len(missing), missing) } if extra := setDiff(applied, onDisk); len(extra) > 0 { t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+ "(orphan rows — either restore the file or DELETE the row)", len(extra), extra) } // (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) } // (4) Readiness probe. With a nil Services bundle the endpoint MUST // report 503 — that's the contract documented in handlers/handlers.go. // A separate svc-with-Pool case is exercised in TestHealthReady (live). rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/health/ready", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Errorf("GET /health/ready (nil svc): status=%d; want 503", rec.Code) } } // TestHealthReady_Live asserts the readiness probe answers 200 when the // pool is reachable, 503 when it isn't. Requires TEST_DATABASE_URL. // // Why a separate test: TestBootSmoke runs Register with svc=nil to keep // its setup minimal; the pool-reachable path needs the pool wired in // through svc.Pool. Two tests, two assertions, no entanglement. func TestHealthReady_Live(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL not set — skipping live readiness probe") } if err := db.ApplyMigrations(url); err != nil { t.Fatalf("db.ApplyMigrations: %v", err) } pool, err := db.OpenPool(url) if err != nil { t.Fatalf("open pool: %v", err) } mux := http.NewServeMux() authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret")) handlers.Register(mux, authClient, "", &handlers.Services{Pool: pool}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/health/ready", nil) mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("GET /health/ready (live pool): status=%d, body=%q; want 200", rec.Code, rec.Body.String()) } if body := strings.TrimSpace(rec.Body.String()); body != "ready" { t.Errorf("GET /health/ready (live pool): body=%q; want \"ready\"", body) } } // embeddedMigrationVersions returns every N where N_*.up.sql exists in // internal/db/migrations/ on disk. The boot smoke compares this set // against paliad.applied_migrations to detect skipped or orphan // migrations. // // Read from disk (not the embed.FS inside the db package — it's unexported) // since the test runs from the repo. The two views must agree for the // build to be self-consistent; if they diverge, the smoke test is the // wrong place to learn about it (the build is). We trust them to match. func embeddedMigrationVersions(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 } // appliedMigrationVersions reads paliad.applied_migrations and returns // the sorted list of versions. Fails the test if the table doesn't exist — // db.ApplyMigrations is supposed to have created it by this point. func appliedMigrationVersions(t *testing.T, url string) []int { t.Helper() conn, err := sql.Open("postgres", url) if err != nil { t.Fatalf("open: %v", err) } defer conn.Close() rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`) if err != nil { t.Fatalf("read applied_migrations: %v", err) } defer rows.Close() var out []int for rows.Next() { var v int if err := rows.Scan(&v); err != nil { t.Fatalf("scan: %v", err) } out = append(out, v) } if err := rows.Err(); err != nil { t.Fatalf("rows: %v", err) } return out } // setDiff returns the elements of a that are not in b. Inputs are sorted // ascending; output preserves that ordering. func setDiff(a, b []int) []int { bset := make(map[int]bool, len(b)) for _, v := range b { bset[v] = true } var out []int for _, v := range a { if !bset[v] { out = append(out, v) } } return out } // 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 } }