feat: backend service
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user