Files
galaxy-game/user/internal/service/selfservice/service_test.go
T
2026-04-10 19:05:02 +02:00

733 lines
22 KiB
Go

package selfservice
import (
"context"
"strings"
"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},
stubRaceNamePolicy{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.renameCalls)
}
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputRaceName string
renameErr error
wantCode string
wantRaceName string
wantRenameCalls int
wantCurrentKey account.RaceNameCanonicalKey
wantNewKey account.RaceNameCanonicalKey
}{
{
name: "success",
inputRaceName: "Nova Prime",
wantRaceName: "Nova Prime",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
},
{
name: "same canonical different exact",
inputRaceName: "P1lot Nova",
wantRaceName: "P1lot Nova",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("P1lot Nova")),
},
{
name: "no-op",
inputRaceName: " Pilot Nova ",
wantRaceName: "Pilot Nova",
wantRenameCalls: 0,
},
{
name: "conflict",
inputRaceName: "Taken Name",
renameErr: ports.ErrConflict,
wantCode: shared.ErrorCodeConflict,
wantRaceName: "Pilot Nova",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Taken Name")),
},
}
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(validUserAccount())
accountStore.renameErr = tt.renameErr
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},
stubRaceNamePolicy{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: tt.inputRaceName,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantRenameCalls, accountStore.renameCalls)
if tt.wantRenameCalls > 0 {
require.Equal(t, tt.wantCurrentKey, accountStore.lastRenameInput.CurrentCanonicalKey)
require.Equal(t, tt.wantNewKey, accountStore.lastRenameInput.NewReservation.CanonicalKey)
}
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, tt.wantRaceName, storedAccount.RaceName.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
}
})
}
}
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"),
RaceName: common.RaceName("Pilot Nova"),
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
renameErr error
updateErr error
renameCalls int
updateCalls int
lastRenameInput ports.RenameRaceNameInput
}
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 || input.Reservation.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) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
for _, record := range store.records {
if record.RaceName == raceName {
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) RenameRaceName(_ context.Context, input ports.RenameRaceNameInput) error {
store.renameCalls++
store.lastRenameInput = input
if store.renameErr != nil {
return store.renameErr
}
if err := input.Validate(); err != nil {
return err
}
record, ok := store.records[input.UserID]
if !ok {
return ports.ErrNotFound
}
record.RaceName = input.NewRaceName
record.UpdatedAt = input.UpdatedAt.UTC()
store.records[input.UserID] = record
return 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) NewInitialRaceName() (common.RaceName, 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
}
type stubRaceNamePolicy struct{}
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return canonicalKey(raceName), nil
}
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey(strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
).Replace(strings.ToLower(raceName.String())))
}
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"),
RaceName: common.RaceName("Pilot Nova"),
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.RaceNamePolicy = stubRaceNamePolicy{}
_ ports.IDGenerator = readerIDGenerator{}
)