375 lines
12 KiB
Go
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
|
|
}
|