package selfservice import ( "context" "testing" "time" "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" "galaxy/user/internal/service/shared" "github.com/stretchr/testify/require" ) func TestAccountGetterExecuteReturnsAggregate(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() accountStore := newFakeAccountStore(validUserAccount()) snapshotStore := &fakeEntitlementSnapshotStore{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now), }, } sanctionStore := fakeSanctionStore{ byUserID: map[common.UserID][]policy.SanctionRecord{ common.UserID("user-123"): { validActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)), expiredSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)), }, }, } limitStore := fakeLimitStore{ byUserID: map[common.UserID][]policy.LimitRecord{ common.UserID("user-123"): { validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)), validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxActivePrivateGames, 1, now.Add(-2*time.Hour)), }, }, } service, err := NewAccountGetter(accountStore, snapshotStore, sanctionStore, limitStore, fixedClock{now: now}) require.NoError(t, err) result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: " user-123 "}) require.NoError(t, err) require.Equal(t, "user-123", result.Account.UserID) require.Equal(t, "DE", result.Account.DeclaredCountry) require.Len(t, result.Account.ActiveSanctions, 1) require.Equal(t, string(policy.SanctionCodeLoginBlock), result.Account.ActiveSanctions[0].SanctionCode) require.Len(t, result.Account.ActiveLimits, 1) require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.Account.ActiveLimits[0].LimitCode) } func TestAccountGetterExecuteUnknownUserReturnsNotFound(t *testing.T) { t.Parallel() service, err := NewAccountGetter( newFakeAccountStore(), &fakeEntitlementSnapshotStore{}, fakeSanctionStore{}, fakeLimitStore{}, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-missing"}) require.Error(t, err) require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err)) } func TestAccountGetterExecuteMissingSnapshotReturnsInternalError(t *testing.T) { t.Parallel() service, err := NewAccountGetter( newFakeAccountStore(validUserAccount()), &fakeEntitlementSnapshotStore{}, fakeSanctionStore{}, fakeLimitStore{}, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"}) require.Error(t, err) require.Equal(t, shared.ErrorCodeInternalError, shared.CodeOf(err)) } func TestAccountGetterExecuteRepairsExpiredPaidSnapshot(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() expiredAt := now.Add(-time.Hour) snapshotStore := &fakeEntitlementSnapshotStore{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ common.UserID("user-123"): { UserID: common.UserID("user-123"), PlanCode: entitlement.PlanCodePaidMonthly, IsPaid: true, StartsAt: now.Add(-30 * 24 * time.Hour), EndsAt: timePointer(expiredAt), Source: common.Source("admin"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, ReasonCode: common.ReasonCode("manual_grant"), UpdatedAt: expiredAt, }, }, } reader, err := entitlementsvc.NewReader( snapshotStore, &fakeEntitlementLifecycleStore{snapshotStore: snapshotStore}, fixedClock{now: now}, readerIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")}, ) require.NoError(t, err) service, err := NewAccountGetter( newFakeAccountStore(validUserAccount()), reader, fakeSanctionStore{}, fakeLimitStore{}, fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"}) require.NoError(t, err) require.Equal(t, "free", result.Account.Entitlement.PlanCode) require.False(t, result.Account.Entitlement.IsPaid) require.Equal(t, expiredAt, result.Account.Entitlement.StartsAt) } func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() accountStore := newFakeAccountStore(validUserAccount()) service, err := NewProfileUpdater( accountStore, &fakeEntitlementSnapshotStore{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now), }, }, fakeSanctionStore{ byUserID: map[common.UserID][]policy.SanctionRecord{ common.UserID("user-123"): { validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)), }, }, }, fakeLimitStore{}, fixedClock{now: now}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), UpdateMyProfileInput{ UserID: "user-123", DisplayName: "NovaPrime", }) require.Error(t, err) require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err)) require.Equal(t, 0, accountStore.updateCalls) } func TestProfileUpdaterExecuteDisplayNameUpdates(t *testing.T) { t.Parallel() tests := []struct { name string inputDisplay string updateErr error wantCode string wantDisplay string wantUpdateCalls int }{ { name: "set display name", inputDisplay: "NovaPrime", wantDisplay: "NovaPrime", wantUpdateCalls: 1, }, { name: "trims input", inputDisplay: " NovaPrime ", wantDisplay: "NovaPrime", wantUpdateCalls: 1, }, { name: "reset to empty", inputDisplay: " ", wantDisplay: "", wantUpdateCalls: 0, }, { name: "invalid display name rejected", inputDisplay: "Nova Prime", wantCode: shared.ErrorCodeInvalidRequest, wantDisplay: "", wantUpdateCalls: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() accountStore := newFakeAccountStore(validUserAccount()) accountStore.updateErr = tt.updateErr service, err := NewProfileUpdater( accountStore, &fakeEntitlementSnapshotStore{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now), }, }, fakeSanctionStore{}, fakeLimitStore{}, fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), UpdateMyProfileInput{ UserID: "user-123", DisplayName: tt.inputDisplay, }) if tt.wantCode != "" { require.Error(t, err) require.Equal(t, tt.wantCode, shared.CodeOf(err)) } else { require.NoError(t, err) } require.Equal(t, tt.wantUpdateCalls, accountStore.updateCalls) storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123")) require.NoError(t, err) require.Equal(t, tt.wantDisplay, storedAccount.DisplayName.String()) if tt.wantCode == "" { require.Equal(t, tt.wantDisplay, result.Account.DisplayName) } }) } } func TestSettingsUpdaterExecuteBlockedBySanction(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() accountStore := newFakeAccountStore(validUserAccount()) service, err := NewSettingsUpdater( accountStore, &fakeEntitlementSnapshotStore{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now), }, }, fakeSanctionStore{ byUserID: map[common.UserID][]policy.SanctionRecord{ common.UserID("user-123"): { validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)), }, }, }, fakeLimitStore{}, fixedClock{now: now}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), UpdateMySettingsInput{ UserID: "user-123", PreferredLanguage: "en-US", TimeZone: "UTC", }) require.Error(t, err) require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err)) require.Equal(t, 0, accountStore.updateCalls) } func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) { t.Parallel() tests := []struct { name string accountRecord account.UserAccount inputLanguage string inputTimeZone string wantCode string wantLanguage string wantTimeZone string wantUpdateCalls int }{ { name: "canonicalized success", accountRecord: validUserAccount(), inputLanguage: " en-us ", inputTimeZone: " UTC ", wantLanguage: "en-US", wantTimeZone: "UTC", wantUpdateCalls: 1, }, { name: "no-op", accountRecord: account.UserAccount{ UserID: common.UserID("user-123"), Email: common.Email("pilot@example.com"), UserName: common.UserName("player-abcdefgh"), PreferredLanguage: common.LanguageTag("en-US"), TimeZone: common.TimeZoneName("UTC"), DeclaredCountry: common.CountryCode("DE"), CreatedAt: time.Unix(1_775_240_000, 0).UTC(), UpdatedAt: time.Unix(1_775_240_000, 0).UTC(), }, inputLanguage: "en-us", inputTimeZone: " UTC ", wantLanguage: "en-US", wantTimeZone: "UTC", wantUpdateCalls: 0, }, { name: "invalid preferred language", accountRecord: validUserAccount(), inputLanguage: "bad@@tag", inputTimeZone: "UTC", wantCode: shared.ErrorCodeInvalidRequest, wantLanguage: "en", wantTimeZone: "Europe/Kaliningrad", }, { name: "invalid time zone", accountRecord: validUserAccount(), inputLanguage: "en", inputTimeZone: "Mars/Olympus", wantCode: shared.ErrorCodeInvalidRequest, wantLanguage: "en", wantTimeZone: "Europe/Kaliningrad", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_500, 0).UTC() accountStore := newFakeAccountStore(tt.accountRecord) service, err := NewSettingsUpdater( accountStore, &fakeEntitlementSnapshotStore{ byUserID: map[common.UserID]entitlement.CurrentSnapshot{ common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now), }, }, fakeSanctionStore{}, fakeLimitStore{}, fixedClock{now: now}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), UpdateMySettingsInput{ UserID: "user-123", PreferredLanguage: tt.inputLanguage, TimeZone: tt.inputTimeZone, }) if tt.wantCode != "" { require.Error(t, err) require.Equal(t, tt.wantCode, shared.CodeOf(err)) } else { require.NoError(t, err) } require.Equal(t, tt.wantUpdateCalls, accountStore.updateCalls) storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123")) require.NoError(t, err) require.Equal(t, tt.wantLanguage, storedAccount.PreferredLanguage.String()) require.Equal(t, tt.wantTimeZone, storedAccount.TimeZone.String()) if tt.wantCode == "" { require.Equal(t, tt.wantLanguage, result.Account.PreferredLanguage) require.Equal(t, tt.wantTimeZone, result.Account.TimeZone) } }) } } type fakeAccountStore struct { records map[common.UserID]account.UserAccount updateErr error updateCalls int } func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore { byUserID := make(map[common.UserID]account.UserAccount, len(records)) for _, record := range records { byUserID[record.UserID] = record } return &fakeAccountStore{records: byUserID} } func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error { if input.Account.Validate() != nil { return ports.ErrConflict } return nil } func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) { record, ok := store.records[userID] if !ok { return account.UserAccount{}, ports.ErrNotFound } return record, nil } func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) { for _, record := range store.records { if record.Email == email { return record, nil } } return account.UserAccount{}, ports.ErrNotFound } func (store *fakeAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) { for _, record := range store.records { if record.UserName == userName { return record, nil } } return account.UserAccount{}, ports.ErrNotFound } func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) { _, ok := store.records[userID] return ok, nil } func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error { store.updateCalls++ if store.updateErr != nil { return store.updateErr } if _, ok := store.records[record.UserID]; !ok { return ports.ErrNotFound } store.records[record.UserID] = record return nil } type fakeEntitlementSnapshotStore struct { byUserID map[common.UserID]entitlement.CurrentSnapshot } func (store *fakeEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) { record, ok := store.byUserID[userID] if !ok { return entitlement.CurrentSnapshot{}, ports.ErrNotFound } return record, nil } func (store *fakeEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error { if store.byUserID != nil { store.byUserID[record.UserID] = record } return nil } type fakeEntitlementLifecycleStore struct { snapshotStore *fakeEntitlementSnapshotStore } func (store *fakeEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error { return nil } func (store *fakeEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error { return nil } func (store *fakeEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error { return nil } func (store *fakeEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error { if store.snapshotStore != nil { return store.snapshotStore.Put(ctx, input.NewSnapshot) } return nil } type readerIDGenerator struct { recordID entitlement.EntitlementRecordID sanctionRecordID policy.SanctionRecordID limitRecordID policy.LimitRecordID } func (generator readerIDGenerator) NewUserID() (common.UserID, error) { return "", nil } func (generator readerIDGenerator) NewUserName() (common.UserName, error) { return "", nil } func (generator readerIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) { return generator.recordID, nil } func (generator readerIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) { return generator.sanctionRecordID, nil } func (generator readerIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) { return generator.limitRecordID, 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 } func validUserAccount() 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"), DeclaredCountry: common.CountryCode("DE"), CreatedAt: createdAt, UpdatedAt: createdAt, } } func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot { return entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodeFree, IsPaid: false, StartsAt: now.Add(-time.Hour), Source: common.Source("auth_registration"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, ReasonCode: common.ReasonCode("initial_free_entitlement"), UpdatedAt: now, } } func validActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord { return policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-" + string(code)), UserID: userID, SanctionCode: code, Scope: common.Scope("self_service"), ReasonCode: common.ReasonCode("policy_enforced"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, AppliedAt: appliedAt.UTC(), } } func expiredSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord { expiresAt := appliedAt.Add(30 * time.Minute) record := validActiveSanction(userID, code, appliedAt) record.RecordID = policy.SanctionRecordID(record.RecordID.String() + "-expired") record.ExpiresAt = &expiresAt return record } func validActiveLimit(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("policy_enforced"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, AppliedAt: appliedAt.UTC(), } } func removedLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord { removedAt := appliedAt.Add(30 * time.Minute) record := validActiveLimit(userID, code, value, appliedAt) record.RecordID = policy.LimitRecordID(record.RecordID.String() + "-removed") record.RemovedAt = &removedAt record.RemovedBy = common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")} record.RemovedReasonCode = common.ReasonCode("policy_reset") return record } func timePointer(value time.Time) *time.Time { utcValue := value.UTC() return &utcValue } var ( _ ports.UserAccountStore = (*fakeAccountStore)(nil) _ ports.EntitlementSnapshotStore = (*fakeEntitlementSnapshotStore)(nil) _ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil) _ ports.SanctionStore = fakeSanctionStore{} _ ports.LimitStore = fakeLimitStore{} _ ports.IDGenerator = readerIDGenerator{} )