openai_quota_reset_credits_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t *testing.T) {
  10. body := []byte(`{
  11. "availableCount":"2",
  12. "credits":[
  13. {"reset_type":"codex_rate_limits","status":"redeemed","expires_at":"2026-07-01T04:05:06Z"},
  14. {"id":"credit-later","reset_type":"codex_rate_limits","status":"available","expires_at":"2026-07-04T04:05:06Z"},
  15. {"creditId":"credit-earlier","resetType":"codex_rate_limits","status":"available","expiresAt":"2026-07-03T04:05:06Z"},
  16. {"reset_type":"other","status":"available","expires_at":"2026-07-02T04:05:06Z"}
  17. ]
  18. }`)
  19. details, err := parseOpenAIRateLimitResetCreditDetails(body)
  20. require.NoError(t, err)
  21. require.NotNil(t, details.AvailableCount)
  22. require.Equal(t, 2, *details.AvailableCount)
  23. require.Equal(t, []OpenAIRateLimitResetCreditDetail{
  24. {ExpiresAt: "2026-07-04T04:05:06Z"},
  25. {ExpiresAt: "2026-07-03T04:05:06Z"},
  26. }, details.Credits)
  27. require.Equal(t, []openAIAutoResetCreditCandidate{
  28. {ID: "credit-later", ExpiresAt: "2026-07-04T04:05:06Z"},
  29. {ID: "credit-earlier", ExpiresAt: "2026-07-03T04:05:06Z"},
  30. }, details.AutoResetCandidates)
  31. }
  32. func TestQueryUsageResetCreditCountPrecedence(t *testing.T) {
  33. tests := []struct {
  34. name string
  35. usageBody string
  36. detailBody string
  37. wantCount int
  38. wantCredits int
  39. wantNil bool
  40. }{
  41. {
  42. name: "detail count creates missing usage credits",
  43. usageBody: `{}`,
  44. detailBody: `{"available_count":3,"credits":[{"expires_at":"2026-07-03T04:05:06Z"}]}`,
  45. wantCount: 3, wantCredits: 1,
  46. },
  47. {
  48. name: "explicit detail zero overrides usage and records",
  49. usageBody: `{"rate_limit_reset_credits":{"available_count":4}}`,
  50. detailBody: `{"available_count":0,"credits":[{"expires_at":"2026-07-03T04:05:06Z"}]}`,
  51. wantCount: 0, wantCredits: 1,
  52. },
  53. {
  54. name: "available records override usage when detail count is absent",
  55. usageBody: `{"rate_limit_reset_credits":{"available_count":7}}`,
  56. detailBody: `{"credits":[{"expires_at":"2026-07-03T04:05:06Z"},{"expiresAt":"2026-07-04T04:05:06Z"}]}`,
  57. wantCount: 2, wantCredits: 2,
  58. },
  59. {
  60. name: "empty detail list overrides usage with zero",
  61. usageBody: `{"rate_limit_reset_credits":{"available_count":7}}`,
  62. detailBody: `{"credits":[]}`,
  63. wantCount: 0,
  64. },
  65. {
  66. name: "fully filtered list overrides usage with zero",
  67. usageBody: `{"rate_limit_reset_credits":{"available_count":7}}`,
  68. detailBody: `{"credits":[{"reset_type":"codex_rate_limits","status":"redeemed","expires_at":"2026-07-03T04:05:06Z"},{"reset_type":"other","status":"available","expires_at":"2026-07-04T04:05:06Z"}]}`,
  69. wantCount: 0,
  70. },
  71. {
  72. name: "available records without expiry still count",
  73. usageBody: `{"rate_limit_reset_credits":{"available_count":7}}`,
  74. detailBody: `{"credits":[{"status":"available"},{"status":"available","expires_at":"2026-07-04T04:05:06Z"}]}`,
  75. wantCount: 2, wantCredits: 1,
  76. },
  77. {
  78. name: "shape without count or list preserves usage details",
  79. usageBody: `{"rate_limit_reset_credits":{"available_count":5,"credits":[{"expires_at":"usage-expiry"}]}}`,
  80. detailBody: `{}`,
  81. wantCount: 5,
  82. wantCredits: 1,
  83. },
  84. {
  85. name: "valid detail count survives malformed authoritative list",
  86. usageBody: `{"rate_limit_reset_credits":{"available_count":7,"credits":[{"expires_at":"usage-expiry"}]}}`,
  87. detailBody: `{"available_count":2,"credits":"malformed"}`,
  88. wantCount: 2,
  89. wantCredits: 1,
  90. },
  91. {
  92. name: "valid detail count creates quota despite malformed authoritative list",
  93. usageBody: `{}`,
  94. detailBody: `{"available_count":2,"credits":"malformed"}`,
  95. wantCount: 2,
  96. },
  97. {
  98. name: "negative detail count without list preserves usage",
  99. usageBody: `{"rate_limit_reset_credits":{"available_count":4}}`,
  100. detailBody: `{"available_count":-1}`,
  101. wantCount: 4,
  102. },
  103. {
  104. name: "negative detail count falls back to available records",
  105. usageBody: `{"rate_limit_reset_credits":{"available_count":4}}`,
  106. detailBody: `{"available_count":-1,"credits":[{"status":"available","expires_at":"2026-07-04T04:05:06Z"}]}`,
  107. wantCount: 1, wantCredits: 1,
  108. },
  109. {
  110. name: "empty object preserves missing usage credits",
  111. usageBody: `{}`,
  112. detailBody: `{}`,
  113. wantNil: true,
  114. },
  115. {
  116. name: "null body preserves missing usage credits",
  117. usageBody: `{}`,
  118. detailBody: `null`,
  119. wantNil: true,
  120. },
  121. {
  122. name: "empty body preserves missing usage credits",
  123. usageBody: `{}`,
  124. detailBody: ``,
  125. wantNil: true,
  126. },
  127. {
  128. name: "null object record is not counted",
  129. usageBody: `{"rate_limit_reset_credits":{"available_count":7}}`,
  130. detailBody: `{"credits":[null]}`,
  131. wantCount: 0,
  132. },
  133. {
  134. name: "null top level record is not counted",
  135. usageBody: `{"rate_limit_reset_credits":{"available_count":7}}`,
  136. detailBody: `[null]`,
  137. wantCount: 0,
  138. },
  139. }
  140. for _, tt := range tests {
  141. t.Run(tt.name, func(t *testing.T) {
  142. account := &Account{
  143. ID: 100,
  144. Platform: PlatformOpenAI,
  145. Type: AccountTypeOAuth,
  146. Status: StatusActive,
  147. Credentials: map[string]any{
  148. "chatgpt_account_id": "org-parent123",
  149. },
  150. }
  151. repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{100: account}}
  152. tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
  153. OpenAITokenCacheKey(account): "fake-token",
  154. }}
  155. tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
  156. var detailCalls int
  157. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  158. w.Header().Set("content-type", "application/json")
  159. switch r.URL.Path {
  160. case "/backend-api/wham/usage":
  161. _, _ = w.Write([]byte(tt.usageBody))
  162. case "/backend-api/wham/rate-limit-reset-credits":
  163. detailCalls++
  164. _, _ = w.Write([]byte(tt.detailBody))
  165. default:
  166. http.NotFound(w, r)
  167. }
  168. }))
  169. defer srv.Close()
  170. svc := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
  171. usage, err := svc.QueryUsage(context.Background(), 100)
  172. require.NoError(t, err)
  173. require.NotNil(t, usage)
  174. require.Equal(t, 1, detailCalls)
  175. if tt.wantNil {
  176. require.Nil(t, usage.RateLimitResetCredits)
  177. return
  178. }
  179. require.NotNil(t, usage.RateLimitResetCredits)
  180. require.Equal(t, tt.wantCount, usage.RateLimitResetCredits.AvailableCount)
  181. require.Len(t, usage.RateLimitResetCredits.Credits, tt.wantCredits)
  182. })
  183. }
  184. }