//go:build integration package store import ( "context" "database/sql" "os" "path/filepath" "testing" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" _ "github.com/jackc/pgx/v5/stdlib" ) // This binary is deliberately opt-in. It requires a disposable PostgreSQL // instance supplied by scripts/run_postgres_integration.sh. func openIntegrationPostgres(t *testing.T) *sql.DB { t.Helper() dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN") if dsn == "" { t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set") } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatalf("open PostgreSQL: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { db.Close() t.Fatalf("ping PostgreSQL: %v", err) } t.Cleanup(func() { db.Close() }) return db } func applyIntegrationMigrations(t *testing.T, db *sql.DB) { t.Helper() if _, err := db.ExecContext(context.Background(), `DROP TABLE IF EXISTS assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil { t.Fatalf("reset PostgreSQL schema: %v", err) } for _, name := range []string{"0001_initial.sql", "0002_assignments.sql"} { path := filepath.Join("..", "migrations", name) sqlBytes, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if _, err := db.ExecContext(context.Background(), string(sqlBytes)); err != nil { t.Fatalf("apply %s: %v", name, err) } } } func TestPostgreSQLQueueAdapterAgainstRealDatabase(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db) now := time.Now().UTC().Truncate(time.Microsecond) ctx := context.Background() if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('integration-player', 'integration-steam')`); err != nil { t.Fatal(err) } spec := domain.QueueSpec{Playlist: domain.Casual, ClientBuild: "integration-build", ProtocolVersion: 1} ticket, err := CreateQueueTicket(ctx, db, "integration-ticket", "integration-player", "integration-create-0001", spec, now) if err != nil { t.Fatalf("create queue ticket: %v", err) } if ticket.State != domain.Queued || ticket.Revision != 0 { t.Fatalf("unexpected ticket: %+v", ticket) } replay, err := CreateQueueTicket(ctx, db, "integration-ticket", "integration-player", "integration-create-0001", spec, now.Add(time.Second)) if err != nil { t.Fatalf("idempotent queue replay: %v", err) } if replay.TicketID != ticket.TicketID || !replay.ExpiresAt.Equal(ticket.ExpiresAt) { t.Fatalf("replay changed durable result: %+v vs %+v", replay, ticket) } if _, err := CreateQueueTicket(ctx, db, "integration-ticket-2", "integration-player", "integration-create-0002", spec, now); err == nil { t.Fatal("second active player ticket was accepted") } if _, err := GetQueueTicket(ctx, db, "integration-player", "integration-ticket", now); err != nil { t.Fatalf("owner recovery: %v", err) } if _, err := GetQueueTicket(ctx, db, "other-player", "integration-ticket", now); err == nil { t.Fatal("non-owner recovered queue ticket") } } func TestPostgreSQLQueueHeartbeatAndCancelAreRevisionFenced(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db) now := time.Now().UTC().Truncate(time.Microsecond) ctx := context.Background() if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('heartbeat-player', 'heartbeat-steam')`); err != nil { t.Fatal(err) } spec := domain.QueueSpec{Playlist: domain.Ranked, ClientBuild: "integration-build", ProtocolVersion: 1} if _, err := CreateQueueTicket(ctx, db, "heartbeat-ticket", "heartbeat-player", "heartbeat-create-0001", spec, now); err != nil { t.Fatal(err) } heartbeat, err := HeartbeatQueueTicket(ctx, db, "heartbeat-player", "heartbeat-ticket", "heartbeat-op-0000001", 0, now.Add(5*time.Second)) if err != nil { t.Fatalf("heartbeat: %v", err) } if heartbeat.Revision != 1 || !heartbeat.ExpiresAt.Equal(now.Add(35*time.Second)) { t.Fatalf("unexpected heartbeat result: %+v", heartbeat) } if _, err := HeartbeatQueueTicket(ctx, db, "heartbeat-player", "heartbeat-ticket", "heartbeat-op-0000002", 0, now.Add(6*time.Second)); err == nil { t.Fatal("stale heartbeat revision was accepted") } cancelled, err := CancelQueueTicket(ctx, db, "heartbeat-player", "heartbeat-ticket", "heartbeat-op-0000003", 1, now.Add(7*time.Second)) if err != nil { t.Fatalf("cancel: %v", err) } if cancelled.State != domain.Cancelled || cancelled.Revision != 2 { t.Fatalf("unexpected cancellation result: %+v", cancelled) } } func TestPostgreSQLAssignmentPersistenceIsPlayerScopedAndExpiryBound(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db) now := time.Now().UTC().Truncate(time.Microsecond) ctx := context.Background() for _, player := range []string{"assignment-player", "assignment-other"} { if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $1)`, player); err != nil { t.Fatal(err) } } if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ('assignment-ticket', 'assignment-player', 'casual', 'ASSIGNED', 'integration-build', 1, $1, $2)`, now, now.Add(time.Minute)); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id) VALUES ('assignment-match', 'casual', 'ASSIGNED', 'EU', 1, 'assignment-server')`); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('assignment-match', 'assignment-player', 'assignment-ticket', 0, 0)`); err != nil { t.Fatal(err) } assignment := DurableAssignment{MatchID: "assignment-match", PlayerID: "assignment-player", AllocationID: "allocation-1", ServerID: "assignment-server", Slot: 0, Region: "EU", ClientBuild: "integration-build", ProtocolVersion: 1, Transport: "enet", Endpoint: "127.0.0.1:7777", JoinAuthorisation: "join-token", ManifestDigest: []byte("manifest"), ExpiresAt: now.Add(time.Minute), Revision: 1} if err := SaveAssignment(ctx, db, assignment); err != nil { t.Fatalf("save assignment: %v", err) } got, err := GetAssignment(ctx, db, assignment.PlayerID, assignment.MatchID, now) if err != nil { t.Fatalf("recover assignment: %v", err) } if got.JoinAuthorisation != assignment.JoinAuthorisation || got.Slot != assignment.Slot { t.Fatalf("assignment changed on round trip: %+v", got) } if _, err := GetAssignment(ctx, db, "assignment-other", assignment.MatchID, now); err == nil { t.Fatal("non-owner recovered assignment") } if _, err := GetAssignment(ctx, db, assignment.PlayerID, assignment.MatchID, now.Add(2*time.Minute)); err == nil { t.Fatal("expired assignment was recovered") } } func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db) var tableCount int if err := db.QueryRow(`SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'assignments'`).Scan(&tableCount); err != nil { t.Fatal(err) } if tableCount != 1 { t.Fatal("assignments migration did not create its table") } }