package manualreadytostart_test import ( "context" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/inviteinmem" "galaxy/lobby/internal/adapters/membershipinmem" "galaxy/lobby/internal/adapters/mocks" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/service/manualreadytostart" "galaxy/lobby/internal/service/shared" "galaxy/notificationintent" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) type intentRec struct { mu sync.Mutex published []notificationintent.Intent } func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) { r.mu.Lock() defer r.mu.Unlock() r.published = append(r.published, intent) return "1", nil } func (r *intentRec) snapshot() []notificationintent.Intent { r.mu.Lock() defer r.mu.Unlock() return append([]notificationintent.Intent(nil), r.published...) } func newIntentMock(t *testing.T, rec *intentRec) *mocks.MockIntentPublisher { t.Helper() m := mocks.NewMockIntentPublisher(gomock.NewController(t)) m.EXPECT().Publish(gomock.Any(), gomock.Any()).DoAndReturn(rec.record).AnyTimes() return m } const ( publicGameID = common.GameID("game-public") privateGameID = common.GameID("game-private") ownerUserID = "user-owner" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func fixedClock(at time.Time) func() time.Time { return func() time.Time { return at } } type fixture struct { now time.Time games *gameinmem.Store invites *inviteinmem.Store memberships *membershipinmem.Store intentRec *intentRec intents *mocks.MockIntentPublisher } func newFixture(t *testing.T) *fixture { t.Helper() now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) rec := &intentRec{} f := &fixture{ now: now, games: gameinmem.NewStore(), invites: inviteinmem.NewStore(), memberships: membershipinmem.NewStore(), intentRec: rec, } f.intents = newIntentMock(t, rec) return f } func (f *fixture) addGame(t *testing.T, gameID common.GameID, gameType game.GameType, owner string, minPlayers int) game.Game { t.Helper() rec, err := game.New(game.NewGameInput{ GameID: gameID, GameName: "Test Game", GameType: gameType, OwnerUserID: owner, MinPlayers: minPlayers, MaxPlayers: minPlayers + 2, StartGapHours: 2, StartGapPlayers: 1, EnrollmentEndsAt: f.now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: f.now, }) require.NoError(t, err) rec.Status = game.StatusEnrollmentOpen require.NoError(t, f.games.Save(context.Background(), rec)) return rec } func (f *fixture) addActiveMember(t *testing.T, membershipID common.MembershipID, gameID common.GameID, userID string) { t.Helper() mem, err := membership.New(membership.NewMembershipInput{ MembershipID: membershipID, GameID: gameID, UserID: userID, RaceName: "Race " + userID, CanonicalKey: "race-" + userID, Now: f.now, }) require.NoError(t, err) require.NoError(t, f.memberships.Save(context.Background(), mem)) } func (f *fixture) addCreatedInvite(t *testing.T, inviteID common.InviteID, gameID common.GameID, owner, invitee string) { t.Helper() rec, err := invite.New(invite.NewInviteInput{ InviteID: inviteID, GameID: gameID, InviterUserID: owner, InviteeUserID: invitee, Now: f.now, ExpiresAt: f.now.Add(24 * time.Hour), }) require.NoError(t, err) require.NoError(t, f.invites.Save(context.Background(), rec)) } func (f *fixture) newService(t *testing.T) *manualreadytostart.Service { t.Helper() svc, err := manualreadytostart.NewService(manualreadytostart.Dependencies{ Games: f.games, Memberships: f.memberships, Invites: f.invites, Intents: f.intents, Clock: fixedClock(f.now), Logger: silentLogger(), }) require.NoError(t, err) return svc } func TestHandleAdminClosesPublicEnrollment(t *testing.T) { t.Parallel() f := newFixture(t) f.addGame(t, publicGameID, game.GameTypePublic, "", 2) f.addActiveMember(t, "membership-1", publicGameID, "user-a") f.addActiveMember(t, "membership-2", publicGameID, "user-b") got, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewAdminActor(), GameID: publicGameID, }) require.NoError(t, err) assert.Equal(t, game.StatusReadyToStart, got.Status) } func TestHandleOwnerClosesPrivateEnrollmentAndExpiresInvites(t *testing.T) { t.Parallel() f := newFixture(t) f.addGame(t, privateGameID, game.GameTypePrivate, ownerUserID, 2) f.addActiveMember(t, "membership-1", privateGameID, "user-a") f.addActiveMember(t, "membership-2", privateGameID, "user-b") f.addCreatedInvite(t, "invite-1", privateGameID, ownerUserID, "user-c") f.addCreatedInvite(t, "invite-2", privateGameID, ownerUserID, "user-d") got, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: privateGameID, }) require.NoError(t, err) assert.Equal(t, game.StatusReadyToStart, got.Status) for _, id := range []common.InviteID{"invite-1", "invite-2"} { rec, err := f.invites.Get(context.Background(), id) require.NoError(t, err) assert.Equal(t, invite.StatusExpired, rec.Status) } intents := f.intentRec.snapshot() require.Len(t, intents, 2) for _, intent := range intents { assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intent.NotificationType) assert.Equal(t, []string{ownerUserID}, intent.RecipientUserIDs) } } func TestHandleNonOwnerOnPrivateForbidden(t *testing.T) { t.Parallel() f := newFixture(t) f.addGame(t, privateGameID, game.GameTypePrivate, ownerUserID, 2) f.addActiveMember(t, "membership-1", privateGameID, "user-a") f.addActiveMember(t, "membership-2", privateGameID, "user-b") _, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewUserActor("user-impostor"), GameID: privateGameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleUserOnPublicForbidden(t *testing.T) { t.Parallel() f := newFixture(t) f.addGame(t, publicGameID, game.GameTypePublic, "", 2) f.addActiveMember(t, "membership-1", publicGameID, "user-a") f.addActiveMember(t, "membership-2", publicGameID, "user-b") _, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewUserActor("user-a"), GameID: publicGameID, }) require.ErrorIs(t, err, shared.ErrForbidden) } func TestHandleGameNotFound(t *testing.T) { t.Parallel() f := newFixture(t) _, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewAdminActor(), GameID: publicGameID, }) require.ErrorIs(t, err, game.ErrNotFound) } func TestHandleStatusNotEnrollmentOpenConflict(t *testing.T) { t.Parallel() f := newFixture(t) rec := f.addGame(t, publicGameID, game.GameTypePublic, "", 2) rec.Status = game.StatusDraft require.NoError(t, f.games.Save(context.Background(), rec)) _, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewAdminActor(), GameID: publicGameID, }) require.ErrorIs(t, err, game.ErrConflict) } func TestHandleBelowMinPlayersConflict(t *testing.T) { t.Parallel() f := newFixture(t) f.addGame(t, publicGameID, game.GameTypePublic, "", 3) f.addActiveMember(t, "membership-1", publicGameID, "user-a") f.addActiveMember(t, "membership-2", publicGameID, "user-b") _, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewAdminActor(), GameID: publicGameID, }) require.ErrorIs(t, err, game.ErrConflict) current, err := f.games.Get(context.Background(), publicGameID) require.NoError(t, err) assert.Equal(t, game.StatusEnrollmentOpen, current.Status) assert.Empty(t, f.intentRec.snapshot()) } func TestHandleEmptyInvitesProducesNoNotifications(t *testing.T) { t.Parallel() f := newFixture(t) f.addGame(t, privateGameID, game.GameTypePrivate, ownerUserID, 2) f.addActiveMember(t, "membership-1", privateGameID, "user-a") f.addActiveMember(t, "membership-2", privateGameID, "user-b") _, err := f.newService(t).Handle(context.Background(), manualreadytostart.Input{ Actor: shared.NewUserActor(ownerUserID), GameID: privateGameID, }) require.NoError(t, err) assert.Empty(t, f.intentRec.snapshot()) }