subscription_daily_midnight_reset_test.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. package service
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // dailyMidnightResetRepo 记录 ResetDailyUsage 收到的新窗口起点。
  10. type dailyMidnightResetRepo struct {
  11. userSubRepoNoop
  12. resetCalled bool
  13. newWindowStart time.Time
  14. }
  15. func (r *dailyMidnightResetRepo) ResetDailyUsage(_ context.Context, _ int64, _ *time.Time, newWindowStart time.Time) error {
  16. r.resetCalled = true
  17. r.newWindowStart = newWindowStart
  18. return nil
  19. }
  20. // midnightTestBase 返回一个固定日期在配置时区的 0 点,测试用时刻均从它推导,
  21. // 保证断言在任意本地时区下都与生产逻辑同构。
  22. func midnightTestBase() time.Time {
  23. return timezone.StartOfDay(time.Date(2026, 8, 6, 12, 0, 0, 0, timezone.Location()))
  24. }
  25. func newMidnightTestSub(dailyWindowStart time.Time, base time.Time) *UserSubscription {
  26. start := dailyWindowStart
  27. return &UserSubscription{
  28. ID: 1,
  29. UserID: 10,
  30. GroupID: 20,
  31. StartsAt: base.AddDate(0, 0, -3),
  32. ExpiresAt: base.AddDate(0, 0, 30),
  33. DailyUsageUSD: 43.34,
  34. DailyWindowStart: &start,
  35. }
  36. }
  37. // 手动重置(或任何原因)留下的非 0 点锚点,跨 0 点后必须重置,
  38. // 且新窗口起点是当天 0 点,而不是锚点+24h 的滚动时刻。
  39. func TestCheckAndResetWindows_DailyResetsAtMidnightNotRollingAnchor(t *testing.T) {
  40. base := midnightTestBase()
  41. manualResetAt := base.Add(16*time.Hour + 49*time.Minute) // 昨日 16:49 手动重置
  42. now := base.AddDate(0, 0, 1).Add(5 * time.Minute) // 次日 00:05
  43. repo := &dailyMidnightResetRepo{}
  44. svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
  45. svc.now = func() time.Time { return now }
  46. sub := newMidnightTestSub(manualResetAt, base)
  47. require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
  48. require.True(t, repo.resetCalled, "跨 0 点后日窗口必须重置")
  49. require.Equal(t, base.AddDate(0, 0, 1), repo.newWindowStart, "新窗口起点应为当天 0 点")
  50. require.Zero(t, sub.DailyUsageUSD)
  51. require.Equal(t, base.AddDate(0, 0, 1), *sub.DailyWindowStart)
  52. }
  53. // 同一日历日内(即使已过锚点+若干小时)不得重置:手动重置当天剩余时间继续累计用量。
  54. func TestCheckAndResetWindows_DailyNoResetWithinSameCalendarDay(t *testing.T) {
  55. base := midnightTestBase()
  56. manualResetAt := base.Add(16*time.Hour + 49*time.Minute)
  57. now := base.Add(23*time.Hour + 59*time.Minute)
  58. repo := &dailyMidnightResetRepo{}
  59. svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
  60. svc.now = func() time.Time { return now }
  61. sub := newMidnightTestSub(manualResetAt, base)
  62. require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
  63. require.False(t, repo.resetCalled, "同一日历日内不应重置日窗口")
  64. require.Equal(t, 43.34, sub.DailyUsageUSD)
  65. }
  66. // 0.1.170/171 期间产生的滚动锚点(如多日前的 17:18)自愈:下一次维护即拉回今天 0 点。
  67. func TestCheckAndResetWindows_LegacyRollingAnchorHealsToMidnight(t *testing.T) {
  68. base := midnightTestBase()
  69. staleAnchor := base.AddDate(0, 0, -3).Add(17*time.Hour + 18*time.Minute)
  70. now := base.Add(10 * time.Hour)
  71. repo := &dailyMidnightResetRepo{}
  72. svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
  73. svc.now = func() time.Time { return now }
  74. sub := newMidnightTestSub(staleAnchor, base)
  75. require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
  76. require.True(t, repo.resetCalled)
  77. require.Equal(t, base, repo.newWindowStart, "滚动锚点应被拉回今天 0 点,而非按 17:18 步进")
  78. }
  79. // 手动重置写入的 0 点锚点不会改变刷新节奏:次日 0 点照常需要重置。
  80. func TestNeedsDailyReset_MidnightScheduleSurvivesManualReset(t *testing.T) {
  81. base := midnightTestBase()
  82. sub := newMidnightTestSub(base, base) // 手动重置后锚点=当天 0 点
  83. require.False(t, sub.NeedsDailyResetAt(base.Add(23*time.Hour+54*time.Minute)), "当天 23:54 不应重置")
  84. require.True(t, sub.NeedsDailyResetAt(base.AddDate(0, 0, 1).Add(time.Minute)), "次日 00:01 应重置")
  85. }
  86. // 多日订阅的日窗口展示的下次刷新时间固定为次日 0 点(截图中「X 小时后重置」的数据源)。
  87. func TestDailyResetTime_NextMidnightForMultiDaySubscription(t *testing.T) {
  88. base := midnightTestBase()
  89. // 0 点锚点 → 次日 0 点
  90. sub := newMidnightTestSub(base, base)
  91. resetAt := sub.DailyResetTime()
  92. require.NotNil(t, resetAt)
  93. require.Equal(t, base.AddDate(0, 0, 1), *resetAt)
  94. // 非 0 点滚动锚点 → 仍是其所在日的次日 0 点,而非锚点+24h
  95. rolling := newMidnightTestSub(base.Add(16*time.Hour+49*time.Minute), base)
  96. resetAt = rolling.DailyResetTime()
  97. require.NotNil(t, resetAt)
  98. require.Equal(t, base.AddDate(0, 0, 1), *resetAt)
  99. }
  100. // 截图场景:跨 0 点后(后台尚未收到请求推进窗口)列表展示即应清零日用量。
  101. func TestNormalizeExpiredWindows_DailyUsageClearsAfterMidnight(t *testing.T) {
  102. base := midnightTestBase()
  103. manualResetAt := base.Add(16*time.Hour + 49*time.Minute)
  104. now := base.AddDate(0, 0, 1).Add(time.Minute) // 次日 00:01
  105. subs := []UserSubscription{*newMidnightTestSub(manualResetAt, base)}
  106. normalizeExpiredWindowsAt(subs, now)
  107. require.Zero(t, subs[0].DailyUsageUSD, "跨 0 点后展示的日用量应清零")
  108. require.Nil(t, subs[0].DailyWindowStart)
  109. }
  110. // 日卡(一次性日额度)不受 0 点语义影响:跨 0 点不重置。
  111. func TestCheckAndResetWindows_OneTimeDailyCardStillExemptFromMidnightReset(t *testing.T) {
  112. base := midnightTestBase()
  113. startsAt := base.Add(17 * time.Hour)
  114. anchor := base
  115. now := base.AddDate(0, 0, 1).Add(2 * time.Hour)
  116. repo := &dailyMidnightResetRepo{}
  117. svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
  118. svc.now = func() time.Time { return now }
  119. sub := &UserSubscription{
  120. ID: 1,
  121. UserID: 10,
  122. GroupID: 20,
  123. StartsAt: startsAt,
  124. ExpiresAt: startsAt.AddDate(0, 0, 1),
  125. DailyUsageUSD: 10,
  126. DailyWindowStart: &anchor,
  127. }
  128. require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
  129. require.False(t, repo.resetCalled, "日卡为一次性配额,跨 0 点不应重置")
  130. require.Equal(t, 10.0, sub.DailyUsageUSD)
  131. }