package service import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/require" ) type lockingRenewalRepo struct { userSubRepoNoop mu sync.Mutex stale UserSubscription current UserSubscription lockReads int } func (r *lockingRenewalRepo) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { return true, nil } func (r *lockingRenewalRepo) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) { copy := r.stale return ©, nil } func (r *lockingRenewalRepo) GetByID(_ context.Context, _ int64) (*UserSubscription, error) { r.mu.Lock() defer r.mu.Unlock() copy := r.current return ©, nil } func (r *lockingRenewalRepo) GetByIDForUpdate(_ context.Context, _ int64) (*UserSubscription, error) { r.mu.Lock() defer r.mu.Unlock() r.lockReads++ copy := r.current return ©, nil } func (r *lockingRenewalRepo) ExtendExpiry(_ context.Context, _ int64, expiresAt time.Time) error { r.mu.Lock() defer r.mu.Unlock() r.current.ExpiresAt = expiresAt return nil } func (r *lockingRenewalRepo) UpdateStatus(_ context.Context, _ int64, status string) error { r.mu.Lock() defer r.mu.Unlock() r.current.Status = status return nil } func (r *lockingRenewalRepo) UpdateNotes(_ context.Context, _ int64, notes string) error { r.mu.Lock() defer r.mu.Unlock() r.current.Notes = notes return nil } func (r *lockingRenewalRepo) Update(_ context.Context, sub *UserSubscription) error { r.mu.Lock() defer r.mu.Unlock() r.current = *sub return nil } func TestAssignOrExtendSubscriptionUsesLockedCurrentRow(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) lockedExpiry := now.AddDate(0, 0, 20) windowStart := now.Add(-24 * time.Hour) repo := &lockingRenewalRepo{ stale: UserSubscription{ID: 7, UserID: 11, GroupID: 13, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired, Notes: "stale"}, current: UserSubscription{ ID: 7, UserID: 11, GroupID: 13, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: lockedExpiry, Status: SubscriptionStatusSuspended, Notes: "current", DailyWindowStart: &windowStart, DailyUsageUSD: 4, }, } svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 13, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil) svc.now = func() time.Time { return now } sub, extended, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{ UserID: 11, GroupID: 13, ValidityDays: 5, Notes: "renewed", }) require.NoError(t, err) require.True(t, extended) require.Equal(t, 1, repo.lockReads) require.Equal(t, lockedExpiry.AddDate(0, 0, 5), sub.ExpiresAt) require.Equal(t, SubscriptionStatusActive, sub.Status) require.Equal(t, "current\nrenewed", sub.Notes) require.Equal(t, windowStart, *sub.DailyWindowStart) require.Equal(t, float64(4), sub.DailyUsageUSD) } func TestAssignOrExtendSubscriptionSerializedRenewalsAccumulateDays(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) initialExpiry := now.AddDate(0, 0, 10) stale := UserSubscription{ID: 17, UserID: 21, GroupID: 23, StartsAt: now, ExpiresAt: initialExpiry, Status: SubscriptionStatusActive} repo := &lockingRenewalRepo{stale: stale, current: stale} svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 23, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil) svc.now = func() time.Time { return now } input := &AssignSubscriptionInput{UserID: 21, GroupID: 23, ValidityDays: 7} _, _, err := svc.AssignOrExtendSubscription(context.Background(), input) require.NoError(t, err) second, _, err := svc.AssignOrExtendSubscription(context.Background(), input) require.NoError(t, err) require.Equal(t, 2, repo.lockReads) require.Equal(t, initialExpiry.AddDate(0, 0, 14), second.ExpiresAt) } func TestAssignSubscriptionDoesNotReactivateRowSuspendedAfterStaleRead(t *testing.T) { now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC) windowStart := now.Add(-24 * time.Hour) current := UserSubscription{ ID: 27, UserID: 31, GroupID: 33, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusSuspended, Notes: "suspended", DailyWindowStart: &windowStart, DailyUsageUSD: 4, } repo := &lockingRenewalRepo{ stale: UserSubscription{ID: 27, UserID: 31, GroupID: 33, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired}, current: current, } svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 33, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil) svc.now = func() time.Time { return now } sub, reused, err := svc.assignSubscriptionWithReuse(context.Background(), &AssignSubscriptionInput{ UserID: 31, GroupID: 33, ValidityDays: 5, Notes: "renewed", }) require.NoError(t, err) require.True(t, reused) require.Equal(t, 1, repo.lockReads) require.Equal(t, current, repo.current) require.Equal(t, SubscriptionStatusSuspended, sub.Status) require.Equal(t, current.ExpiresAt, sub.ExpiresAt) require.Equal(t, current.Notes, sub.Notes) }