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) GetByUserName(context.Context, common.UserName) (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) 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) NewUserName() (common.UserName, 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{} )