package listmyracenames_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/racenameinmem" "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 *racenameinmem.Directory games *gameinmem.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 := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return now })) require.NoError(t, err) games := gameinmem.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 := racenameinmem.NewDirectory(racenameinmem.WithClock(func() time.Time { return clock })) require.NoError(t, err) games := gameinmem.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 := racenameinmem.NewDirectory() require.NoError(t, err) games := gameinmem.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 = (*racenameinmem.Directory)(nil)