user_subscription_daily_quota_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  8. "github.com/stretchr/testify/require"
  9. )
  10. type dailyResetTrackingUserSubRepo struct {
  11. userSubRepoNoop
  12. resetDailyCalled bool
  13. }
  14. func (r *dailyResetTrackingUserSubRepo) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error {
  15. r.resetDailyCalled = true
  16. return nil
  17. }
  18. func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *testing.T) {
  19. groupRepo := &subscriptionGroupRepoStub{
  20. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  21. }
  22. subRepo := newSubscriptionUserSubRepoStub()
  23. oldStart := time.Now().AddDate(0, 0, -3)
  24. oldWindowStart := startOfDay(oldStart)
  25. subRepo.seed(&UserSubscription{
  26. ID: 100,
  27. UserID: 200,
  28. GroupID: 1,
  29. StartsAt: oldStart,
  30. ExpiresAt: oldStart.AddDate(0, 0, 1),
  31. Status: SubscriptionStatusExpired,
  32. DailyWindowStart: &oldWindowStart,
  33. WeeklyWindowStart: &oldWindowStart,
  34. MonthlyWindowStart: &oldWindowStart,
  35. DailyUsageUSD: 10,
  36. WeeklyUsageUSD: 20,
  37. MonthlyUsageUSD: 30,
  38. Notes: "old",
  39. })
  40. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  41. renewed, reused, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{
  42. UserID: 200,
  43. GroupID: 1,
  44. ValidityDays: 1,
  45. Notes: "new",
  46. })
  47. require.NoError(t, err)
  48. require.True(t, reused)
  49. require.True(t, renewed.HasOneTimeDailyQuota(), "过期后重新购买 1 日卡仍应被识别为一次性日额度")
  50. require.Equal(t, SubscriptionStatusActive, renewed.Status)
  51. require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt")
  52. require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1)))
  53. require.NotNil(t, renewed.DailyWindowStart)
  54. require.Equal(t, timezone.StartOfDay(renewed.StartsAt), *renewed.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
  55. require.Equal(t, 0.0, renewed.DailyUsageUSD)
  56. require.Equal(t, 0.0, renewed.WeeklyUsageUSD)
  57. require.Equal(t, 0.0, renewed.MonthlyUsageUSD)
  58. require.Equal(t, "old\nnew", renewed.Notes)
  59. }
  60. func TestAssignOrExtendSubscription_ExpiredSubscriptionAppendsMatchingNotes(t *testing.T) {
  61. groupRepo := &subscriptionGroupRepoStub{
  62. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  63. }
  64. subRepo := newSubscriptionUserSubRepoStub()
  65. oldStart := time.Now().AddDate(0, 0, -3)
  66. subRepo.seed(&UserSubscription{
  67. ID: 101,
  68. UserID: 201,
  69. GroupID: 1,
  70. StartsAt: oldStart,
  71. ExpiresAt: oldStart.AddDate(0, 0, 1),
  72. Status: SubscriptionStatusExpired,
  73. Notes: "same",
  74. })
  75. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  76. renewed, reused, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{
  77. UserID: 201,
  78. GroupID: 1,
  79. ValidityDays: 1,
  80. Notes: "same",
  81. })
  82. require.NoError(t, err)
  83. require.True(t, reused)
  84. require.Equal(t, "same\nsame", renewed.Notes)
  85. }
  86. func TestUserSubscriptionNeedsDailyReset_DailyCardKeepsOneTimeQuota(t *testing.T) {
  87. start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
  88. dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)
  89. sub := &UserSubscription{
  90. StartsAt: start,
  91. ExpiresAt: start.Add(24 * time.Hour),
  92. DailyWindowStart: &dailyWindowStart,
  93. DailyUsageUSD: 10,
  94. }
  95. require.True(t, sub.HasOneTimeDailyQuota())
  96. require.False(t, sub.NeedsDailyResetAt(dailyWindowStart.Add(25*time.Hour)), "日卡应作为一次性配额,跨 0 点后不再刷新日额度")
  97. }
  98. func TestUserSubscriptionNeedsDailyReset_MultiDaySubscriptionStillRefreshes(t *testing.T) {
  99. start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
  100. dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)
  101. sub := &UserSubscription{
  102. StartsAt: start,
  103. ExpiresAt: start.AddDate(0, 0, 2),
  104. DailyWindowStart: &dailyWindowStart,
  105. }
  106. require.False(t, sub.HasOneTimeDailyQuota())
  107. require.True(t, sub.NeedsDailyResetAt(dailyWindowStart.Add(24*time.Hour)), "多日订阅仍应按 24 小时日窗口刷新")
  108. }
  109. func TestUserSubscriptionDailyResetTime_DailyCardReturnsExpiry(t *testing.T) {
  110. start := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
  111. dailyWindowStart := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)
  112. expiresAt := start.Add(24 * time.Hour)
  113. sub := &UserSubscription{
  114. StartsAt: start,
  115. ExpiresAt: expiresAt,
  116. DailyWindowStart: &dailyWindowStart,
  117. }
  118. resetAt := sub.DailyResetTime()
  119. require.NotNil(t, resetAt)
  120. require.Equal(t, expiresAt, *resetAt, "日卡展示的日额度结束时间应为订阅过期时间")
  121. }
  122. func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) {
  123. now := time.Now()
  124. startsAt := now.Add(-23 * time.Hour)
  125. dailyWindowStart := now.Add(-25 * time.Hour)
  126. repo := &dailyResetTrackingUserSubRepo{}
  127. svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
  128. sub := &UserSubscription{
  129. ID: 1,
  130. UserID: 10,
  131. GroupID: 20,
  132. StartsAt: startsAt,
  133. ExpiresAt: startsAt.Add(24 * time.Hour),
  134. DailyUsageUSD: 10,
  135. DailyWindowStart: &dailyWindowStart,
  136. }
  137. err := svc.CheckAndResetWindows(context.Background(), sub)
  138. require.NoError(t, err)
  139. require.False(t, repo.resetDailyCalled, "日卡作为一次性配额,过了 24 小时日窗口也不应重置 daily usage")
  140. require.Equal(t, 10.0, sub.DailyUsageUSD)
  141. }
  142. func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) {
  143. now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
  144. startsAt := now.Add(-48 * time.Hour)
  145. dailyWindowStart := now.Add(-25 * time.Hour)
  146. repo := &dailyResetTrackingUserSubRepo{}
  147. svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
  148. svc.now = func() time.Time { return now }
  149. sub := &UserSubscription{
  150. ID: 1,
  151. UserID: 10,
  152. GroupID: 20,
  153. StartsAt: startsAt,
  154. ExpiresAt: startsAt.AddDate(0, 0, 4),
  155. DailyUsageUSD: 10,
  156. DailyWindowStart: &dailyWindowStart,
  157. }
  158. err := svc.CheckAndResetWindows(context.Background(), sub)
  159. require.NoError(t, err)
  160. require.True(t, repo.resetDailyCalled, "多日订阅仍应重置过期 daily window")
  161. require.Equal(t, 0.0, sub.DailyUsageUSD)
  162. }
  163. func TestValidateAndCheckLimits_DailyCardDoesNotAllowSecondQuotaAfterMidnight(t *testing.T) {
  164. start := time.Now().Add(-23 * time.Hour)
  165. dailyWindowStart := time.Now().Add(-25 * time.Hour)
  166. dailyLimit := 10.0
  167. sub := &UserSubscription{
  168. Status: SubscriptionStatusActive,
  169. StartsAt: start,
  170. ExpiresAt: start.Add(24 * time.Hour),
  171. DailyWindowStart: &dailyWindowStart,
  172. DailyUsageUSD: dailyLimit + 0.01,
  173. }
  174. group := &Group{
  175. SubscriptionType: SubscriptionTypeSubscription,
  176. DailyLimitUSD: &dailyLimit,
  177. }
  178. svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
  179. needsMaintenance, err := svc.ValidateAndCheckLimits(sub, group)
  180. require.False(t, needsMaintenance, "日卡跨过日窗口后不应触发 daily reset 维护")
  181. require.True(t, errors.Is(err, ErrDailyLimitExceeded))
  182. require.Equal(t, dailyLimit+0.01, sub.DailyUsageUSD, "热路径不应清零日卡已用额度")
  183. }