Files
galaxy-game/user/internal/adapters/redis/userstore/store_test.go
T
2026-04-25 23:20:55 +02:00

880 lines
30 KiB
Go

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,
}
}