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