package lobbyeligibility import ( "context" "testing" "time" "galaxy/user/internal/adapters/redis/userstore" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/entitlementsvc" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/require" ) func TestSnapshotReaderExecuteReturnsStableNotFound(t *testing.T) { t.Parallel() service, err := NewSnapshotReader( fakeAccountStore{existsByUserID: map[common.UserID]bool{}}, fakeEntitlementReader{}, fakeSanctionStore{}, fakeLimitStore{}, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: " user-missing "}) require.NoError(t, err) require.False(t, result.Exists) require.Equal(t, "user-missing", result.UserID) require.Nil(t, result.Entitlement) require.Empty(t, result.ActiveSanctions) require.Empty(t, result.EffectiveLimits) require.Equal(t, EligibilityMarkersView{}, result.Markers) } func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() userID := common.UserID("user-123") service, err := NewSnapshotReader( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, fakeEntitlementReader{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ userID: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)), }, }, fakeSanctionStore{ byUserID: map[common.UserID][]policy.SanctionRecord{ userID: { activeSanction(userID, policy.SanctionCodePrivateGameManageBlock, "lobby", now.Add(-time.Hour)), activeSanction(userID, policy.SanctionCodeProfileUpdateBlock, "profile", now.Add(-30*time.Minute)), expiredSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-2*time.Hour)), }, }, }, fakeLimitStore{ byUserID: map[common.UserID][]policy.LimitRecord{ userID: { activeLimit(userID, policy.LimitCodeMaxPendingPrivateInvitesSent, 17, now.Add(-time.Hour)), activeLimit(userID, policy.LimitCodeMaxActivePrivateGames, 2, now.Add(-2*time.Hour)), }, }, }, fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()}) require.NoError(t, err) require.True(t, result.Exists) require.NotNil(t, result.Entitlement) require.Equal(t, "paid_monthly", result.Entitlement.PlanCode) require.True(t, result.Entitlement.IsPaid) require.Len(t, result.ActiveSanctions, 1) require.Equal(t, "private_game_manage_block", result.ActiveSanctions[0].SanctionCode) require.Equal(t, EligibilityMarkersView{ CanLogin: true, CanCreatePrivateGame: true, CanManagePrivateGame: false, CanJoinGame: true, CanUpdateProfile: false, }, result.Markers) require.Equal(t, []EffectiveLimitView{ {LimitCode: "max_owned_private_games", Value: 3}, {LimitCode: "max_pending_public_applications", Value: 10}, {LimitCode: "max_active_game_memberships", Value: 10}, {LimitCode: "max_registered_race_names", Value: 2}, }, result.EffectiveLimits) } func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() userID := common.UserID("user-123") tests := []struct { name string snapshot entitlement.CurrentSnapshot sanctions []policy.SanctionRecord limits []policy.LimitRecord wantSanctions []string wantMarkers EligibilityMarkersView wantLimits []EffectiveLimitView }{ { name: "unpaid defaults", snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)), limits: []policy.LimitRecord{activeLimit(userID, policy.LimitCodeMaxOwnedPrivateGames, 9, now.Add(-time.Hour))}, wantSanctions: []string{}, wantMarkers: EligibilityMarkersView{ CanLogin: true, CanCreatePrivateGame: false, CanManagePrivateGame: false, CanJoinGame: true, CanUpdateProfile: true, }, wantLimits: []EffectiveLimitView{ {LimitCode: "max_pending_public_applications", Value: 3}, {LimitCode: "max_active_game_memberships", Value: 3}, {LimitCode: "max_registered_race_names", Value: 1}, }, }, { name: "login block denies all markers", snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)), sanctions: []policy.SanctionRecord{ activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)), activeSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-30*time.Minute)), }, wantSanctions: []string{"game_join_block", "login_block"}, wantMarkers: EligibilityMarkersView{ CanLogin: false, CanCreatePrivateGame: false, CanManagePrivateGame: false, CanJoinGame: false, CanUpdateProfile: false, }, wantLimits: []EffectiveLimitView{ {LimitCode: "max_owned_private_games", Value: 3}, {LimitCode: "max_pending_public_applications", Value: 10}, {LimitCode: "max_active_game_memberships", Value: 10}, {LimitCode: "max_registered_race_names", Value: 2}, }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() service, err := NewSnapshotReader( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}}, fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}}, fakeLimitStore{byUserID: map[common.UserID][]policy.LimitRecord{userID: tt.limits}}, fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()}) require.NoError(t, err) require.Equal(t, tt.wantMarkers, result.Markers) require.Equal(t, tt.wantLimits, result.EffectiveLimits) gotSanctions := make([]string, 0, len(result.ActiveSanctions)) for _, sanction := range result.ActiveSanctions { gotSanctions = append(gotSanctions, sanction.SanctionCode) } require.Equal(t, tt.wantSanctions, gotSanctions) }) } } func TestSnapshotReaderExecutePermanentBlockCollapsesMarkers(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() userID := common.UserID("user-123") tests := []struct { name string snapshot entitlement.CurrentSnapshot sanctions []policy.SanctionRecord }{ { name: "permanent_block alone on paid user", snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)), sanctions: []policy.SanctionRecord{ activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)), }, }, { name: "permanent_block alone on free user", snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)), sanctions: []policy.SanctionRecord{ activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)), }, }, { name: "permanent_block dominates login_block", snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)), sanctions: []policy.SanctionRecord{ activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)), activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-30*time.Minute)), }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() service, err := NewSnapshotReader( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}}, fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}}, fakeLimitStore{}, fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()}) require.NoError(t, err) require.True(t, result.Exists) require.Equal(t, EligibilityMarkersView{}, result.Markers, "every can_* marker must be false under permanent_block") gotSanctions := make([]string, 0, len(result.ActiveSanctions)) for _, sanction := range result.ActiveSanctions { gotSanctions = append(gotSanctions, sanction.SanctionCode) } require.Contains(t, gotSanctions, string(policy.SanctionCodePermanentBlock), "permanent_block must surface in the eligibility snapshot") }) } } func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() store := newRedisStore(t) userID := common.UserID("user-123") accountRecord := validAccountRecord() require.NoError(t, store.Accounts().Create(context.Background(), ports.CreateAccountInput{ Account: accountRecord, })) expiredEndsAt := now.Add(-time.Minute) require.NoError(t, store.EntitlementSnapshots().Put(context.Background(), entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodePaidMonthly, IsPaid: true, StartsAt: now.Add(-30 * 24 * time.Hour), EndsAt: timePointer(expiredEndsAt), Source: common.Source("billing"), Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")}, ReasonCode: common.ReasonCode("renewal"), UpdatedAt: now.Add(-2 * time.Hour), })) entitlementReader, err := entitlementsvc.NewReader( store.EntitlementSnapshots(), store.EntitlementLifecycle(), fixedClock{now: now}, fixedIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-expiry-repair")}, ) require.NoError(t, err) service, err := NewSnapshotReader( store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()}) require.NoError(t, err) require.True(t, result.Exists) require.NotNil(t, result.Entitlement) require.Equal(t, "free", result.Entitlement.PlanCode) require.False(t, result.Entitlement.IsPaid) require.Equal(t, expiredEndsAt, result.Entitlement.StartsAt) require.Equal(t, []EffectiveLimitView{ {LimitCode: "max_pending_public_applications", Value: 3}, {LimitCode: "max_active_game_memberships", Value: 3}, {LimitCode: "max_registered_race_names", Value: 1}, }, result.EffectiveLimits) storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID) require.NoError(t, err) require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode) require.False(t, storedSnapshot.IsPaid) } type fakeAccountStore struct { existsByUserID map[common.UserID]bool err error } func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error { return nil } func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) { return account.UserAccount{}, ports.ErrNotFound } func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) { return account.UserAccount{}, ports.ErrNotFound } func (store fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) { return account.UserAccount{}, ports.ErrNotFound } func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) { if store.err != nil { return false, store.err } return store.existsByUserID[userID], nil } func (store fakeAccountStore) Update(context.Context, account.UserAccount) error { return nil } type fakeEntitlementReader struct { byUserID map[common.UserID]entitlement.CurrentSnapshot err error } func (reader fakeEntitlementReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) { if reader.err != nil { return entitlement.CurrentSnapshot{}, reader.err } record, ok := reader.byUserID[userID] if !ok { return entitlement.CurrentSnapshot{}, ports.ErrNotFound } return record, nil } type fakeSanctionStore struct { byUserID map[common.UserID][]policy.SanctionRecord err error } func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error { return nil } func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) { return policy.SanctionRecord{}, ports.ErrNotFound } func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) { if store.err != nil { return nil, store.err } records := store.byUserID[userID] cloned := make([]policy.SanctionRecord, len(records)) copy(cloned, records) return cloned, nil } func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error { return nil } type fakeLimitStore struct { byUserID map[common.UserID][]policy.LimitRecord err error } func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error { return nil } func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) { return policy.LimitRecord{}, ports.ErrNotFound } func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) { if store.err != nil { return nil, store.err } records := store.byUserID[userID] cloned := make([]policy.LimitRecord, len(records)) copy(cloned, records) return cloned, nil } func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error { return nil } type fixedClock struct { now time.Time } func (clock fixedClock) Now() time.Time { return clock.now } type fixedIDGenerator struct { entitlementRecordID entitlement.EntitlementRecordID } func (generator fixedIDGenerator) NewUserID() (common.UserID, error) { return "", nil } func (generator fixedIDGenerator) NewUserName() (common.UserName, error) { return "", nil } func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) { return generator.entitlementRecordID, nil } func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) { return "", nil } func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) { return "", nil } func activeSanction( userID common.UserID, code policy.SanctionCode, scope string, appliedAt time.Time, ) policy.SanctionRecord { return policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-" + string(code)), UserID: userID, SanctionCode: code, Scope: common.Scope(scope), ReasonCode: common.ReasonCode("manual_block"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: appliedAt.UTC(), } } func expiredSanction( userID common.UserID, code policy.SanctionCode, scope string, appliedAt time.Time, ) policy.SanctionRecord { record := activeSanction(userID, code, scope, appliedAt) expiresAt := appliedAt.Add(30 * time.Minute) record.ExpiresAt = &expiresAt return record } func activeLimit( userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time, ) policy.LimitRecord { return policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-" + string(code)), UserID: userID, LimitCode: code, Value: value, ReasonCode: common.ReasonCode("manual_override"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: appliedAt.UTC(), } } func removedLimit( userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time, ) policy.LimitRecord { record := activeLimit(userID, code, value, appliedAt) removedAt := appliedAt.Add(15 * time.Minute) record.RemovedAt = &removedAt record.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")} record.RemovedReasonCode = common.ReasonCode("manual_remove") return record } func paidEntitlementSnapshot( userID common.UserID, startsAt time.Time, endsAt time.Time, ) entitlement.CurrentSnapshot { return entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodePaidMonthly, IsPaid: true, StartsAt: startsAt.UTC(), EndsAt: timePointer(endsAt), Source: common.Source("billing"), Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")}, ReasonCode: common.ReasonCode("renewal"), UpdatedAt: startsAt.UTC(), } } func freeEntitlementSnapshot(userID common.UserID, startsAt time.Time) entitlement.CurrentSnapshot { return entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodeFree, IsPaid: false, StartsAt: startsAt.UTC(), Source: common.Source("auth_registration"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, ReasonCode: common.ReasonCode("initial_free_entitlement"), UpdatedAt: startsAt.UTC(), } } func validAccountRecord() account.UserAccount { createdAt := time.Unix(1_775_240_000, 0).UTC() return account.UserAccount{ UserID: common.UserID("user-123"), Email: common.Email("pilot@example.com"), UserName: common.UserName("player-abcdefgh"), PreferredLanguage: common.LanguageTag("en"), TimeZone: common.TimeZoneName("Europe/Kaliningrad"), CreatedAt: createdAt, UpdatedAt: createdAt, } } func newRedisStore(t *testing.T) *userstore.Store { t.Helper() server := miniredis.RunT(t) store, err := userstore.New(userstore.Config{ Addr: server.Addr(), DB: 0, KeyspacePrefix: "user:test:", OperationTimeout: 250 * time.Millisecond, }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) return store } func timePointer(value time.Time) *time.Time { utcValue := value.UTC() return &utcValue } var _ ports.UserAccountStore = fakeAccountStore{} var _ ports.SanctionStore = fakeSanctionStore{} var _ ports.LimitStore = fakeLimitStore{} var _ ports.Clock = fixedClock{} var _ ports.IDGenerator = fixedIDGenerator{}