feat: game lobby service
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
package listmyracenames_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/listmyracenames"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// silentLogger discards all log output to keep test stdout clean.
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// fixture wires the listmyracenames service with the in-process
|
||||
// race-name directory stub and the in-process game store.
|
||||
type fixture struct {
|
||||
now time.Time
|
||||
directory *racenamestub.Directory
|
||||
games *gamestub.Store
|
||||
service *listmyracenames.Service
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return now }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return &fixture{now: now, directory: directory, games: games, service: svc}
|
||||
}
|
||||
|
||||
// seedGame stores one game record so reservation lookups have a record
|
||||
// to read. Private games receive a synthetic owner so domain
|
||||
// validation accepts them.
|
||||
func (f *fixture) seedGame(t *testing.T, id common.GameID, gameType game.GameType, status game.Status) {
|
||||
t.Helper()
|
||||
|
||||
owner := ""
|
||||
if gameType == game.GameTypePrivate {
|
||||
owner = "owner-" + id.String()
|
||||
}
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: id,
|
||||
GameName: "Seed " + id.String(),
|
||||
GameType: gameType,
|
||||
OwnerUserID: owner,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: f.now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: f.now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if status != game.StatusDraft {
|
||||
record.Status = status
|
||||
}
|
||||
require.NoError(t, f.games.Save(context.Background(), record))
|
||||
}
|
||||
|
||||
func (f *fixture) reserve(t *testing.T, gameID, userID, raceName string) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName))
|
||||
}
|
||||
|
||||
func (f *fixture) markPending(t *testing.T, gameID, userID, raceName string, eligibleUntil time.Time) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.directory.MarkPendingRegistration(context.Background(), gameID, userID, raceName, eligibleUntil))
|
||||
}
|
||||
|
||||
func (f *fixture) register(t *testing.T, gameID, userID, raceName string) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.directory.Register(context.Background(), gameID, userID, raceName))
|
||||
}
|
||||
|
||||
func TestHandleHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t)
|
||||
const userID = "user-1"
|
||||
|
||||
// One registered name converted from a finished game.
|
||||
f.seedGame(t, "game-finished", game.GameTypePublic, game.StatusFinished)
|
||||
f.reserve(t, "game-finished", userID, "Andromeda")
|
||||
f.markPending(t, "game-finished", userID, "Andromeda", f.now.Add(7*24*time.Hour))
|
||||
f.register(t, "game-finished", userID, "Andromeda")
|
||||
|
||||
// One pending registration awaiting the
|
||||
f.seedGame(t, "game-pending", game.GameTypePublic, game.StatusFinished)
|
||||
f.reserve(t, "game-pending", userID, "Vega")
|
||||
f.markPending(t, "game-pending", userID, "Vega", f.now.Add(24*time.Hour))
|
||||
|
||||
// Two active reservations across a running and an enrollment_open game.
|
||||
f.seedGame(t, "game-running", game.GameTypePrivate, game.StatusRunning)
|
||||
f.reserve(t, "game-running", userID, "Orion")
|
||||
f.seedGame(t, "game-open", game.GameTypePublic, game.StatusEnrollmentOpen)
|
||||
f.reserve(t, "game-open", userID, "Cygnus")
|
||||
|
||||
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, out.Registered, 1)
|
||||
assert.Equal(t, "Andromeda", out.Registered[0].RaceName)
|
||||
assert.Equal(t, "game-finished", out.Registered[0].SourceGameID)
|
||||
assert.Equal(t, f.now.UnixMilli(), out.Registered[0].RegisteredAtMs)
|
||||
assert.NotEmpty(t, out.Registered[0].CanonicalKey)
|
||||
|
||||
require.Len(t, out.Pending, 1)
|
||||
assert.Equal(t, "Vega", out.Pending[0].RaceName)
|
||||
assert.Equal(t, "game-pending", out.Pending[0].SourceGameID)
|
||||
assert.Equal(t, f.now.Add(24*time.Hour).UnixMilli(), out.Pending[0].EligibleUntilMs)
|
||||
assert.Equal(t, f.now.UnixMilli(), out.Pending[0].ReservedAtMs)
|
||||
|
||||
require.Len(t, out.Reservations, 2)
|
||||
gameStatusByID := map[string]string{}
|
||||
for _, r := range out.Reservations {
|
||||
gameStatusByID[r.GameID] = r.GameStatus
|
||||
}
|
||||
assert.Equal(t, string(game.StatusRunning), gameStatusByID["game-running"])
|
||||
assert.Equal(t, string(game.StatusEnrollmentOpen), gameStatusByID["game-open"])
|
||||
}
|
||||
|
||||
func TestHandleEmptyView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t)
|
||||
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
||||
Actor: shared.NewUserActor("user-empty"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, out.Registered)
|
||||
assert.NotNil(t, out.Pending)
|
||||
assert.NotNil(t, out.Reservations)
|
||||
assert.Empty(t, out.Registered)
|
||||
assert.Empty(t, out.Pending)
|
||||
assert.Empty(t, out.Reservations)
|
||||
}
|
||||
|
||||
func TestHandleAdminActorIsForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t)
|
||||
_, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, shared.ErrForbidden)
|
||||
}
|
||||
|
||||
func TestHandleInvalidActorIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t)
|
||||
_, err := f.service.Handle(context.Background(), listmyracenames.Input{Actor: shared.Actor{}})
|
||||
require.Error(t, err)
|
||||
assert.NotErrorIs(t, err, shared.ErrForbidden)
|
||||
}
|
||||
|
||||
func TestHandleVisibilityIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t)
|
||||
f.seedGame(t, "game-shared", game.GameTypePublic, game.StatusEnrollmentOpen)
|
||||
f.reserve(t, "game-shared", "user-owner", "Polaris")
|
||||
|
||||
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
||||
Actor: shared.NewUserActor("user-other"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.Reservations)
|
||||
assert.Empty(t, out.Pending)
|
||||
assert.Empty(t, out.Registered)
|
||||
}
|
||||
|
||||
func TestHandleReservationOnMissingGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t)
|
||||
f.reserve(t, "game-vanished", "user-1", "Hydra")
|
||||
|
||||
out, err := f.service.Handle(context.Background(), listmyracenames.Input{
|
||||
Actor: shared.NewUserActor("user-1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Reservations, 1)
|
||||
assert.Equal(t, "game-vanished", out.Reservations[0].GameID)
|
||||
assert.Empty(t, out.Reservations[0].GameStatus)
|
||||
}
|
||||
|
||||
func TestHandleSortByTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const userID = "user-sort"
|
||||
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
||||
clock := now
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(func() time.Time { return clock }))
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
svc, err := listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
Games: games,
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
seed := func(id common.GameID) {
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: id,
|
||||
GameName: "Seed " + id.String(),
|
||||
GameType: game.GameTypePublic,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.Status = game.StatusEnrollmentOpen
|
||||
require.NoError(t, games.Save(context.Background(), record))
|
||||
}
|
||||
seed("game-a")
|
||||
seed("game-b")
|
||||
seed("game-c")
|
||||
|
||||
// Reserve three names against an advancing clock so reserved_at_ms
|
||||
// is strictly increasing.
|
||||
clock = now
|
||||
require.NoError(t, directory.Reserve(context.Background(), "game-a", userID, "Lyra"))
|
||||
clock = now.Add(time.Minute)
|
||||
require.NoError(t, directory.Reserve(context.Background(), "game-b", userID, "Sirius"))
|
||||
clock = now.Add(2 * time.Minute)
|
||||
require.NoError(t, directory.Reserve(context.Background(), "game-c", userID, "Pavo"))
|
||||
|
||||
out, err := svc.Handle(context.Background(), listmyracenames.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Reservations, 3)
|
||||
for index := 1; index < len(out.Reservations); index++ {
|
||||
prev := out.Reservations[index-1]
|
||||
curr := out.Reservations[index]
|
||||
if prev.ReservedAtMs == curr.ReservedAtMs {
|
||||
assert.LessOrEqual(t, prev.CanonicalKey, curr.CanonicalKey)
|
||||
continue
|
||||
}
|
||||
assert.Less(t, prev.ReservedAtMs, curr.ReservedAtMs)
|
||||
}
|
||||
assert.Equal(t, "game-a", out.Reservations[0].GameID)
|
||||
assert.Equal(t, "game-b", out.Reservations[1].GameID)
|
||||
assert.Equal(t, "game-c", out.Reservations[2].GameID)
|
||||
}
|
||||
|
||||
// TestNewServiceRejectsMissingDeps guards against constructor misuse.
|
||||
func TestNewServiceRejectsMissingDeps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
games := gamestub.NewStore()
|
||||
|
||||
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Games: games,
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = listmyracenames.NewService(listmyracenames.Dependencies{
|
||||
Directory: directory,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Sanity guard so a future port refactor that drops the user-keyed
|
||||
// indexes immediately breaks the test build instead of silently
|
||||
// regressing the no-full-scan invariant.
|
||||
var _ ports.RaceNameDirectory = (*racenamestub.Directory)(nil)
|
||||
Reference in New Issue
Block a user