package lobby_test import ( "context" "database/sql" "errors" "net/url" "testing" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/lobby" backendpg "galaxy/backend/internal/postgres" pgshared "galaxy/postgres" "github.com/google/uuid" testcontainers "github.com/testcontainers/testcontainers-go" tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) const ( testImage = "postgres:16-alpine" testUser = "galaxy" testPassword = "galaxy" testDatabase = "galaxy_backend" testSchema = "backend" testStartup = 90 * time.Second testOpTimeout = 10 * time.Second ) func startPostgres(t *testing.T) *sql.DB { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) t.Cleanup(cancel) pgContainer, err := tcpostgres.Run(ctx, testImage, tcpostgres.WithDatabase(testDatabase), tcpostgres.WithUsername(testUser), tcpostgres.WithPassword(testPassword), testcontainers.WithWaitStrategy( wait.ForLog("database system is ready to accept connections"). WithOccurrence(2). WithStartupTimeout(testStartup), ), ) if err != nil { 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("connection string: %v", err) } scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema) if err != nil { t.Fatalf("scope dsn: %v", err) } cfg := pgshared.DefaultConfig() cfg.PrimaryDSN = scopedDSN cfg.OperationTimeout = testOpTimeout 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) } return db } 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 } type stubEntitlement struct { max int32 } func (s stubEntitlement) GetMaxRegisteredRaceNames(_ context.Context, _ uuid.UUID) (int32, error) { return s.max, nil } func newServiceForTest(t *testing.T, db *sql.DB, now func() time.Time, max int32) *lobby.Service { t.Helper() store := lobby.NewStore(db) cache := lobby.NewCache() if err := cache.Warm(context.Background(), store); err != nil { t.Fatalf("warm cache: %v", err) } svc, err := lobby.NewService(lobby.Deps{ Store: store, Cache: cache, Entitlement: stubEntitlement{max: max}, Config: config.LobbyConfig{ SweeperInterval: time.Second, PendingRegistrationTTL: time.Hour, InviteDefaultTTL: time.Hour, }, Now: now, }) if err != nil { t.Fatalf("new service: %v", err) } return svc } // seedAccount inserts a minimal accounts row so games / memberships // referencing user_id can be created without violating any FK. func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) { t.Helper() _, err := db.ExecContext(context.Background(), ` INSERT INTO backend.accounts ( user_id, email, user_name, preferred_language, time_zone ) VALUES ($1, $2, $3, 'en', 'UTC') `, userID, userID.String()+"@test.local", "user-"+userID.String()[:8]) if err != nil { t.Fatalf("seed account %s: %v", userID, err) } } func TestEndToEndPrivateGameFlow(t *testing.T) { db := startPostgres(t) now := time.Now().UTC() clock := func() time.Time { return now } svc := newServiceForTest(t, db, clock, 5) owner := uuid.New() seedAccount(t, db, owner) game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{ OwnerUserID: &owner, Visibility: lobby.VisibilityPrivate, GameName: "End-to-End Game", MinPlayers: 1, MaxPlayers: 4, StartGapHours: 1, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(time.Hour), TurnSchedule: "0 0 * * *", TargetEngineVersion: "1.0.0", }) if err != nil { t.Fatalf("create game: %v", err) } if game.Status != lobby.GameStatusDraft { t.Fatalf("create game status = %q, want draft", game.Status) } if got, ok := svc.Cache().GetGame(game.GameID); !ok || got.GameID != game.GameID { t.Fatalf("game not cached after create") } if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("open enrollment: %v", err) } // Approve a member to clear min_players. applicant := uuid.New() seedAccount(t, db, applicant) game = mustGet(t, svc, game.GameID) // public-only handler does not run on private games; bypass via direct // membership insert through the store to focus on state-machine. store := lobby.NewStore(db) canonicalPolicy, err := lobby.NewPolicy() if err != nil { t.Fatalf("new policy: %v", err) } canonical, err := canonicalPolicy.Canonical("PrivateRace") if err != nil { t.Fatalf("canonical: %v", err) } if _, err := db.ExecContext(context.Background(), ` INSERT INTO backend.memberships ( membership_id, game_id, user_id, race_name, canonical_key, status ) VALUES ($1, $2, $3, $4, $5, 'active') `, uuid.New(), game.GameID, applicant, "PrivateRace", string(canonical)); err != nil { t.Fatalf("seed membership: %v", err) } // Re-warm cache so the new membership flows through MembershipsForGame. if err := svc.Cache().Warm(context.Background(), store); err != nil { t.Fatalf("re-warm cache: %v", err) } if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("ready-to-start: %v", err) } if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("start: %v", err) } game = mustGet(t, svc, game.GameID) if game.Status != lobby.GameStatusStarting { t.Fatalf("after start status = %q, want starting", game.Status) } // Simulate runtime → running. if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{ CurrentTurn: 1, RuntimeStatus: "running", }); err != nil { t.Fatalf("on-runtime-snapshot running: %v", err) } game = mustGet(t, svc, game.GameID) if game.Status != lobby.GameStatusRunning { t.Fatalf("after runtime snapshot status = %q, want running", game.Status) } if _, err := svc.Pause(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("pause: %v", err) } if _, err := svc.Resume(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("resume: %v", err) } if _, err := svc.Cancel(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("cancel: %v", err) } game, err = svc.GetGame(context.Background(), game.GameID) if err != nil { t.Fatalf("get cancelled: %v", err) } if game.Status != lobby.GameStatusCancelled { t.Fatalf("after cancel status = %q, want cancelled", game.Status) } } // TestDeleteGameCascadesEverything pins the contract the dev-sandbox // bootstrap relies on: removing a game wipes every referencing row // (memberships, applications, invites, runtime_records, // player_mappings) in a single SQL statement. Before this is wired // the developer's lobby pile up cancelled tiles between // `make rebuild` cycles; with it, every boot starts from a clean // slate. func TestDeleteGameCascadesEverything(t *testing.T) { db := startPostgres(t) now := time.Now().UTC() clock := func() time.Time { return now } svc := newServiceForTest(t, db, clock, 5) owner := uuid.New() seedAccount(t, db, owner) game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{ OwnerUserID: &owner, Visibility: lobby.VisibilityPrivate, GameName: "Doomed", MinPlayers: 1, MaxPlayers: 4, StartGapHours: 1, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(time.Hour), TurnSchedule: "0 0 * * *", TargetEngineVersion: "1.0.0", }) if err != nil { t.Fatalf("create game: %v", err) } if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil { t.Fatalf("open enrollment: %v", err) } if _, err := svc.InsertMembershipDirect(context.Background(), lobby.InsertMembershipDirectInput{ GameID: game.GameID, UserID: owner, RaceName: "Owner", }); err != nil { t.Fatalf("insert membership: %v", err) } if err := svc.DeleteGame(context.Background(), game.GameID); err != nil { t.Fatalf("delete game: %v", err) } // Verify cascade: the game must be gone, ListMyGames must drop // it, and re-deleting the same id is a no-op. if _, err := svc.GetGame(context.Background(), game.GameID); !errors.Is(err, lobby.ErrNotFound) { t.Fatalf("get after delete: err = %v, want ErrNotFound", err) } games, err := svc.ListMyGames(context.Background(), owner) if err != nil { t.Fatalf("list my games: %v", err) } for _, g := range games { if g.GameID == game.GameID { t.Fatalf("ListMyGames still lists the deleted game") } } if err := svc.DeleteGame(context.Background(), game.GameID); err != nil { t.Fatalf("delete idempotent: %v", err) } } func TestEndToEndPublicGameApplicationApproval(t *testing.T) { db := startPostgres(t) now := time.Now().UTC() clock := func() time.Time { return now } svc := newServiceForTest(t, db, clock, 5) game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{ OwnerUserID: nil, Visibility: lobby.VisibilityPublic, GameName: "Public Game", MinPlayers: 1, MaxPlayers: 8, StartGapHours: 1, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(time.Hour), TurnSchedule: "0 0 * * *", TargetEngineVersion: "1.0.0", }) if err != nil { t.Fatalf("create public game: %v", err) } // Move to enrollment_open via admin force-start path is wrong; use // transition via admin OpenEnrollment by passing callerIsAdmin=true. if _, err := svc.OpenEnrollment(context.Background(), nil, true, game.GameID); err != nil { t.Fatalf("open enrollment (admin): %v", err) } applicant := uuid.New() seedAccount(t, db, applicant) app, err := svc.SubmitApplication(context.Background(), lobby.SubmitApplicationInput{ GameID: game.GameID, ApplicantUserID: applicant, RaceName: "AlphaCentauri", }) if err != nil { t.Fatalf("submit application: %v", err) } if app.Status != lobby.ApplicationStatusPending { t.Fatalf("application status = %q, want pending", app.Status) } approved, err := svc.ApproveApplication(context.Background(), nil, true, game.GameID, app.ApplicationID) if err != nil { t.Fatalf("approve application: %v", err) } if approved.Status != lobby.ApplicationStatusApproved { t.Fatalf("approved status = %q, want approved", approved.Status) } memberships, err := svc.ListMembershipsForGame(context.Background(), game.GameID) if err != nil { t.Fatalf("list memberships: %v", err) } if len(memberships) != 1 || memberships[0].UserID != applicant { t.Fatalf("memberships = %+v, want one for %s", memberships, applicant) } // Re-applying the same race name from a different user must conflict. other := uuid.New() seedAccount(t, db, other) _, err = svc.SubmitApplication(context.Background(), lobby.SubmitApplicationInput{ GameID: game.GameID, ApplicantUserID: other, RaceName: "AlphaCentauri", }) if err != nil { t.Fatalf("second application setup: %v", err) } if _, err := svc.ApproveApplication(context.Background(), nil, true, game.GameID, secondApplication(t, db, game.GameID, other)); err == nil { t.Fatal("approving second application with same race name should conflict") } else if !errors.Is(err, lobby.ErrRaceNameTaken) { t.Fatalf("approve second application: err = %v, want ErrRaceNameTaken", err) } } func TestSweeperReleasesExpiredPendingRegistrations(t *testing.T) { db := startPostgres(t) now := time.Now().UTC() clock := func() time.Time { return now } svc := newServiceForTest(t, db, clock, 5) user := uuid.New() seedAccount(t, db, user) gameID := uuid.New() expired := now.Add(-time.Hour) if _, err := db.ExecContext(context.Background(), ` INSERT INTO backend.race_names ( name, canonical, status, owner_user_id, game_id, expires_at ) VALUES ('Vega', 'vega', 'pending_registration', $1, $2, $3) `, user, gameID, expired); err != nil { t.Fatalf("seed pending row: %v", err) } sweeper := lobby.NewSweeper(svc) if err := sweeper.Tick(context.Background()); err != nil { t.Fatalf("sweeper tick: %v", err) } rows, err := lobby.NewStore(db).FindRaceNameByCanonical(context.Background(), "vega") if err != nil { t.Fatalf("find canonical after sweep: %v", err) } if len(rows) != 0 { t.Fatalf("expected pending row to be released, got %d rows", len(rows)) } } func mustGet(t *testing.T, svc *lobby.Service, gameID uuid.UUID) lobby.GameRecord { t.Helper() g, err := svc.GetGame(context.Background(), gameID) if err != nil { t.Fatalf("get game %s: %v", gameID, err) } return g } // secondApplication looks up the second application id (the one // submitted by `userID`) on `gameID`. The test seeds two applications // in `TestEndToEndPublicGameApplicationApproval` and uses this helper // to fetch the not-yet-decided one without coupling the test to insert // order. func secondApplication(t *testing.T, db *sql.DB, gameID, userID uuid.UUID) uuid.UUID { t.Helper() var id uuid.UUID if err := db.QueryRowContext(context.Background(), ` SELECT application_id FROM backend.applications WHERE game_id = $1 AND applicant_user_id = $2 `, gameID, userID).Scan(&id); err != nil { t.Fatalf("lookup second application: %v", err) } return id }