package userlifecycle_test import ( "context" "errors" "io" "log/slog" "strings" "sync" "testing" "time" "galaxy/lobby/internal/adapters/applicationinmem" "galaxy/lobby/internal/adapters/gameinmem" "galaxy/lobby/internal/adapters/inviteinmem" "galaxy/lobby/internal/adapters/membershipinmem" "galaxy/lobby/internal/adapters/mocks" "galaxy/lobby/internal/adapters/racenameinmem" "galaxy/lobby/internal/domain/application" "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/worker/userlifecycle" "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...) } type runtimeRec struct { mu sync.Mutex stopIDs []string stopReas []ports.StopReason stopErr error } func (r *runtimeRec) recordStart(_ context.Context, _, _ string) error { return nil } func (r *runtimeRec) recordStop(_ context.Context, gameID string, reason ports.StopReason) error { r.mu.Lock() defer r.mu.Unlock() if r.stopErr != nil { return r.stopErr } r.stopIDs = append(r.stopIDs, gameID) r.stopReas = append(r.stopReas, reason) return nil } func (r *runtimeRec) stopJobs() []string { r.mu.Lock() defer r.mu.Unlock() return append([]string(nil), r.stopIDs...) } func (r *runtimeRec) stopReasons() []ports.StopReason { r.mu.Lock() defer r.mu.Unlock() return append([]ports.StopReason(nil), r.stopReas...) } func (r *runtimeRec) setStopErr(err error) { r.mu.Lock() defer r.mu.Unlock() r.stopErr = 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 } func newRuntimeMock(t *testing.T, rec *runtimeRec) *mocks.MockRuntimeManager { t.Helper() m := mocks.NewMockRuntimeManager(gomock.NewController(t)) m.EXPECT().PublishStartJob(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(rec.recordStart).AnyTimes() m.EXPECT().PublishStopJob(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(rec.recordStop).AnyTimes() return m } func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } type fixture struct { directory *racenameinmem.Directory memberships *membershipinmem.Store applications *applicationinmem.Store invites *inviteinmem.Store games *gameinmem.Store runtimeRec *runtimeRec runtimeManager *mocks.MockRuntimeManager intentRec *intentRec intents *mocks.MockIntentPublisher worker *userlifecycle.Worker now time.Time } func newFixture(t *testing.T) *fixture { t.Helper() directory, err := racenameinmem.NewDirectory() require.NoError(t, err) now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) rtRec := &runtimeRec{} intRec := &intentRec{} f := &fixture{ directory: directory, memberships: membershipinmem.NewStore(), applications: applicationinmem.NewStore(), invites: inviteinmem.NewStore(), games: gameinmem.NewStore(), runtimeRec: rtRec, runtimeManager: newRuntimeMock(t, rtRec), intentRec: intRec, intents: newIntentMock(t, intRec), now: now, } worker, err := userlifecycle.NewWorker(userlifecycle.Dependencies{ Directory: directory, Memberships: f.memberships, Applications: f.applications, Invites: f.invites, Games: f.games, RuntimeManager: f.runtimeManager, Intents: f.intents, Clock: func() time.Time { return now }, Logger: silentLogger(), }) require.NoError(t, err) f.worker = worker return f } func (f *fixture) seedGame( t *testing.T, id common.GameID, gameType game.GameType, ownerUserID string, status game.Status, ) game.Game { t.Helper() createdAt := f.now.Add(-2 * time.Hour) record, err := game.New(game.NewGameInput{ GameID: id, GameName: "cascade " + id.String(), GameType: gameType, OwnerUserID: ownerUserID, MinPlayers: 2, MaxPlayers: 4, StartGapHours: 1, StartGapPlayers: 1, EnrollmentEndsAt: createdAt.Add(24 * time.Hour), TurnSchedule: "0 18 * * *", TargetEngineVersion: "v1.0.0", Now: createdAt, }) require.NoError(t, err) if status != game.StatusDraft { record.Status = status record.UpdatedAt = f.now switch status { case game.StatusRunning, game.StatusPaused: startedAt := f.now.Add(-time.Hour) record.StartedAt = &startedAt } } require.NoError(t, f.games.Save(context.Background(), record)) return record } func (f *fixture) seedMembership( t *testing.T, gameID common.GameID, id common.MembershipID, userID, raceName string, ) membership.Membership { t.Helper() record, err := membership.New(membership.NewMembershipInput{ MembershipID: id, GameID: gameID, UserID: userID, RaceName: raceName, CanonicalKey: strings.ToLower(strings.ReplaceAll(raceName, " ", "")), Now: f.now, }) require.NoError(t, err) require.NoError(t, f.memberships.Save(context.Background(), record)) require.NoError(t, f.directory.Reserve(context.Background(), gameID.String(), userID, raceName)) return record } func (f *fixture) seedApplication( t *testing.T, gameID common.GameID, id common.ApplicationID, userID, raceName string, ) application.Application { t.Helper() record, err := application.New(application.NewApplicationInput{ ApplicationID: id, GameID: gameID, ApplicantUserID: userID, RaceName: raceName, Now: f.now, }) require.NoError(t, err) require.NoError(t, f.applications.Save(context.Background(), record)) return record } func (f *fixture) seedInvite( t *testing.T, gameID common.GameID, id common.InviteID, inviterUserID, inviteeUserID string, ) invite.Invite { t.Helper() record, err := invite.New(invite.NewInviteInput{ InviteID: id, GameID: gameID, InviterUserID: inviterUserID, InviteeUserID: inviteeUserID, Now: f.now, ExpiresAt: f.now.Add(48 * time.Hour), }) require.NoError(t, err) require.NoError(t, f.invites.Save(context.Background(), record)) return record } func (f *fixture) reserveRegistered(t *testing.T, gameID, userID, raceName string, registered bool) { t.Helper() require.NoError(t, f.directory.Reserve(context.Background(), gameID, userID, raceName)) if registered { require.NoError(t, f.directory.MarkPendingRegistration( context.Background(), gameID, userID, raceName, f.now.Add(30*24*time.Hour))) require.NoError(t, f.directory.Register(context.Background(), gameID, userID, raceName)) } } func TestNewWorkerRejectsMissingDeps(t *testing.T) { t.Parallel() _, err := userlifecycle.NewWorker(userlifecycle.Dependencies{}) require.Error(t, err) } func TestHandleFullCascadePermanentBlock(t *testing.T) { t.Parallel() f := newFixture(t) // Owned private game in running status (must publish stop job). ownedRunning := f.seedGame(t, "game-owned-1", game.GameTypePrivate, "user-victim", game.StatusRunning) // Owned private game in enrollment_open (no stop job needed). ownedDraft := f.seedGame(t, "game-owned-2", game.GameTypePrivate, "user-victim", game.StatusEnrollmentOpen) // Third party private game where the victim has an active membership. thirdPartyGame := f.seedGame(t, "game-third-1", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen) member := f.seedMembership(t, thirdPartyGame.GameID, "membership-1", "user-victim", "PrismHawk") // Public game where the victim has an active membership. publicGame := f.seedGame(t, "game-pub-1", game.GameTypePublic, "", game.StatusRunning) publicMember := f.seedMembership(t, publicGame.GameID, "membership-2", "user-victim", "Nebula") // Pending application by the victim. app := f.seedApplication(t, "game-pub-1", "application-1", "user-victim", "Nebula") // Pending invite addressed to the victim. inv1 := f.seedInvite(t, "game-third-1", "invite-1", "owner-other", "user-victim") // Pending invite where the victim is the inviter. inv2 := f.seedInvite(t, "game-owned-2", "invite-2", "user-victim", "guest-1") // Race name registered by the victim (RND should release it). f.reserveRegistered(t, "game-third-1", "user-victim", "PrismHawk", true) require.NoError(t, f.worker.Handle(context.Background(), ports.UserLifecycleEvent{ EntryID: "1700000000000-0", EventType: ports.UserLifecycleEventTypePermanentBlocked, UserID: "user-victim", OccurredAt: f.now, Source: "admin_internal_api", ActorType: "admin_user", ActorID: "admin-1", ReasonCode: "policy_violation", })) // RND is fully cleared for the user. registered, err := f.directory.ListRegistered(context.Background(), "user-victim") require.NoError(t, err) assert.Empty(t, registered) pending, err := f.directory.ListPendingRegistrations(context.Background(), "user-victim") require.NoError(t, err) assert.Empty(t, pending) reservations, err := f.directory.ListReservations(context.Background(), "user-victim") require.NoError(t, err) assert.Empty(t, reservations) // Both memberships are blocked. got, err := f.memberships.Get(context.Background(), member.MembershipID) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, got.Status) gotPub, err := f.memberships.Get(context.Background(), publicMember.MembershipID) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, gotPub.Status) // Application rejected. gotApp, err := f.applications.Get(context.Background(), app.ApplicationID) require.NoError(t, err) assert.Equal(t, application.StatusRejected, gotApp.Status) // Both invites revoked. gotInv1, err := f.invites.Get(context.Background(), inv1.InviteID) require.NoError(t, err) assert.Equal(t, invite.StatusRevoked, gotInv1.Status) gotInv2, err := f.invites.Get(context.Background(), inv2.InviteID) require.NoError(t, err) assert.Equal(t, invite.StatusRevoked, gotInv2.Status) // Owned games cancelled, stop job published only for in-flight ones. gotOwned1, err := f.games.Get(context.Background(), ownedRunning.GameID) require.NoError(t, err) assert.Equal(t, game.StatusCancelled, gotOwned1.Status) gotOwned2, err := f.games.Get(context.Background(), ownedDraft.GameID) require.NoError(t, err) assert.Equal(t, game.StatusCancelled, gotOwned2.Status) stopJobs := f.runtimeRec.stopJobs() require.Len(t, stopJobs, 1) assert.Equal(t, ownedRunning.GameID.String(), stopJobs[0]) stopReasons := f.runtimeRec.stopReasons() require.Len(t, stopReasons, 1) assert.Equal(t, ports.StopReasonCancelled, stopReasons[0], "user-lifecycle cascade must classify the stop job as cancelled") // Notification published only for the third-party private game owner. intents := f.intentRec.snapshot() require.Len(t, intents, 1) assert.Equal(t, notificationintent.NotificationTypeLobbyMembershipBlocked, intents[0].NotificationType) assert.Equal(t, []string{"owner-other"}, intents[0].RecipientUserIDs) assert.Contains(t, intents[0].PayloadJSON, `"reason":"permanent_blocked"`) assert.Contains(t, intents[0].PayloadJSON, `"membership_user_id":"user-victim"`) } func TestHandleIsIdempotentOnReplay(t *testing.T) { t.Parallel() f := newFixture(t) thirdParty := f.seedGame(t, "game-third-2", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen) f.seedMembership(t, thirdParty.GameID, "membership-3", "user-victim", "PrismHawk") event := ports.UserLifecycleEvent{ EntryID: "1700000000000-0", EventType: ports.UserLifecycleEventTypeDeleted, UserID: "user-victim", OccurredAt: f.now, Source: "admin_internal_api", ActorType: "system", ReasonCode: "user_request", } require.NoError(t, f.worker.Handle(context.Background(), event)) require.NoError(t, f.worker.Handle(context.Background(), event)) intents := f.intentRec.snapshot() require.Len(t, intents, 1, "second pass must not double-publish") assert.Contains(t, intents[0].PayloadJSON, `"reason":"deleted"`) } func TestHandleRetryAfterMembershipBackendError(t *testing.T) { t.Parallel() f := newFixture(t) thirdParty := f.seedGame(t, "game-third-3", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen) member := f.seedMembership(t, thirdParty.GameID, "membership-4", "user-victim", "Stardust") failingMemberships := &flakyMembershipStore{ Store: f.memberships, failOnce: true, failError: errors.New("redis flake"), } worker, err := userlifecycle.NewWorker(userlifecycle.Dependencies{ Directory: f.directory, Memberships: failingMemberships, Applications: f.applications, Invites: f.invites, Games: f.games, RuntimeManager: f.runtimeManager, Intents: f.intents, Clock: func() time.Time { return f.now }, Logger: silentLogger(), }) require.NoError(t, err) event := ports.UserLifecycleEvent{ EntryID: "1700000000000-0", EventType: ports.UserLifecycleEventTypePermanentBlocked, UserID: "user-victim", OccurredAt: f.now, Source: "admin_internal_api", ActorType: "admin_user", ReasonCode: "abuse", } err = worker.Handle(context.Background(), event) require.Error(t, err) // The failing call already consumed its single failure budget. require.NoError(t, worker.Handle(context.Background(), event)) // Confirm membership is now blocked. got, err := f.memberships.Get(context.Background(), member.MembershipID) require.NoError(t, err) assert.Equal(t, membership.StatusBlocked, got.Status) } func TestHandleUnknownEventTypeIsNoop(t *testing.T) { t.Parallel() f := newFixture(t) thirdParty := f.seedGame(t, "game-third-4", game.GameTypePrivate, "owner-other", game.StatusEnrollmentOpen) member := f.seedMembership(t, thirdParty.GameID, "membership-5", "user-victim", "Comet") require.NoError(t, f.worker.Handle(context.Background(), ports.UserLifecycleEvent{ EntryID: "1700000000000-0", EventType: ports.UserLifecycleEventType("user.lifecycle.unknown"), UserID: "user-victim", OccurredAt: f.now, })) got, err := f.memberships.Get(context.Background(), member.MembershipID) require.NoError(t, err) assert.Equal(t, membership.StatusActive, got.Status) assert.Empty(t, f.intentRec.snapshot()) } func TestHandlePropagatesStopJobError(t *testing.T) { t.Parallel() f := newFixture(t) f.seedGame(t, "game-owned-3", game.GameTypePrivate, "user-victim", game.StatusRunning) f.runtimeRec.setStopErr(errors.New("runtime down")) err := f.worker.Handle(context.Background(), ports.UserLifecycleEvent{ EntryID: "1700000000000-0", EventType: ports.UserLifecycleEventTypePermanentBlocked, UserID: "user-victim", OccurredAt: f.now, ActorType: "admin_user", ReasonCode: "abuse", }) require.Error(t, err) } // flakyMembershipStore wraps membershipinmem.Store with a one-shot // UpdateStatus failure injection used by the retry-after-error test. type flakyMembershipStore struct { *membershipinmem.Store failOnce bool failError error } func (f *flakyMembershipStore) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error { if f.failOnce { f.failOnce = false return f.failError } return f.Store.UpdateStatus(ctx, input) }