package shared_test import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/inviteinmem" "galaxy/lobby/internal/adapters/mocks" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/invite" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/notificationintent" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) const ( closeOwnerUserID = "user-owner" closeGameID = common.GameID("game-private") closeGameName = "Friends Only" ) func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type intentRec struct { mu sync.Mutex published []notificationintent.Intent err error } func (r *intentRec) record(_ context.Context, intent notificationintent.Intent) (string, error) { r.mu.Lock() defer r.mu.Unlock() if r.err != nil { return "", r.err } 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 (r *intentRec) setErr(err error) { r.mu.Lock() defer r.mu.Unlock() r.err = err } 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 } type closeFixture struct { now time.Time games *gameinmem.Store invites *inviteinmem.Store intentRec *intentRec intents *mocks.MockIntentPublisher game game.Game } func newCloseFixture(t *testing.T) *closeFixture { t.Helper() now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) games := gameinmem.NewStore() invites := inviteinmem.NewStore() rec := &intentRec{} intents := newIntentMock(t, rec) gameRecord, err := game.New(game.NewGameInput{ GameID: closeGameID, GameName: closeGameName, GameType: game.GameTypePrivate, OwnerUserID: closeOwnerUserID, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 2, StartGapPlayers: 1, EnrollmentEndsAt: now.Add(24 * time.Hour), TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) gameRecord.Status = game.StatusEnrollmentOpen require.NoError(t, games.Save(context.Background(), gameRecord)) return &closeFixture{ now: now, games: games, invites: invites, intentRec: rec, intents: intents, game: gameRecord, } } func (f *closeFixture) addCreatedInvite(t *testing.T, inviteID common.InviteID, inviteeUserID string) invite.Invite { t.Helper() rec, err := invite.New(invite.NewInviteInput{ InviteID: inviteID, GameID: f.game.GameID, InviterUserID: closeOwnerUserID, InviteeUserID: inviteeUserID, Now: f.now, ExpiresAt: f.game.EnrollmentEndsAt, }) require.NoError(t, err) require.NoError(t, f.invites.Save(context.Background(), rec)) return rec } func (f *closeFixture) deps() shared.CloseEnrollmentDeps { return shared.CloseEnrollmentDeps{ Games: f.games, Invites: f.invites, Intents: f.intents, Logger: silentLogger(), } } func TestCloseEnrollmentTransitionsGameAndExpiresInvites(t *testing.T) { t.Parallel() f := newCloseFixture(t) f.addCreatedInvite(t, "invite-1", "user-a") f.addCreatedInvite(t, "invite-2", "user-b") updated, err := shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.TriggerManual, f.now.Add(time.Minute), ) require.NoError(t, err) assert.Equal(t, game.StatusReadyToStart, updated.Status) first, err := f.invites.Get(context.Background(), "invite-1") require.NoError(t, err) assert.Equal(t, invite.StatusExpired, first.Status) second, err := f.invites.Get(context.Background(), "invite-2") require.NoError(t, err) assert.Equal(t, invite.StatusExpired, second.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{closeOwnerUserID}, intent.RecipientUserIDs) } } func TestCloseEnrollmentLeavesNonCreatedInvitesUntouched(t *testing.T) { t.Parallel() f := newCloseFixture(t) created := f.addCreatedInvite(t, "invite-1", "user-a") declined := f.addCreatedInvite(t, "invite-2", "user-b") require.NoError(t, f.invites.UpdateStatus(context.Background(), ports.UpdateInviteStatusInput{ InviteID: declined.InviteID, ExpectedFrom: invite.StatusCreated, To: invite.StatusDeclined, At: f.now, })) _, err := shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.TriggerManual, f.now.Add(time.Minute), ) require.NoError(t, err) createdAfter, err := f.invites.Get(context.Background(), created.InviteID) require.NoError(t, err) assert.Equal(t, invite.StatusExpired, createdAfter.Status) declinedAfter, err := f.invites.Get(context.Background(), declined.InviteID) require.NoError(t, err) assert.Equal(t, invite.StatusDeclined, declinedAfter.Status) intents := f.intentRec.snapshot() require.Len(t, intents, 1) } func TestCloseEnrollmentSurfacesGameConflict(t *testing.T) { t.Parallel() f := newCloseFixture(t) f.addCreatedInvite(t, "invite-1", "user-a") rec, err := f.games.Get(context.Background(), f.game.GameID) require.NoError(t, err) rec.Status = game.StatusDraft require.NoError(t, f.games.Save(context.Background(), rec)) _, err = shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.TriggerManual, f.now.Add(time.Minute), ) require.ErrorIs(t, err, game.ErrConflict) stillCreated, err := f.invites.Get(context.Background(), "invite-1") require.NoError(t, err) assert.Equal(t, invite.StatusCreated, stillCreated.Status) assert.Empty(t, f.intentRec.snapshot()) } func TestCloseEnrollmentSwallowsIntentPublishFailure(t *testing.T) { t.Parallel() f := newCloseFixture(t) f.addCreatedInvite(t, "invite-1", "user-a") f.intentRec.setErr(errors.New("publisher offline")) updated, err := shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.TriggerManual, f.now.Add(time.Minute), ) require.NoError(t, err) assert.Equal(t, game.StatusReadyToStart, updated.Status) expired, err := f.invites.Get(context.Background(), "invite-1") require.NoError(t, err) assert.Equal(t, invite.StatusExpired, expired.Status) } func TestCloseEnrollmentIsIdempotentOnSecondCall(t *testing.T) { t.Parallel() f := newCloseFixture(t) f.addCreatedInvite(t, "invite-1", "user-a") _, err := shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.TriggerManual, f.now.Add(time.Minute), ) require.NoError(t, err) assert.Len(t, f.intentRec.snapshot(), 1) _, err = shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.TriggerManual, f.now.Add(2*time.Minute), ) require.ErrorIs(t, err, game.ErrConflict) assert.Len(t, f.intentRec.snapshot(), 1) } func TestCloseEnrollmentRejectsUnknownTrigger(t *testing.T) { t.Parallel() f := newCloseFixture(t) _, err := shared.CloseEnrollment( context.Background(), f.deps(), f.game.GameID, game.Trigger("bogus"), f.now, ) require.Error(t, err) }