package getgame_test import ( "context" "errors" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/inviteinmem" "galaxy/lobby/internal/adapters/membershipinmem" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/getgame" "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 *gameinmem.Store memberships *membershipinmem.Store invites *inviteinmem.Store svc *getgame.Service } func newFixture(t *testing.T) *fixture { t.Helper() games := gameinmem.NewStore() memberships := membershipinmem.NewStore() invites := inviteinmem.NewStore() svc, err := getgame.NewService(getgame.Dependencies{ Games: games, Memberships: memberships, Invites: invites, Logger: silentLogger(), }) require.NoError(t, err) return &fixture{ games: games, memberships: memberships, invites: invites, svc: svc, } } func seedGame( t *testing.T, store *gameinmem.Store, id common.GameID, gameType game.GameType, ownerUserID string, status game.Status, now 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: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) if status != game.StatusDraft { record.Status = status record.UpdatedAt = now.Add(time.Minute) } require.NoError(t, store.Save(context.Background(), record)) return record } func seedMembership( t *testing.T, store *membershipinmem.Store, gameID common.GameID, userID string, status membership.Status, now time.Time, ) membership.Membership { 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)) if status != membership.StatusActive { require.NoError(t, store.UpdateStatus(context.Background(), ports.UpdateMembershipStatusInput{ MembershipID: record.MembershipID, ExpectedFrom: membership.StatusActive, To: status, At: now.Add(time.Minute), })) updated, err := store.Get(context.Background(), record.MembershipID) require.NoError(t, err) return updated } return record } func seedInvite( t *testing.T, store *inviteinmem.Store, gameID common.GameID, inviterID, inviteeID string, status invite.Status, now time.Time, ) invite.Invite { t.Helper() record, err := invite.New(invite.NewInviteInput{ InviteID: common.InviteID("invite-" + inviteeID + "-" + gameID.String()), GameID: gameID, InviterUserID: inviterID, InviteeUserID: inviteeID, Now: now, ExpiresAt: now.Add(48 * time.Hour), }) require.NoError(t, err) require.NoError(t, store.Save(context.Background(), record)) if status != invite.StatusCreated { input := ports.UpdateInviteStatusInput{ InviteID: record.InviteID, ExpectedFrom: invite.StatusCreated, To: status, At: now.Add(time.Minute), } if status == invite.StatusRedeemed { input.RaceName = "Race-" + inviteeID } require.NoError(t, store.UpdateStatus(context.Background(), input)) updated, err := store.Get(context.Background(), record.InviteID) require.NoError(t, err) return updated } return record } func TestHandleAdminSeesEveryRecord(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, } cases := []struct { name string gameType game.GameType ownerUserID string }{ {"public", game.GameTypePublic, ""}, {"private", game.GameTypePrivate, "user-owner"}, } for _, tc := range cases { for _, status := range statuses { id := common.GameID("game-admin-" + tc.name + "-" + string(status)) seedGame(t, fix.games, id, tc.gameType, tc.ownerUserID, status, now) out, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewAdminActor(), GameID: id, }) require.NoError(t, err, "%s/%s", tc.name, status) require.Equal(t, id, out.GameID) } } } func TestHandlePublicNonDraftVisibleToUser(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) statuses := []game.Status{ game.StatusEnrollmentOpen, game.StatusReadyToStart, game.StatusRunning, game.StatusPaused, game.StatusFinished, game.StatusStartFailed, game.StatusStarting, game.StatusCancelled, } for _, status := range statuses { id := common.GameID("game-public-" + string(status)) seedGame(t, fix.games, id, game.GameTypePublic, "", status, now) out, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-1"), GameID: id, }) require.NoError(t, err, "status=%s", status) require.Equal(t, id, out.GameID) } } func TestHandlePublicDraftHiddenFromUser(t *testing.T) { t.Parallel() fix := newFixture(t) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-public-draft") seedGame(t, fix.games, id, game.GameTypePublic, "", game.StatusDraft, now) _, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-1"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandlePrivateDraftVisibleOnlyToOwner(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") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusDraft, now) out, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-owner"), GameID: id, }) require.NoError(t, err) require.Equal(t, id, out.GameID) _, err = fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-stranger"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandlePrivateNonDraftOwnerSees(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-owner") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusEnrollmentOpen, now) out, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-owner"), GameID: id, }) require.NoError(t, err) require.Equal(t, id, out.GameID) } func TestHandlePrivateNonDraftActiveMemberSees(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-member") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusRunning, now) seedMembership(t, fix.memberships, id, "user-member", membership.StatusActive, now) out, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-member"), GameID: id, }) require.NoError(t, err) require.Equal(t, id, out.GameID) } func TestHandlePrivateNonDraftRemovedMemberHidden(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-removed") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusRunning, now) seedMembership(t, fix.memberships, id, "user-member", membership.StatusRemoved, now) _, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-member"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandlePrivateNonDraftCreatedInviteSees(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-invitee") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusEnrollmentOpen, now) seedInvite(t, fix.invites, id, "user-owner", "user-invitee", invite.StatusCreated, now) out, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-invitee"), GameID: id, }) require.NoError(t, err) require.Equal(t, id, out.GameID) } func TestHandlePrivateNonDraftDeclinedInviteHidden(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-declined") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusEnrollmentOpen, now) seedInvite(t, fix.invites, id, "user-owner", "user-invitee", invite.StatusDeclined, now) _, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-invitee"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleStrangerForbidden(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-stranger") seedGame(t, fix.games, id, game.GameTypePrivate, "user-owner", game.StatusRunning, now) _, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-stranger"), GameID: id, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleNotFound(t *testing.T) { t.Parallel() fix := newFixture(t) _, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-1"), GameID: common.GameID("game-missing"), }) require.ErrorIs(t, err, game.ErrNotFound) } func TestHandleValidatesActor(t *testing.T) { t.Parallel() fix := newFixture(t) _, err := fix.svc.Handle(context.Background(), getgame.Input{ Actor: shared.Actor{Kind: "bogus"}, GameID: common.GameID("game-1"), }) require.Error(t, err) } func TestNewServiceRejectsMissingDeps(t *testing.T) { t.Parallel() cases := []struct { name string deps getgame.Dependencies }{ {"nil games", getgame.Dependencies{Memberships: membershipinmem.NewStore(), Invites: inviteinmem.NewStore()}}, {"nil memberships", getgame.Dependencies{Games: gameinmem.NewStore(), Invites: inviteinmem.NewStore()}}, {"nil invites", getgame.Dependencies{Games: gameinmem.NewStore(), Memberships: membershipinmem.NewStore()}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() _, err := getgame.NewService(tc.deps) require.Error(t, err) }) } } func TestHandleSurfacesStoreError(t *testing.T) { // Sanity check that errors from the membership store bubble up wrapped. t.Parallel() games := gameinmem.NewStore() memberships := &erroringMemberships{err: errors.New("stub failure")} svc, err := getgame.NewService(getgame.Dependencies{ Games: games, Memberships: memberships, Invites: inviteinmem.NewStore(), Logger: silentLogger(), }) require.NoError(t, err) now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) id := common.GameID("game-private-err") seedGame(t, games, id, game.GameTypePrivate, "user-owner", game.StatusRunning, now) _, err = svc.Handle(context.Background(), getgame.Input{ Actor: shared.NewUserActor("user-other"), GameID: id, }) require.Error(t, err) } type erroringMemberships struct { membershipinmem.Store err error } func (s *erroringMemberships) GetByUser(_ context.Context, _ string) ([]membership.Membership, error) { return nil, s.err }