package listgames_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/membershipstub" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/service/listgames" "galaxy/lobby/internal/service/shared" "github.com/stretchr/testify/require" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type fixture struct { games *gamestub.Store memberships *membershipstub.Store svc *listgames.Service } func newFixture(t *testing.T) *fixture { t.Helper() games := gamestub.NewStore() memberships := membershipstub.NewStore() svc, err := listgames.NewService(listgames.Dependencies{ Games: games, Memberships: memberships, Logger: silentLogger(), }) require.NoError(t, err) return &fixture{games: games, memberships: memberships, svc: svc} } func seedGameAt( t *testing.T, store *gamestub.Store, id common.GameID, gameType game.GameType, ownerUserID string, status game.Status, createdAt time.Time, ) game.Game { t.Helper() record, err := game.New(game.NewGameInput{ GameID: id, GameName: "Seed", GameType: gameType, OwnerUserID: ownerUserID, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 4, StartGapPlayers: 1, EnrollmentEndsAt: createdAt.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: createdAt, }) require.NoError(t, err) if status != game.StatusDraft { record.Status = status record.UpdatedAt = createdAt.Add(time.Minute) } require.NoError(t, store.Save(context.Background(), record)) return record } func seedActiveMembership( t *testing.T, store *membershipstub.Store, gameID common.GameID, userID string, now time.Time, ) { t.Helper() record, err := membership.New(membership.NewMembershipInput{ MembershipID: common.MembershipID("membership-" + userID + "-" + gameID.String()), GameID: gameID, UserID: userID, RaceName: "Race-" + userID, CanonicalKey: "race-" + userID, Now: now, }) require.NoError(t, err) require.NoError(t, store.Save(context.Background(), record)) } func TestHandlePublicListExcludesDraftAndCancelled(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) included := []game.Status{ game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusRunning, game.StatusFinished, } excluded := []game.Status{ game.StatusDraft, game.StatusCancelled, game.StatusPaused, game.StatusStarting, game.StatusStartFailed, } for i, status := range included { id := common.GameID("game-public-incl-" + string(status)) seedGameAt(t, fix.games, id, game.GameTypePublic, "", status, now.Add(time.Duration(i)*time.Minute)) } for i, status := range excluded { id := common.GameID("game-public-excl-" + string(status)) seedGameAt(t, fix.games, id, game.GameTypePublic, "", status, now.Add(time.Duration(i)*time.Minute)) } out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-stranger"), }) require.NoError(t, err) require.Len(t, out.Items, len(included)) for _, item := range out.Items { require.Equal(t, game.GameTypePublic, item.GameType) require.Contains(t, included, item.Status) } require.Empty(t, out.NextPageToken) } func TestHandlePublicListExcludesPrivateForStranger(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) seedGameAt(t, fix.games, "game-private-1", game.GameTypePrivate, "user-owner", game.StatusEnrollmentOpen, now) out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-stranger"), }) require.NoError(t, err) require.Empty(t, out.Items) } func TestHandleUserSeesOwnPrivateGames(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) publicID := common.GameID("game-public-1") seedGameAt(t, fix.games, publicID, game.GameTypePublic, "", game.StatusEnrollmentOpen, now) privateID := common.GameID("game-private-mine") seedGameAt(t, fix.games, privateID, game.GameTypePrivate, "user-owner", game.StatusRunning, now.Add(time.Minute)) seedActiveMembership(t, fix.memberships, privateID, "user-member", now) out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-member"), }) require.NoError(t, err) require.Len(t, out.Items, 2) gotIDs := []common.GameID{out.Items[0].GameID, out.Items[1].GameID} require.Contains(t, gotIDs, publicID) require.Contains(t, gotIDs, privateID) } func TestHandleDedupesPrivateAlreadyVisible(t *testing.T) { // A public game where the user also has a membership must appear // only once in the response. t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-public-dedup") seedGameAt(t, fix.games, id, game.GameTypePublic, "", game.StatusRunning, now) seedActiveMembership(t, fix.memberships, id, "user-1", now) out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-1"), }) require.NoError(t, err) require.Len(t, out.Items, 1) } func TestHandlePrivateGameDraftHidden(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-private-draft") seedGameAt(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusDraft, now) seedActiveMembership(t, fix.memberships, id, "user-member", now) out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-member"), }) require.NoError(t, err) require.Empty(t, out.Items) } func TestHandleAdminSeesAllStatuses(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) statuses := []game.Status{ game.StatusDraft, game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusStarting, game.StatusStartFailed, game.StatusRunning, game.StatusPaused, game.StatusFinished, game.StatusCancelled, } for i, status := range statuses { id := common.GameID("game-admin-" + string(status)) seedGameAt(t, fix.games, id, game.GameTypePublic, "", status, now.Add(time.Duration(i)*time.Minute)) } out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewAdminActor(), Page: shared.Page{Size: 50}, }) require.NoError(t, err) require.Len(t, out.Items, len(statuses)) } func TestHandleSortGroupAndCreatedAt(t *testing.T) { t.Parallel() fix := newFixture(t) base := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) seedGameAt(t, fix.games, "game-running-old", game.GameTypePublic, "", game.StatusRunning, base) seedGameAt(t, fix.games, "game-running-new", game.GameTypePublic, "", game.StatusRunning, base.Add(2*time.Hour)) seedGameAt(t, fix.games, "game-finished", game.GameTypePublic, "", game.StatusFinished, base.Add(time.Hour)) seedGameAt(t, fix.games, "game-enroll", game.GameTypePublic, "", game.StatusEnrollmentOpen, base.Add(30*time.Minute)) seedGameAt(t, fix.games, "game-ready", game.GameTypePublic, "", game.StatusReadyToStart, base.Add(45*time.Minute)) out, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-1"), }) require.NoError(t, err) require.Len(t, out.Items, 5) // Group 0: ready-to-start (created later) before enroll (created earlier). require.Equal(t, common.GameID("game-ready"), out.Items[0].GameID) require.Equal(t, common.GameID("game-enroll"), out.Items[1].GameID) // Group 1: running, descending CreatedAt. require.Equal(t, common.GameID("game-running-new"), out.Items[2].GameID) require.Equal(t, common.GameID("game-running-old"), out.Items[3].GameID) // Group 2: finished. require.Equal(t, common.GameID("game-finished"), out.Items[4].GameID) } func TestHandlePagination(t *testing.T) { t.Parallel() fix := newFixture(t) base := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) for i := range 5 { id := common.GameID("game-page-" + string(rune('a'+i))) seedGameAt(t, fix.games, id, game.GameTypePublic, "", game.StatusRunning, base.Add(time.Duration(i)*time.Minute)) } first, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-1"), Page: shared.Page{Size: 2}, }) require.NoError(t, err) require.Len(t, first.Items, 2) require.NotEmpty(t, first.NextPageToken) page2, err := shared.ParsePage("2", first.NextPageToken) require.NoError(t, err) second, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-1"), Page: page2, }) require.NoError(t, err) require.Len(t, second.Items, 2) require.NotEmpty(t, second.NextPageToken) page3, err := shared.ParsePage("2", second.NextPageToken) require.NoError(t, err) third, err := fix.svc.Handle(context.Background(), listgames.Input{ Actor: shared.NewUserActor("user-1"), Page: page3, }) require.NoError(t, err) require.Len(t, third.Items, 1) require.Empty(t, third.NextPageToken) } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() cases := []struct { name string deps listgames.Dependencies }{ {"nil games", listgames.Dependencies{Memberships: membershipstub.NewStore()}}, {"nil memberships", listgames.Dependencies{Games: gamestub.NewStore()}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := listgames.NewService(tc.deps) require.Error(t, err) }) } }