package enrollmentautomation_test import ( "context" "io" "log/slog" "testing" "time" "galaxy/lobby/internal/adapters/gamestub" "galaxy/lobby/internal/adapters/gapactivationstub" "galaxy/lobby/internal/adapters/intentpubstub" "galaxy/lobby/internal/adapters/invitestub" "galaxy/lobby/internal/adapters/membershipstub" "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" ) 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 *gamestub.Store invites *invitestub.Store memberships *membershipstub.Store gapStore *gapactivationstub.Store intents *intentpubstub.Publisher 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 := gamestub.NewStore() require.NoError(t, games.Save(context.Background(), rec)) return &fixture{ now: now, games: games, invites: invitestub.NewStore(), memberships: membershipstub.NewStore(), gapStore: gapactivationstub.NewStore(), intents: intentpubstub.NewPublisher(), 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: gamestub.NewStore(), Memberships: membershipstub.NewStore(), Invites: invitestub.NewStore(), Intents: intentpubstub.NewPublisher(), GapStore: gapactivationstub.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.intents.Published() 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.intents.Published()) } 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.intents.Published(), 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") } }