//go:build integration package integration_test import ( "context" "os" "path/filepath" "sort" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) const migrationsDir = "../../internal/storage/migrations" // TestMigrationRoundtrip applies every up migration, runs every down in // reverse, then applies the ups again, against a fresh Postgres // container. Catches any down.sql that's missing, broken, or asymmetric // with its up — Block G's "every migration has a tested down" check. func TestMigrationRoundtrip(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) dsn := startPostgres(t, ctx) pool, err := pgxpool.New(ctx, dsn) must(t, err, "connect") t.Cleanup(pool.Close) ups, downs := loadMigrations(t) // Phase 1: apply all ups in order. Mirrors what the API does at boot. for _, m := range ups { t.Logf("up: %s", m.version) execAll(t, ctx, pool, m.sql) } // Sanity: the latest table from each migration exists. for _, expected := range []string{ "users", "rsvps", "refresh_tokens", "unsubscribes", "subscriptions", } { var exists bool must(t, pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name=$1)`, expected, ).Scan(&exists), "check "+expected) if !exists { t.Fatalf("after ups: table %q is missing", expected) } } // Phase 2: apply downs in REVERSE order. Each must execute without // error (even though the result is allowed to be lossy — down // migrations are not required to preserve data). for i := len(downs) - 1; i >= 0; i-- { m := downs[i] t.Logf("down: %s", m.version) execAll(t, ctx, pool, m.sql) } // All app tables should be gone now. for _, gone := range []string{ "users", "events", "guests", "tokens", "rsvps", "access_logs", "notifications", "subscriptions", } { var exists bool must(t, pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name=$1)`, gone, ).Scan(&exists), "check "+gone) if exists { t.Errorf("after downs: %q still exists — incomplete down migration", gone) } } // Phase 3: re-apply all ups. This catches down migrations that // leave hidden state (types, sequences, indexes, extensions) which // would clash on the second up. for _, m := range ups { t.Logf("up2: %s", m.version) execAll(t, ctx, pool, m.sql) } } type migration struct { version string sql string } func loadMigrations(t *testing.T) (ups, downs []migration) { t.Helper() entries, err := os.ReadDir(migrationsDir) must(t, err, "read migrations dir") for _, e := range entries { name := e.Name() b, err := os.ReadFile(filepath.Join(migrationsDir, name)) must(t, err, "read "+name) m := migration{sql: string(b)} switch { case strings.HasSuffix(name, ".up.sql"): m.version = strings.TrimSuffix(name, ".up.sql") ups = append(ups, m) case strings.HasSuffix(name, ".down.sql"): m.version = strings.TrimSuffix(name, ".down.sql") downs = append(downs, m) } } sort.Slice(ups, func(i, j int) bool { return ups[i].version < ups[j].version }) sort.Slice(downs, func(i, j int) bool { return downs[i].version < downs[j].version }) if len(ups) != len(downs) { t.Fatalf("up/down count mismatch: %d ups, %d downs — every migration needs a .down.sql", len(ups), len(downs)) } for i := range ups { if ups[i].version != downs[i].version { t.Fatalf("migration %s has no matching down (or vice versa)", ups[i].version) } } return ups, downs } func execAll(t *testing.T, ctx context.Context, pool *pgxpool.Pool, sql string) { t.Helper() _, err := pool.Exec(ctx, sql) must(t, err, "exec migration") }