subscription_renewal_lock_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. package service
  2. import (
  3. "context"
  4. "sync"
  5. "testing"
  6. "time"
  7. "github.com/stretchr/testify/require"
  8. )
  9. type lockingRenewalRepo struct {
  10. userSubRepoNoop
  11. mu sync.Mutex
  12. stale UserSubscription
  13. current UserSubscription
  14. lockReads int
  15. }
  16. func (r *lockingRenewalRepo) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
  17. return true, nil
  18. }
  19. func (r *lockingRenewalRepo) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
  20. copy := r.stale
  21. return &copy, nil
  22. }
  23. func (r *lockingRenewalRepo) GetByID(_ context.Context, _ int64) (*UserSubscription, error) {
  24. r.mu.Lock()
  25. defer r.mu.Unlock()
  26. copy := r.current
  27. return &copy, nil
  28. }
  29. func (r *lockingRenewalRepo) GetByIDForUpdate(_ context.Context, _ int64) (*UserSubscription, error) {
  30. r.mu.Lock()
  31. defer r.mu.Unlock()
  32. r.lockReads++
  33. copy := r.current
  34. return &copy, nil
  35. }
  36. func (r *lockingRenewalRepo) ExtendExpiry(_ context.Context, _ int64, expiresAt time.Time) error {
  37. r.mu.Lock()
  38. defer r.mu.Unlock()
  39. r.current.ExpiresAt = expiresAt
  40. return nil
  41. }
  42. func (r *lockingRenewalRepo) UpdateStatus(_ context.Context, _ int64, status string) error {
  43. r.mu.Lock()
  44. defer r.mu.Unlock()
  45. r.current.Status = status
  46. return nil
  47. }
  48. func (r *lockingRenewalRepo) UpdateNotes(_ context.Context, _ int64, notes string) error {
  49. r.mu.Lock()
  50. defer r.mu.Unlock()
  51. r.current.Notes = notes
  52. return nil
  53. }
  54. func (r *lockingRenewalRepo) Update(_ context.Context, sub *UserSubscription) error {
  55. r.mu.Lock()
  56. defer r.mu.Unlock()
  57. r.current = *sub
  58. return nil
  59. }
  60. func TestAssignOrExtendSubscriptionUsesLockedCurrentRow(t *testing.T) {
  61. now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
  62. lockedExpiry := now.AddDate(0, 0, 20)
  63. windowStart := now.Add(-24 * time.Hour)
  64. repo := &lockingRenewalRepo{
  65. stale: UserSubscription{ID: 7, UserID: 11, GroupID: 13, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired, Notes: "stale"},
  66. current: UserSubscription{
  67. ID: 7, UserID: 11, GroupID: 13, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: lockedExpiry,
  68. Status: SubscriptionStatusSuspended, Notes: "current", DailyWindowStart: &windowStart, DailyUsageUSD: 4,
  69. },
  70. }
  71. svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 13, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil)
  72. svc.now = func() time.Time { return now }
  73. sub, extended, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{
  74. UserID: 11, GroupID: 13, ValidityDays: 5, Notes: "renewed",
  75. })
  76. require.NoError(t, err)
  77. require.True(t, extended)
  78. require.Equal(t, 1, repo.lockReads)
  79. require.Equal(t, lockedExpiry.AddDate(0, 0, 5), sub.ExpiresAt)
  80. require.Equal(t, SubscriptionStatusActive, sub.Status)
  81. require.Equal(t, "current\nrenewed", sub.Notes)
  82. require.Equal(t, windowStart, *sub.DailyWindowStart)
  83. require.Equal(t, float64(4), sub.DailyUsageUSD)
  84. }
  85. func TestAssignOrExtendSubscriptionSerializedRenewalsAccumulateDays(t *testing.T) {
  86. now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
  87. initialExpiry := now.AddDate(0, 0, 10)
  88. stale := UserSubscription{ID: 17, UserID: 21, GroupID: 23, StartsAt: now, ExpiresAt: initialExpiry, Status: SubscriptionStatusActive}
  89. repo := &lockingRenewalRepo{stale: stale, current: stale}
  90. svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 23, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil)
  91. svc.now = func() time.Time { return now }
  92. input := &AssignSubscriptionInput{UserID: 21, GroupID: 23, ValidityDays: 7}
  93. _, _, err := svc.AssignOrExtendSubscription(context.Background(), input)
  94. require.NoError(t, err)
  95. second, _, err := svc.AssignOrExtendSubscription(context.Background(), input)
  96. require.NoError(t, err)
  97. require.Equal(t, 2, repo.lockReads)
  98. require.Equal(t, initialExpiry.AddDate(0, 0, 14), second.ExpiresAt)
  99. }
  100. func TestAssignSubscriptionDoesNotReactivateRowSuspendedAfterStaleRead(t *testing.T) {
  101. now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
  102. windowStart := now.Add(-24 * time.Hour)
  103. current := UserSubscription{
  104. ID: 27, UserID: 31, GroupID: 33, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: now.Add(-time.Hour),
  105. Status: SubscriptionStatusSuspended, Notes: "suspended", DailyWindowStart: &windowStart, DailyUsageUSD: 4,
  106. }
  107. repo := &lockingRenewalRepo{
  108. stale: UserSubscription{ID: 27, UserID: 31, GroupID: 33, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired},
  109. current: current,
  110. }
  111. svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 33, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil)
  112. svc.now = func() time.Time { return now }
  113. sub, reused, err := svc.assignSubscriptionWithReuse(context.Background(), &AssignSubscriptionInput{
  114. UserID: 31, GroupID: 33, ValidityDays: 5, Notes: "renewed",
  115. })
  116. require.NoError(t, err)
  117. require.True(t, reused)
  118. require.Equal(t, 1, repo.lockReads)
  119. require.Equal(t, current, repo.current)
  120. require.Equal(t, SubscriptionStatusSuspended, sub.Status)
  121. require.Equal(t, current.ExpiresAt, sub.ExpiresAt)
  122. require.Equal(t, current.Notes, sub.Notes)
  123. }