user_platform_quotas_handler_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. //go:build unit
  2. package handler
  3. import (
  4. "context"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  13. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/gin-gonic/gin"
  16. )
  17. // fakeQuotaRepoForUserHandler 实现 service.UserPlatformQuotaRepository 最小子集
  18. type fakeQuotaRepoForUserHandler struct {
  19. service.UserPlatformQuotaRepository
  20. records []service.UserPlatformQuotaRecord
  21. }
  22. func (f *fakeQuotaRepoForUserHandler) ListByUser(_ context.Context, _ int64) ([]service.UserPlatformQuotaRecord, error) {
  23. return f.records, nil
  24. }
  25. func TestGetMyPlatformQuotas_EmptyReturns200WithEmptyArray(t *testing.T) {
  26. repo := &fakeQuotaRepoForUserHandler{records: nil}
  27. h := &UserHandler{userPlatformQuotaRepo: repo}
  28. gin.SetMode(gin.TestMode)
  29. w := httptest.NewRecorder()
  30. c, _ := gin.CreateTestContext(w)
  31. c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
  32. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42})
  33. h.GetMyPlatformQuotas(c)
  34. if w.Code != 200 {
  35. t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
  36. }
  37. var body struct {
  38. Code int `json:"code"`
  39. Data struct {
  40. PlatformQuotas []any `json:"platform_quotas"`
  41. } `json:"data"`
  42. }
  43. if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
  44. t.Fatalf("unmarshal error: %v, body: %s", err, w.Body.String())
  45. }
  46. if body.Code != 0 {
  47. t.Errorf("expected code=0, got %d", body.Code)
  48. }
  49. if body.Data.PlatformQuotas == nil {
  50. // nil 和 empty slice 均视为可接受(JSON 可能序列化为 null 或 [])
  51. // 此断言只验证 HTTP 200 + code=0 即可
  52. }
  53. }
  54. func TestGetMyPlatformQuotas_D14_LazyZeroForExpiredWindow(t *testing.T) {
  55. pastStart := time.Now().UTC().AddDate(0, 0, -2)
  56. daily := 5.0
  57. repo := &fakeQuotaRepoForUserHandler{records: []service.UserPlatformQuotaRecord{{
  58. UserID: 42,
  59. Platform: "anthropic",
  60. DailyLimitUSD: &daily,
  61. DailyUsageUSD: 3.0,
  62. DailyWindowStart: &pastStart,
  63. }}}
  64. h := &UserHandler{userPlatformQuotaRepo: repo}
  65. gin.SetMode(gin.TestMode)
  66. w := httptest.NewRecorder()
  67. c, _ := gin.CreateTestContext(w)
  68. c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
  69. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42})
  70. h.GetMyPlatformQuotas(c)
  71. if w.Code != 200 {
  72. t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
  73. }
  74. // 解析 response,验证过期 daily 的 usage_usd=0 且 window_resets_at=null
  75. body := w.Body.String()
  76. if !strings.Contains(body, `"daily_usage_usd":0`) {
  77. t.Errorf("expected daily_usage_usd:0 in body, got: %s", body)
  78. }
  79. if !strings.Contains(body, `"daily_window_resets_at":null`) {
  80. t.Errorf("expected daily_window_resets_at:null in body, got: %s", body)
  81. }
  82. }
  83. func TestGetMyPlatformQuotas_NilRepo_Returns200Empty(t *testing.T) {
  84. h := &UserHandler{userPlatformQuotaRepo: nil}
  85. gin.SetMode(gin.TestMode)
  86. w := httptest.NewRecorder()
  87. c, _ := gin.CreateTestContext(w)
  88. c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
  89. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 99})
  90. h.GetMyPlatformQuotas(c)
  91. if w.Code != 200 {
  92. t.Fatalf("expected 200, got %d", w.Code)
  93. }
  94. }
  95. func TestGetMyPlatformQuotas_NoAuth_Returns401(t *testing.T) {
  96. h := &UserHandler{userPlatformQuotaRepo: nil}
  97. gin.SetMode(gin.TestMode)
  98. w := httptest.NewRecorder()
  99. c, _ := gin.CreateTestContext(w)
  100. c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
  101. // 不设置 auth subject
  102. h.GetMyPlatformQuotas(c)
  103. if w.Code != 401 {
  104. t.Fatalf("expected 401, got %d", w.Code)
  105. }
  106. }
  107. func TestLazyZeroQuotaForResponse_UserViewStripsWindowStart(t *testing.T) {
  108. start := time.Now().UTC().Add(-1 * time.Hour)
  109. r := service.UserPlatformQuotaRecord{
  110. Platform: "anthropic",
  111. DailyUsageUSD: 1.0,
  112. DailyWindowStart: &start,
  113. }
  114. out := quotaview.LazyZeroQuotaForResponse(r, time.Now().UTC(), false)
  115. if _, ok := out["daily_window_start"]; ok {
  116. t.Error("user view should not include daily_window_start")
  117. }
  118. }
  119. func TestLazyZeroQuotaForResponse_AdminViewIncludesWindowStart(t *testing.T) {
  120. start := time.Now().UTC().Add(-1 * time.Hour)
  121. r := service.UserPlatformQuotaRecord{
  122. Platform: "anthropic",
  123. DailyWindowStart: &start,
  124. }
  125. out := quotaview.LazyZeroQuotaForResponse(r, time.Now().UTC(), true)
  126. if _, ok := out["daily_window_start"]; !ok {
  127. t.Error("admin view should include daily_window_start")
  128. }
  129. }
  130. func TestLazyZeroQuotaForResponse_ActiveWindowPreservesUsage(t *testing.T) {
  131. // 今天的窗口起始时间(不过期):按全局时区取当天 0 点,与 view 层同口径
  132. now := time.Now()
  133. today := timezone.StartOfDay(now)
  134. usage := 2.5
  135. r := service.UserPlatformQuotaRecord{
  136. Platform: "openai",
  137. DailyUsageUSD: usage,
  138. DailyWindowStart: &today,
  139. }
  140. out := quotaview.LazyZeroQuotaForResponse(r, now, false)
  141. if out["daily_usage_usd"] != usage {
  142. t.Errorf("expected daily_usage_usd=%v, got %v", usage, out["daily_usage_usd"])
  143. }
  144. // 活跃窗口应有 resets_at(非 nil)
  145. if out["daily_window_resets_at"] == nil {
  146. t.Error("active window should have daily_window_resets_at set")
  147. }
  148. }
  149. func TestNeedsDailyReset_NilStart_ReturnsFalse(t *testing.T) {
  150. if quotaview.NeedsDailyReset(nil, time.Now().UTC()) {
  151. t.Error("nil start should not need reset")
  152. }
  153. }
  154. func TestNeedsDailyReset_OldStart_ReturnsTrue(t *testing.T) {
  155. old := time.Now().UTC().AddDate(0, 0, -1)
  156. if !quotaview.NeedsDailyReset(&old, time.Now().UTC()) {
  157. t.Error("yesterday start should need daily reset")
  158. }
  159. }
  160. func TestNeedsWeeklyReset_NilStart_ReturnsFalse(t *testing.T) {
  161. if quotaview.NeedsWeeklyReset(nil, time.Now().UTC()) {
  162. t.Error("nil start should not need weekly reset")
  163. }
  164. }
  165. func TestNeedsMonthlyReset_NilStart_ReturnsFalse(t *testing.T) {
  166. if quotaview.NeedsMonthlyReset(nil, time.Now().UTC()) {
  167. t.Error("nil start should not need monthly reset")
  168. }
  169. }
  170. // TestNeedsMonthlyReset_30DayRolling 验证 30 天滚动语义(C-NEW-1)。
  171. func TestNeedsMonthlyReset_30DayRolling_Expired(t *testing.T) {
  172. start := time.Now().UTC().Add(-31 * 24 * time.Hour) // 31 天前,已过期
  173. if !quotaview.NeedsMonthlyReset(&start, time.Now().UTC()) {
  174. t.Error("31 days ago should need monthly reset (30-day rolling)")
  175. }
  176. }
  177. func TestNeedsMonthlyReset_30DayRolling_Active(t *testing.T) {
  178. start := time.Now().UTC().Add(-15 * 24 * time.Hour) // 15 天前,窗口有效
  179. if quotaview.NeedsMonthlyReset(&start, time.Now().UTC()) {
  180. t.Error("15 days ago should NOT need monthly reset (30-day rolling, still active)")
  181. }
  182. }
  183. // TestNeedsMonthlyReset_CrossMonthBoundary 验证跨自然月时 30 天未满不重置(旧自然月语义会提前重置)。
  184. func TestNeedsMonthlyReset_CrossMonthBoundary(t *testing.T) {
  185. // 窗口起始 4 月 20 日;5 月 1 日仅过了 11 天,不足 30 天,不应重置
  186. windowStart := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
  187. now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
  188. if quotaview.NeedsMonthlyReset(&windowStart, now) {
  189. t.Error("cross-month boundary within 30 days should NOT trigger reset (30-day rolling)")
  190. }
  191. }