204 lines
5.4 KiB
Go
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
|
|
}
|