package postgres_test import ( "context" "net/url" "sort" "testing" "time" backendpg "galaxy/backend/internal/postgres" pgshared "galaxy/postgres" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) const ( migrationsTestImage = "postgres:16-alpine" migrationsTestUser = "galaxy" migrationsTestPassword = "galaxy" migrationsTestDatabase = "galaxy_backend" migrationsTestSchema = "backend" migrationsTestStartup = 90 * time.Second migrationsTestOpTimeout = 10 * time.Second ) // expectedBackendTables enumerates every table the embedded migration // set is expected to materialise inside the `backend` schema. Adding a // table to the migration without updating this list fails the smoke // test loudly so readers cannot lose sight of the schema surface. var expectedBackendTables = []string{ // Auth domain. "auth_challenges", "blocked_emails", "device_sessions", "session_revocations", // User domain. "accounts", "entitlement_records", "entitlement_snapshots", "limit_active", "limit_records", "sanction_active", "sanction_records", // Admin domain. "admin_accounts", // Lobby domain. "applications", "games", "invites", "memberships", "race_names", // Runtime domain. "engine_versions", "player_mappings", "runtime_health_snapshots", "runtime_operation_log", "runtime_records", // Mail domain. "mail_attempts", "mail_dead_letters", "mail_deliveries", "mail_payloads", "mail_recipients", // Notification domain. "notification_dead_letters", "notification_malformed_intents", "notification_routes", "notifications", // Diplomail domain. "diplomail_messages", "diplomail_recipients", "diplomail_translations", // Geo domain. "user_country_counters", } func TestMigrationsApplyToFreshSchema(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) pgContainer, err := tcpostgres.Run(ctx, migrationsTestImage, tcpostgres.WithDatabase(migrationsTestDatabase), tcpostgres.WithUsername(migrationsTestUser), tcpostgres.WithPassword(migrationsTestPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(migrationsTestStartup), ), ) if err != nil { // testcontainers fails fast when no Docker daemon is reachable; skip // rather than fail so the test stays green on machines without // Docker (CI without Docker, sandboxed runners, etc.). t.Skipf("postgres testcontainer unavailable, skipping: %v", err) } t.Cleanup(func() { if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil { t.Errorf("terminate postgres container: %v", termErr) } }) baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("postgres connection string: %v", err) } scopedDSN, err := dsnWithSearchPath(baseDSN, migrationsTestSchema) if err != nil { t.Fatalf("scope dsn to %s: %v", migrationsTestSchema, err) } cfg := pgshared.DefaultConfig() cfg.PrimaryDSN = scopedDSN cfg.OperationTimeout = migrationsTestOpTimeout db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...) if err != nil { t.Fatalf("open primary: %v", err) } t.Cleanup(func() { if err := db.Close(); err != nil { t.Errorf("close db: %v", err) } }) if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil { t.Fatalf("ping: %v", err) } if err := backendpg.ApplyMigrations(ctx, db); err != nil { t.Fatalf("apply migrations: %v", err) } t.Run("backend schema exists", func(t *testing.T) { var present bool if err := db.QueryRowContext(ctx, ` SELECT EXISTS ( SELECT 1 FROM information_schema.schemata WHERE schema_name = $1 ) `, migrationsTestSchema).Scan(&present); err != nil { t.Fatalf("query schema existence: %v", err) } if !present { t.Fatalf("expected schema %q to exist after migrations", migrationsTestSchema) } }) t.Run("every expected table is present", func(t *testing.T) { rows, err := db.QueryContext(ctx, ` SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE' `, migrationsTestSchema) if err != nil { t.Fatalf("list backend tables: %v", err) } defer func() { _ = rows.Close() }() got := make(map[string]struct{}) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { t.Fatalf("scan table name: %v", err) } got[name] = struct{}{} } if err := rows.Err(); err != nil { t.Fatalf("iterate table rows: %v", err) } // Goose's own bookkeeping table lives inside the same schema. It is // not a backend domain table; drop it from the comparison so a // goose upgrade that renames the tracker does not break the test. delete(got, "goose_db_version") var missing, extra []string for _, want := range expectedBackendTables { if _, ok := got[want]; !ok { missing = append(missing, want) } delete(got, want) } for name := range got { extra = append(extra, name) } sort.Strings(missing) sort.Strings(extra) if len(missing) > 0 || len(extra) > 0 { t.Fatalf("backend tables mismatch: missing=%v extra=%v", missing, extra) } }) } func dsnWithSearchPath(baseDSN, schema string) (string, error) { parsed, err := url.Parse(baseDSN) if err != nil { return "", err } values := parsed.Query() values.Set("search_path", schema) if values.Get("sslmode") == "" { values.Set("sslmode", "disable") } parsed.RawQuery = values.Encode() return parsed.String(), nil }