Files
galaxy-game/backend/internal/postgres/migrations_test.go
T
2026-05-06 10:14:55 +03:00

204 lines
5.4 KiB
Go

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",
// 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",
// 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)
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
}