package userstore import ( "context" "testing" "time" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/authblock" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/require" ) func TestAccountStoreCreateAndLookups(t *testing.T) { t.Parallel() store := newTestStore(t) accountStore := store.Accounts() record := validAccountRecord() require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record))) byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.Equal(t, record, byUserID) byEmail, err := accountStore.GetByEmail(context.Background(), record.Email) require.NoError(t, err) require.Equal(t, record, byEmail) byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName) require.NoError(t, err) require.Equal(t, record, byUserName) exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID) require.NoError(t, err) require.True(t, exists) } func TestBlockedEmailStoreUpsertAndGet(t *testing.T) { t.Parallel() store := newTestStore(t) blockedEmailStore := store.BlockedEmails() record := authblock.BlockedEmailSubject{ Email: common.Email("blocked@example.com"), ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: time.Unix(1_775_240_100, 0).UTC(), ResolvedUserID: common.UserID("user-123"), } require.NoError(t, blockedEmailStore.Upsert(context.Background(), record)) got, err := blockedEmailStore.GetByEmail(context.Background(), record.Email) require.NoError(t, err) require.Equal(t, record, got) } func TestEnsureResolveAndBlockFlows(t *testing.T) { t.Parallel() store := newTestStore(t) now := time.Unix(1_775_240_000, 0).UTC() accountRecord := validAccountRecord() entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now) created, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{ Email: accountRecord.Email, Account: accountRecord, Entitlement: entitlementSnapshot, EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now), }) require.NoError(t, err) require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome) byUserName, err := store.GetByUserName(context.Background(), accountRecord.UserName) require.NoError(t, err) require.Equal(t, accountRecord.UserID, byUserName.UserID) entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID) require.NoError(t, err) require.Len(t, entitlementHistory, 1) require.Equal(t, validEntitlementRecord(accountRecord.UserID, now), entitlementHistory[0]) resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email) require.NoError(t, err) require.Equal(t, ports.AuthResolutionKindExisting, resolved.Kind) blockedByUserID, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{ UserID: accountRecord.UserID, ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: now.Add(time.Minute), }) require.NoError(t, err) require.Equal(t, ports.AuthBlockOutcomeBlocked, blockedByUserID.Outcome) repeatedBlock, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{ Email: accountRecord.Email, ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: now.Add(2 * time.Minute), }) require.NoError(t, err) require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, repeatedBlock.Outcome) require.Equal(t, accountRecord.UserID, repeatedBlock.UserID) blockedResolution, err := store.ResolveByEmail(context.Background(), accountRecord.Email) require.NoError(t, err) require.Equal(t, ports.AuthResolutionKindBlocked, blockedResolution.Kind) ensureBlocked, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{ Email: accountRecord.Email, Account: accountRecord, Entitlement: entitlementSnapshot, EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now), }) require.NoError(t, err) require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome) } func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) { t.Parallel() store := newTestStore(t) now := time.Unix(1_775_240_000, 0).UTC() accountRecord := validAccountRecord() entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now) blocked, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{ Email: accountRecord.Email, ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: now, }) require.NoError(t, err) require.Equal(t, ports.AuthBlockOutcomeBlocked, blocked.Outcome) require.True(t, blocked.UserID.IsZero()) resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email) require.NoError(t, err) require.Equal(t, ports.AuthResolutionKindBlocked, resolved.Kind) ensured, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{ Email: accountRecord.Email, Account: accountRecord, Entitlement: entitlementSnapshot, EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now), }) require.NoError(t, err) require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome) exists, err := store.ExistsByUserID(context.Background(), accountRecord.UserID) require.NoError(t, err) require.False(t, exists) } func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) { t.Parallel() store := newTestStore(t) createdAt := time.Unix(1_775_240_000, 0).UTC() existingAccount := account.UserAccount{ UserID: common.UserID("user-existing"), Email: common.Email("pilot@example.com"), UserName: common.UserName("player-abcdefgh"), PreferredLanguage: common.LanguageTag("en"), TimeZone: common.TimeZoneName("Europe/Kaliningrad"), CreatedAt: createdAt, UpdatedAt: createdAt, } require.NoError(t, store.Create(context.Background(), createAccountInput(existingAccount))) result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{ Email: existingAccount.Email, Account: account.UserAccount{ UserID: common.UserID("user-created"), Email: existingAccount.Email, UserName: common.UserName("player-newabcde"), PreferredLanguage: common.LanguageTag("fr-FR"), TimeZone: common.TimeZoneName("UTC"), CreatedAt: createdAt.Add(time.Minute), UpdatedAt: createdAt.Add(time.Minute), }, Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)), EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)), }) require.NoError(t, err) require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome) require.Equal(t, existingAccount.UserID, result.UserID) storedAccount, err := store.GetByEmail(context.Background(), existingAccount.Email) require.NoError(t, err) require.Equal(t, existingAccount, storedAccount) } func TestAccountStoreUpdateDisplayNamePreservesImmutableFields(t *testing.T) { t.Parallel() store := newTestStore(t) accountStore := store.Accounts() record := validAccountRecord() require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record))) updated := record updated.DisplayName = common.DisplayName("NovaPrime") updated.UpdatedAt = record.UpdatedAt.Add(time.Minute) require.NoError(t, accountStore.Update(context.Background(), updated)) byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.Equal(t, updated, byUserID) byEmail, err := accountStore.GetByEmail(context.Background(), record.Email) require.NoError(t, err) require.Equal(t, updated, byEmail) byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName) require.NoError(t, err) require.Equal(t, updated, byUserName) } func TestAccountStoreUpdateRejectsUserNameMutation(t *testing.T) { t.Parallel() store := newTestStore(t) accountStore := store.Accounts() record := validAccountRecord() require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record))) attempted := record attempted.UserName = common.UserName("player-changed") attempted.UpdatedAt = record.UpdatedAt.Add(time.Minute) err := accountStore.Update(context.Background(), attempted) require.ErrorIs(t, err, ports.ErrConflict) } func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) { t.Parallel() store := newTestStore(t) accountStore := store.Accounts() record := validAccountRecord() require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record))) updated := record updated.DeclaredCountry = common.CountryCode("FR") updated.UpdatedAt = record.UpdatedAt.Add(time.Minute) require.NoError(t, accountStore.Update(context.Background(), updated)) byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.Equal(t, updated, byUserID) byEmail, err := accountStore.GetByEmail(context.Background(), record.Email) require.NoError(t, err) require.Equal(t, updated, byEmail) byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName) require.NoError(t, err) require.Equal(t, updated, byUserName) } func TestAccountStorePersistsSoftDeleteMarker(t *testing.T) { t.Parallel() store := newTestStore(t) accountStore := store.Accounts() record := validAccountRecord() require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record))) deletedAt := record.UpdatedAt.Add(time.Hour) updated := record updated.UpdatedAt = deletedAt updated.DeletedAt = &deletedAt require.NoError(t, accountStore.Update(context.Background(), updated)) byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID) require.NoError(t, err) require.NotNil(t, byUserID.DeletedAt) require.True(t, byUserID.DeletedAt.Equal(deletedAt)) require.True(t, byUserID.IsDeleted()) } func TestAccountStoreCreateReturnsUserNameConflict(t *testing.T) { t.Parallel() store := newTestStore(t) accountStore := store.Accounts() first := validAccountRecord() second := validAccountRecord() second.UserID = common.UserID("user-456") second.Email = common.Email("other@example.com") require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first))) err := accountStore.Create(context.Background(), createAccountInput(second)) require.ErrorIs(t, err, ports.ErrUserNameConflict) } func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) { t.Parallel() store := newTestStore(t) now := time.Unix(1_775_240_000, 0).UTC() accountRecord := validAccountRecord() require.NoError(t, store.Create(context.Background(), createAccountInput(accountRecord))) first, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{ UserID: accountRecord.UserID, ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: now, }) require.NoError(t, err) require.Equal(t, ports.AuthBlockOutcomeBlocked, first.Outcome) second, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{ UserID: accountRecord.UserID, ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: now.Add(time.Minute), }) require.NoError(t, err) require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, second.Outcome) require.Equal(t, accountRecord.UserID, second.UserID) } func TestBlockByUserIDUnknownUserReturnsNotFound(t *testing.T) { t.Parallel() store := newTestStore(t) _, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{ UserID: common.UserID("user-missing"), ReasonCode: common.ReasonCode("policy_blocked"), BlockedAt: time.Unix(1_775_240_000, 0).UTC(), }) require.ErrorIs(t, err, ports.ErrNotFound) } func TestSanctionAndLimitStoresRoundTrip(t *testing.T) { t.Parallel() store := newTestStore(t) sanctionStore := store.Sanctions() limitStore := store.Limits() now := time.Unix(1_775_240_000, 0).UTC() sanctionRecord := policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-1"), UserID: common.UserID("user-123"), SanctionCode: policy.SanctionCodeLoginBlock, Scope: common.Scope("self_service"), ReasonCode: common.ReasonCode("policy_enforced"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, AppliedAt: now, } require.NoError(t, sanctionStore.Create(context.Background(), sanctionRecord)) gotSanction, err := sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID) require.NoError(t, err) require.Equal(t, sanctionRecord, gotSanction) sanctions, err := sanctionStore.ListByUserID(context.Background(), sanctionRecord.UserID) require.NoError(t, err) require.Len(t, sanctions, 1) expiresAt := now.Add(time.Hour) sanctionRecord.ExpiresAt = &expiresAt require.NoError(t, sanctionStore.Update(context.Background(), sanctionRecord)) gotSanction, err = sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID) require.NoError(t, err) require.Equal(t, sanctionRecord.RecordID, gotSanction.RecordID) require.Equal(t, sanctionRecord.UserID, gotSanction.UserID) require.Equal(t, sanctionRecord.SanctionCode, gotSanction.SanctionCode) require.Equal(t, sanctionRecord.Scope, gotSanction.Scope) require.Equal(t, sanctionRecord.ReasonCode, gotSanction.ReasonCode) require.Equal(t, sanctionRecord.Actor, gotSanction.Actor) require.True(t, gotSanction.AppliedAt.Equal(sanctionRecord.AppliedAt)) require.NotNil(t, gotSanction.ExpiresAt) require.True(t, gotSanction.ExpiresAt.Equal(*sanctionRecord.ExpiresAt)) limitRecord := policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-1"), UserID: common.UserID("user-123"), LimitCode: policy.LimitCodeMaxOwnedPrivateGames, Value: 3, ReasonCode: common.ReasonCode("policy_enforced"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, AppliedAt: now, } require.NoError(t, limitStore.Create(context.Background(), limitRecord)) gotLimit, err := limitStore.GetByRecordID(context.Background(), limitRecord.RecordID) require.NoError(t, err) require.Equal(t, limitRecord, gotLimit) limits, err := limitStore.ListByUserID(context.Background(), limitRecord.UserID) require.NoError(t, err) require.Len(t, limits, 1) limitRecord.Value = 5 require.NoError(t, limitStore.Update(context.Background(), limitRecord)) gotLimit, err = limitStore.GetByRecordID(context.Background(), limitRecord.RecordID) require.NoError(t, err) require.Equal(t, limitRecord, gotLimit) } func TestPolicyLifecycleApplyAndRemoveSanction(t *testing.T) { t.Parallel() store := newTestStore(t) lifecycleStore := store.PolicyLifecycle() sanctionStore := store.Sanctions() snapshotStore := store.EntitlementSnapshots() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") require.NoError(t, snapshotStore.Put(context.Background(), validEntitlementSnapshot(userID, now))) record := policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-1"), UserID: userID, SanctionCode: policy.SanctionCodeLoginBlock, Scope: common.Scope("auth"), ReasonCode: common.ReasonCode("manual_block"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now, } require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{ NewRecord: record, })) activeRecordID, err := store.loadActiveSanctionRecordID( context.Background(), store.client, store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock), ) require.NoError(t, err) require.Equal(t, record.RecordID, activeRecordID) err = lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{ NewRecord: policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-2"), UserID: userID, SanctionCode: policy.SanctionCodeLoginBlock, Scope: common.Scope("auth"), ReasonCode: common.ReasonCode("manual_block"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}, AppliedAt: now.Add(time.Minute), }, }) require.ErrorIs(t, err, ports.ErrConflict) removed := record removedAt := now.Add(30 * time.Minute) removed.RemovedAt = &removedAt removed.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")} removed.RemovedReasonCode = common.ReasonCode("manual_remove") require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{ ExpectedActiveRecord: record, UpdatedRecord: removed, })) stored, err := sanctionStore.GetByRecordID(context.Background(), record.RecordID) require.NoError(t, err) require.Equal(t, removed, stored) _, err = store.loadActiveSanctionRecordID( context.Background(), store.client, store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock), ) require.ErrorIs(t, err, ports.ErrNotFound) } func TestPolicyLifecycleSetAndRemoveLimit(t *testing.T) { t.Parallel() store := newTestStore(t) lifecycleStore := store.PolicyLifecycle() limitStore := store.Limits() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") first := policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-1"), UserID: userID, LimitCode: policy.LimitCodeMaxOwnedPrivateGames, Value: 3, ReasonCode: common.ReasonCode("manual_override"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now, } require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{ NewRecord: first, })) activeRecordID, err := store.loadActiveLimitRecordID( context.Background(), store.client, store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames), ) require.NoError(t, err) require.Equal(t, first.RecordID, activeRecordID) second := policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-2"), UserID: userID, LimitCode: policy.LimitCodeMaxOwnedPrivateGames, Value: 5, ReasonCode: common.ReasonCode("manual_override"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}, AppliedAt: now.Add(time.Hour), } updatedFirst := first removedAt := second.AppliedAt updatedFirst.RemovedAt = &removedAt updatedFirst.RemovedBy = second.Actor updatedFirst.RemovedReasonCode = second.ReasonCode require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{ ExpectedActiveRecord: &first, UpdatedActiveRecord: &updatedFirst, NewRecord: second, })) storedFirst, err := limitStore.GetByRecordID(context.Background(), first.RecordID) require.NoError(t, err) require.Equal(t, updatedFirst, storedFirst) activeRecordID, err = store.loadActiveLimitRecordID( context.Background(), store.client, store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames), ) require.NoError(t, err) require.Equal(t, second.RecordID, activeRecordID) removedSecond := second removeAt := now.Add(90 * time.Minute) removedSecond.RemovedAt = &removeAt removedSecond.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-3")} removedSecond.RemovedReasonCode = common.ReasonCode("manual_remove") require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{ ExpectedActiveRecord: second, UpdatedRecord: removedSecond, })) storedSecond, err := limitStore.GetByRecordID(context.Background(), second.RecordID) require.NoError(t, err) require.Equal(t, removedSecond, storedSecond) _, err = store.loadActiveLimitRecordID( context.Background(), store.client, store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames), ) require.ErrorIs(t, err, ports.ErrNotFound) } func TestEntitlementLifecycleTransitions(t *testing.T) { t.Parallel() store := newTestStore(t) historyStore := store.EntitlementHistory() snapshotStore := store.EntitlementSnapshots() lifecycleStore := store.EntitlementLifecycle() userID := common.UserID("user-123") startedFreeAt := time.Unix(1_775_240_000, 0).UTC() freeRecord := validEntitlementRecord(userID, startedFreeAt) freeSnapshot := validEntitlementSnapshot(userID, startedFreeAt) require.NoError(t, historyStore.Create(context.Background(), freeRecord)) require.NoError(t, snapshotStore.Put(context.Background(), freeSnapshot)) grantStartsAt := startedFreeAt.Add(24 * time.Hour) grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour) grantedRecord := paidEntitlementRecord( entitlement.EntitlementRecordID("entitlement-paid-1"), userID, entitlement.PlanCodePaidMonthly, grantStartsAt, grantEndsAt, common.Source("admin"), common.ReasonCode("manual_grant"), ) grantedSnapshot := paidEntitlementSnapshot( userID, entitlement.PlanCodePaidMonthly, grantStartsAt, grantEndsAt, common.Source("admin"), common.ReasonCode("manual_grant"), ) closedFreeRecord := freeRecord closedFreeRecord.ClosedAt = timePointer(grantStartsAt) closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")} closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant") require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{ ExpectedCurrentSnapshot: freeSnapshot, ExpectedCurrentRecord: freeRecord, UpdatedCurrentRecord: closedFreeRecord, NewRecord: grantedRecord, NewSnapshot: grantedSnapshot, })) storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID) require.NoError(t, err) require.Equal(t, grantedSnapshot, storedSnapshot) storedFreeRecord, err := historyStore.GetByRecordID(context.Background(), freeRecord.RecordID) require.NoError(t, err) require.Equal(t, closedFreeRecord, storedFreeRecord) extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour) extensionRecord := paidEntitlementRecord( entitlement.EntitlementRecordID("entitlement-paid-2"), userID, entitlement.PlanCodePaidMonthly, grantEndsAt, extendedEndsAt, common.Source("admin"), common.ReasonCode("manual_extend"), ) extendedSnapshot := paidEntitlementSnapshot( userID, entitlement.PlanCodePaidMonthly, grantStartsAt, extendedEndsAt, common.Source("admin"), common.ReasonCode("manual_extend"), ) require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{ ExpectedCurrentSnapshot: grantedSnapshot, NewRecord: extensionRecord, NewSnapshot: extendedSnapshot, })) storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID) require.NoError(t, err) require.Equal(t, extendedSnapshot, storedSnapshot) revokeAt := grantEndsAt.Add(12 * time.Hour) revokedCurrentRecord := extensionRecord revokedCurrentRecord.ClosedAt = timePointer(revokeAt) revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")} revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke") freeAfterRevokeRecord := entitlement.PeriodRecord{ RecordID: entitlement.EntitlementRecordID("entitlement-free-2"), UserID: userID, PlanCode: entitlement.PlanCodeFree, Source: common.Source("admin"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, ReasonCode: common.ReasonCode("manual_revoke"), StartsAt: revokeAt, CreatedAt: revokeAt, } freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodeFree, IsPaid: false, StartsAt: revokeAt, Source: common.Source("admin"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, ReasonCode: common.ReasonCode("manual_revoke"), UpdatedAt: revokeAt, } require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{ ExpectedCurrentSnapshot: extendedSnapshot, ExpectedCurrentRecord: extensionRecord, UpdatedCurrentRecord: revokedCurrentRecord, NewRecord: freeAfterRevokeRecord, NewSnapshot: freeAfterRevokeSnapshot, })) storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID) require.NoError(t, err) require.Equal(t, freeAfterRevokeSnapshot, storedSnapshot) historyRecords, err := historyStore.ListByUserID(context.Background(), userID) require.NoError(t, err) require.Len(t, historyRecords, 4) } func TestRepairExpiredEntitlementMaterializesFreeSnapshot(t *testing.T) { t.Parallel() store := newTestStore(t) historyStore := store.EntitlementHistory() snapshotStore := store.EntitlementSnapshots() lifecycleStore := store.EntitlementLifecycle() userID := common.UserID("user-123") startsAt := time.Unix(1_775_240_000, 0).UTC() endsAt := startsAt.Add(24 * time.Hour) expiredSnapshot := paidEntitlementSnapshot( userID, entitlement.PlanCodePaidMonthly, startsAt, endsAt, common.Source("admin"), common.ReasonCode("manual_grant"), ) expiredSnapshot.UpdatedAt = endsAt.Add(24 * time.Hour) expiredRecord := paidEntitlementRecord( entitlement.EntitlementRecordID("entitlement-paid-1"), userID, entitlement.PlanCodePaidMonthly, startsAt, endsAt, common.Source("admin"), common.ReasonCode("manual_grant"), ) require.NoError(t, historyStore.Create(context.Background(), expiredRecord)) require.NoError(t, snapshotStore.Put(context.Background(), expiredSnapshot)) repairedAt := endsAt.Add(2 * time.Hour) freeRecord := entitlement.PeriodRecord{ RecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry"), UserID: userID, PlanCode: entitlement.PlanCodeFree, Source: common.Source("entitlement_expiry_repair"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, ReasonCode: common.ReasonCode("paid_entitlement_expired"), StartsAt: endsAt, CreatedAt: repairedAt, } freeSnapshot := entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodeFree, IsPaid: false, StartsAt: endsAt, Source: common.Source("entitlement_expiry_repair"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, ReasonCode: common.ReasonCode("paid_entitlement_expired"), UpdatedAt: repairedAt, } require.NoError(t, lifecycleStore.RepairExpired(context.Background(), ports.RepairExpiredEntitlementInput{ ExpectedExpiredSnapshot: expiredSnapshot, NewRecord: freeRecord, NewSnapshot: freeSnapshot, })) storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID) require.NoError(t, err) require.Equal(t, freeSnapshot, storedSnapshot) historyRecords, err := historyStore.ListByUserID(context.Background(), userID) require.NoError(t, err) require.Len(t, historyRecords, 2) require.Equal(t, freeRecord, historyRecords[1]) } func newTestStore(t *testing.T) *Store { t.Helper() server := miniredis.RunT(t) store, err := New(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 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 validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot { return entitlement.CurrentSnapshot{ UserID: userID, PlanCode: entitlement.PlanCodeFree, IsPaid: false, StartsAt: now, 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 validEntitlementRecord(userID common.UserID, now time.Time) entitlement.PeriodRecord { return entitlement.PeriodRecord{ RecordID: entitlement.EntitlementRecordID("entitlement-" + userID.String()), UserID: userID, PlanCode: entitlement.PlanCodeFree, Source: common.Source("auth_registration"), Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}, ReasonCode: common.ReasonCode("initial_free_entitlement"), StartsAt: now, CreatedAt: now, } } func paidEntitlementRecord( recordID entitlement.EntitlementRecordID, userID common.UserID, planCode entitlement.PlanCode, startsAt time.Time, endsAt time.Time, source common.Source, reasonCode common.ReasonCode, ) entitlement.PeriodRecord { return entitlement.PeriodRecord{ RecordID: recordID, UserID: userID, PlanCode: planCode, Source: source, Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, ReasonCode: reasonCode, StartsAt: startsAt, EndsAt: timePointer(endsAt), CreatedAt: startsAt, } } func paidEntitlementSnapshot( userID common.UserID, planCode entitlement.PlanCode, startsAt time.Time, endsAt time.Time, source common.Source, reasonCode common.ReasonCode, ) entitlement.CurrentSnapshot { return entitlement.CurrentSnapshot{ UserID: userID, PlanCode: planCode, IsPaid: true, StartsAt: startsAt, EndsAt: timePointer(endsAt), Source: source, Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, ReasonCode: reasonCode, UpdatedAt: startsAt, } } func timePointer(value time.Time) *time.Time { utcValue := value.UTC() return &utcValue } func createAccountInput(record account.UserAccount) ports.CreateAccountInput { return ports.CreateAccountInput{ Account: record, } }