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

566 lines
18 KiB
Go

package entitlementsvc
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/shared"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDRepairsExpiredFinitePaidSnapshot(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(2 * time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
reader, err := NewReader(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
})
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.False(t, got.IsPaid)
require.Equal(t, endsAt, got.StartsAt)
require.Equal(t, expiryRepairSource, got.Source)
require.Equal(t, expiryRepairReasonCode, got.ReasonCode)
require.Equal(t, common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID}, got.Actor)
require.Len(t, historyStore.byUserID[userID], 2)
require.Equal(t, got, snapshotStore.byUserID[userID])
require.Equal(t, entitlement.EntitlementRecordID("entitlement-free"), lifecycleStore.repairInput.NewRecord.RecordID)
}
func TestGrantServiceExecuteRejectsInvalidPlanRules(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
freeSnapshot := freeSnapshot(userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
freeRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
tests := []struct {
name string
input GrantInput
wantErr string
}{
{
name: "free plan not allowed",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodeFree),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "future starts at rejected",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Add(time.Hour).Format(time.RFC3339Nano),
EndsAt: now.Add(31 * 24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "finite plan requires ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "lifetime plan forbids ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidLifetime),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {freeRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: freeSnapshot}},
&fakeLifecycleStore{},
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, tt.wantErr, shared.CodeOf(err))
})
}
}
func TestGrantServiceExecuteBuildsTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-paid"), lifecycleStore.grantInput.NewRecord.RecordID)
require.Equal(t, currentSnapshot, lifecycleStore.grantInput.ExpectedCurrentSnapshot)
require.Equal(t, currentRecord.RecordID, lifecycleStore.grantInput.UpdatedCurrentRecord.RecordID)
require.NotNil(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
}
func TestExtendServiceExecuteBuildsExtensionSegment(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewExtendService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ExtendInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_extend",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
EndsAt: currentEndsAt.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, currentEndsAt, lifecycleStore.extendInput.NewRecord.StartsAt)
require.Equal(t, startsAt, lifecycleStore.extendInput.NewSnapshot.StartsAt)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
}
func TestRevokeServiceExecuteBuildsFreeTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewRevokeService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RevokeInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_revoke",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, result.Entitlement.PlanCode)
require.NotNil(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
require.Equal(t, now, lifecycleStore.revokeInput.NewRecord.StartsAt)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeSnapshotStore) 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 *fakeSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
store.byUserID[record.UserID] = record
return nil
}
type fakeHistoryStore struct {
byUserID map[common.UserID][]entitlement.PeriodRecord
}
func (store *fakeHistoryStore) Create(_ context.Context, record entitlement.PeriodRecord) error {
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeHistoryStore) GetByRecordID(_ context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
for _, records := range store.byUserID {
for _, record := range records {
if record.RecordID == recordID {
return record, nil
}
}
}
return entitlement.PeriodRecord{}, ports.ErrNotFound
}
func (store *fakeHistoryStore) ListByUserID(_ context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
records := store.byUserID[userID]
cloned := make([]entitlement.PeriodRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeHistoryStore) Update(_ context.Context, record entitlement.PeriodRecord) error {
records := store.byUserID[record.UserID]
for idx := range records {
if records[idx].RecordID == record.RecordID {
records[idx] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeEffectiveReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (reader fakeEffectiveReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
type fakeLifecycleStore struct {
historyStore *fakeHistoryStore
snapshotStore *fakeSnapshotStore
grantInput ports.GrantEntitlementInput
extendInput ports.ExtendEntitlementInput
revokeInput ports.RevokeEntitlementInput
repairInput ports.RepairExpiredEntitlementInput
}
func (store *fakeLifecycleStore) Grant(_ context.Context, input ports.GrantEntitlementInput) error {
store.grantInput = input
return nil
}
func (store *fakeLifecycleStore) Extend(_ context.Context, input ports.ExtendEntitlementInput) error {
store.extendInput = input
return nil
}
func (store *fakeLifecycleStore) Revoke(_ context.Context, input ports.RevokeEntitlementInput) error {
store.revokeInput = input
return nil
}
func (store *fakeLifecycleStore) RepairExpired(_ context.Context, input ports.RepairExpiredEntitlementInput) error {
store.repairInput = input
if store.historyStore != nil {
store.historyStore.byUserID[input.NewRecord.UserID] = append(store.historyStore.byUserID[input.NewRecord.UserID], input.NewRecord)
}
if store.snapshotStore != nil {
store.snapshotStore.byUserID[input.NewSnapshot.UserID] = input.NewSnapshot
}
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
func freeSnapshot(
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func freeRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
StartsAt: startsAt,
CreatedAt: startsAt,
}
}
func paidSnapshot(
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 paidRecord(
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 timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.EntitlementSnapshotStore = (*fakeSnapshotStore)(nil)
_ ports.EntitlementHistoryStore = (*fakeHistoryStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)