Files
galaxy-game/backend/internal/lobby/lobby_e2e_test.go
T
2026-05-07 00:58:53 +03:00

375 lines
12 KiB
Go

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)
}
}
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
}