package enrollmentautomation_test import ( "context" "io" "log/slog" "sync" "testing" "time" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/gapactivationinmem" "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/worker/enrollmentautomation" "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 ( gameID = 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 gapStore *gapactivationinmem.Store intentRec *intentRec intents *mocks.MockIntentPublisher game game.Game } type fixtureOptions struct { minPlayers int maxPlayers int startGapHours int startGapPlayers int enrollmentEndsAt time.Time } func newFixture(t *testing.T, opts fixtureOptions) *fixture { t.Helper() now := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) if opts.minPlayers == 0 { opts.minPlayers = 2 } if opts.maxPlayers == 0 { opts.maxPlayers = 4 } if opts.startGapHours == 0 { opts.startGapHours = 2 } if opts.startGapPlayers == 0 { opts.startGapPlayers = 1 } if opts.enrollmentEndsAt.IsZero() { opts.enrollmentEndsAt = now.Add(24 * time.Hour) } rec, err := game.New(game.NewGameInput{ GameID: gameID, GameName: "Friends Only", GameType: game.GameTypePrivate, OwnerUserID: ownerUserID, MinPlayers: opts.minPlayers, MaxPlayers: opts.maxPlayers, StartGapHours: opts.startGapHours, StartGapPlayers: opts.startGapPlayers, EnrollmentEndsAt: opts.enrollmentEndsAt, TurnSchedule: "0 */6 * * *", TargetEngineVersion: "1.0.0", Now: now, }) require.NoError(t, err) rec.Status = game.StatusEnrollmentOpen games := gameinmem.NewStore() require.NoError(t, games.Save(context.Background(), rec)) intentRecord := &intentRec{} return &fixture{ now: now, games: games, invites: inviteinmem.NewStore(), memberships: membershipinmem.NewStore(), gapStore: gapactivationinmem.NewStore(), intentRec: intentRecord, intents: newIntentMock(t, intentRecord), game: rec, } } func (f *fixture) addActiveMember(t *testing.T, membershipID common.MembershipID, 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, invitee string) { t.Helper() rec, err := invite.New(invite.NewInviteInput{ InviteID: inviteID, GameID: gameID, InviterUserID: ownerUserID, InviteeUserID: invitee, Now: f.now, ExpiresAt: f.game.EnrollmentEndsAt, }) require.NoError(t, err) require.NoError(t, f.invites.Save(context.Background(), rec)) } func (f *fixture) markGapActivatedAt(t *testing.T, at time.Time) { t.Helper() require.NoError(t, f.gapStore.MarkActivated(context.Background(), gameID, at)) } func (f *fixture) newWorker(t *testing.T, tickAt time.Time) *enrollmentautomation.Worker { t.Helper() worker, err := enrollmentautomation.NewWorker(enrollmentautomation.Dependencies{ Games: f.games, Memberships: f.memberships, Invites: f.invites, Intents: f.intents, GapStore: f.gapStore, Interval: time.Minute, Clock: fixedClock(tickAt), Logger: silentLogger(), }) require.NoError(t, err) return worker } func currentStatus(t *testing.T, f *fixture) game.Status { t.Helper() rec, err := f.games.Get(context.Background(), gameID) require.NoError(t, err) return rec.Status } func TestNewWorkerRejectsZeroInterval(t *testing.T) { t.Parallel() _, err := enrollmentautomation.NewWorker(enrollmentautomation.Dependencies{ Games: gameinmem.NewStore(), Memberships: membershipinmem.NewStore(), Invites: inviteinmem.NewStore(), Intents: newIntentMock(t, &intentRec{}), GapStore: gapactivationinmem.NewStore(), Interval: 0, }) require.Error(t, err) } func TestTickDeadlineTriggers(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 2}) f.addActiveMember(t, "membership-1", "user-a") f.addActiveMember(t, "membership-2", "user-b") f.addCreatedInvite(t, "invite-1", "user-c") tickAt := f.game.EnrollmentEndsAt.Add(time.Minute) f.newWorker(t, tickAt).Tick(context.Background()) assert.Equal(t, game.StatusReadyToStart, currentStatus(t, f)) expired, err := f.invites.Get(context.Background(), "invite-1") require.NoError(t, err) assert.Equal(t, invite.StatusExpired, expired.Status) intents := f.intentRec.snapshot() require.Len(t, intents, 1) assert.Equal(t, notificationintent.NotificationTypeLobbyInviteExpired, intents[0].NotificationType) } func TestTickDeadlineSkipsBelowMinPlayers(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 3}) f.addActiveMember(t, "membership-1", "user-a") f.addActiveMember(t, "membership-2", "user-b") tickAt := f.game.EnrollmentEndsAt.Add(time.Minute) f.newWorker(t, tickAt).Tick(context.Background()) assert.Equal(t, game.StatusEnrollmentOpen, currentStatus(t, f)) assert.Empty(t, f.intentRec.snapshot()) } func TestTickGapTimeTriggers(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 2, maxPlayers: 4, startGapHours: 2, startGapPlayers: 2}) f.addActiveMember(t, "membership-1", "user-a") f.addActiveMember(t, "membership-2", "user-b") f.addActiveMember(t, "membership-3", "user-c") f.addActiveMember(t, "membership-4", "user-d") f.markGapActivatedAt(t, f.now) tickAt := f.now.Add(2 * time.Hour).Add(time.Minute) f.newWorker(t, tickAt).Tick(context.Background()) assert.Equal(t, game.StatusReadyToStart, currentStatus(t, f)) } func TestTickGapPlayersTriggersBeforeTime(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 2, maxPlayers: 4, startGapHours: 24, startGapPlayers: 1}) f.addActiveMember(t, "membership-1", "user-a") f.addActiveMember(t, "membership-2", "user-b") f.addActiveMember(t, "membership-3", "user-c") f.addActiveMember(t, "membership-4", "user-d") f.addActiveMember(t, "membership-5", "user-e") f.markGapActivatedAt(t, f.now) tickAt := f.now.Add(15 * time.Minute) f.newWorker(t, tickAt).Tick(context.Background()) assert.Equal(t, game.StatusReadyToStart, currentStatus(t, f)) } func TestTickGapInactiveSkipsBeforeDeadline(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 2, maxPlayers: 4, startGapHours: 1}) f.addActiveMember(t, "membership-1", "user-a") f.addActiveMember(t, "membership-2", "user-b") tickAt := f.now.Add(2 * time.Hour) f.newWorker(t, tickAt).Tick(context.Background()) assert.Equal(t, game.StatusEnrollmentOpen, currentStatus(t, f)) } func TestTickIsIdempotent(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 2}) f.addActiveMember(t, "membership-1", "user-a") f.addActiveMember(t, "membership-2", "user-b") f.addCreatedInvite(t, "invite-1", "user-c") tickAt := f.game.EnrollmentEndsAt.Add(time.Minute) worker := f.newWorker(t, tickAt) worker.Tick(context.Background()) worker.Tick(context.Background()) assert.Equal(t, game.StatusReadyToStart, currentStatus(t, f)) assert.Len(t, f.intentRec.snapshot(), 1) } func TestRunStopsOnContextCancel(t *testing.T) { t.Parallel() f := newFixture(t, fixtureOptions{minPlayers: 2}) worker := f.newWorker(t, f.now) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() cancel() select { case err := <-done: require.ErrorIs(t, err, context.Canceled) case <-time.After(time.Second): t.Fatal("worker did not stop after context cancel") } }