account_usage_service_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "testing"
  6. "time"
  7. )
  8. type accountUsageCodexProbeRepo struct {
  9. stubOpenAIAccountRepo
  10. updateExtraCh chan map[string]any
  11. rateLimitCh chan time.Time
  12. }
  13. func (r *accountUsageCodexProbeRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
  14. if r.updateExtraCh != nil {
  15. copied := make(map[string]any, len(updates))
  16. for k, v := range updates {
  17. copied[k] = v
  18. }
  19. r.updateExtraCh <- copied
  20. }
  21. return nil
  22. }
  23. func (r *accountUsageCodexProbeRepo) SetRateLimited(_ context.Context, _ int64, resetAt time.Time) error {
  24. if r.rateLimitCh != nil {
  25. r.rateLimitCh <- resetAt
  26. }
  27. return nil
  28. }
  29. func TestShouldRefreshOpenAICodexSnapshot(t *testing.T) {
  30. t.Parallel()
  31. rateLimitedUntil := time.Now().Add(5 * time.Minute)
  32. now := time.Now()
  33. usage := &UsageInfo{
  34. FiveHour: &UsageProgress{Utilization: 0},
  35. SevenDay: &UsageProgress{Utilization: 0},
  36. }
  37. if !shouldRefreshOpenAICodexSnapshot(&Account{RateLimitResetAt: &rateLimitedUntil}, usage, now) {
  38. t.Fatal("expected rate-limited account to force codex snapshot refresh")
  39. }
  40. if shouldRefreshOpenAICodexSnapshot(&Account{}, usage, now) {
  41. t.Fatal("expected complete non-rate-limited usage to skip codex snapshot refresh")
  42. }
  43. if !shouldRefreshOpenAICodexSnapshot(&Account{}, &UsageInfo{FiveHour: nil, SevenDay: &UsageProgress{}}, now) {
  44. t.Fatal("expected missing 5h snapshot to require refresh")
  45. }
  46. staleAt := now.Add(-(openAIProbeCacheTTL + time.Minute)).Format(time.RFC3339)
  47. if !shouldRefreshOpenAICodexSnapshot(&Account{
  48. Platform: PlatformOpenAI,
  49. Type: AccountTypeOAuth,
  50. Extra: map[string]any{
  51. "openai_oauth_responses_websockets_v2_enabled": true,
  52. "codex_usage_updated_at": staleAt,
  53. },
  54. }, usage, now) {
  55. t.Fatal("expected stale ws snapshot to trigger refresh")
  56. }
  57. }
  58. // TestShouldRefreshOpenAICodexSnapshot_SparkShadowIgnoresWSv2 外审第9轮 P1:spark 影子用量走
  59. // QueryUsage(/wham/usage,与 WSv2 无关),staleness 不得被 WSv2 门控,否则首刷后窗口永久冻结。
  60. func TestShouldRefreshOpenAICodexSnapshot_SparkShadowIgnoresWSv2(t *testing.T) {
  61. t.Parallel()
  62. now := time.Now()
  63. usage := &UsageInfo{
  64. FiveHour: &UsageProgress{Utilization: 0},
  65. SevenDay: &UsageProgress{Utilization: 0},
  66. }
  67. staleAt := now.Add(-(openAIProbeCacheTTL + time.Minute)).Format(time.RFC3339)
  68. freshAt := now.Add(-time.Minute).Format(time.RFC3339)
  69. parentID := int64(7001)
  70. // 影子无 WSv2,但首刷后窗口已存在;过期 codex_usage_updated_at 必须触发再刷新。
  71. shadowStale := &Account{
  72. Platform: PlatformOpenAI,
  73. Type: AccountTypeOAuth,
  74. ParentAccountID: &parentID,
  75. QuotaDimension: QuotaDimensionSpark,
  76. Extra: map[string]any{"codex_usage_updated_at": staleAt},
  77. }
  78. if !shouldRefreshOpenAICodexSnapshot(shadowStale, usage, now) {
  79. t.Fatal("expected stale spark shadow (no WSv2) to trigger refresh")
  80. }
  81. // 影子时间戳仍新鲜→不刷(TTL 生效)。
  82. shadowFresh := &Account{
  83. Platform: PlatformOpenAI,
  84. Type: AccountTypeOAuth,
  85. ParentAccountID: &parentID,
  86. QuotaDimension: QuotaDimensionSpark,
  87. Extra: map[string]any{"codex_usage_updated_at": freshAt},
  88. }
  89. if shouldRefreshOpenAICodexSnapshot(shadowFresh, usage, now) {
  90. t.Fatal("expected fresh spark shadow to skip refresh (TTL not elapsed)")
  91. }
  92. // 反向对照:普通账号无 WSv2 + 过期时间戳→仍不刷(WSv2 门控普通账号的 probe 刷新)。
  93. normalNoWS := &Account{
  94. Platform: PlatformOpenAI,
  95. Type: AccountTypeOAuth,
  96. Extra: map[string]any{"codex_usage_updated_at": staleAt},
  97. }
  98. if shouldRefreshOpenAICodexSnapshot(normalNoWS, usage, now) {
  99. t.Fatal("expected non-WSv2 normal account to skip codex probe refresh")
  100. }
  101. }
  102. func TestExtractOpenAICodexProbeUpdatesAccepts429WithCodexHeaders(t *testing.T) {
  103. t.Parallel()
  104. headers := make(http.Header)
  105. headers.Set("x-codex-primary-used-percent", "100")
  106. headers.Set("x-codex-primary-reset-after-seconds", "604800")
  107. headers.Set("x-codex-primary-window-minutes", "10080")
  108. headers.Set("x-codex-secondary-used-percent", "100")
  109. headers.Set("x-codex-secondary-reset-after-seconds", "18000")
  110. headers.Set("x-codex-secondary-window-minutes", "300")
  111. updates, err := extractOpenAICodexProbeUpdates(&http.Response{StatusCode: http.StatusTooManyRequests, Header: headers})
  112. if err != nil {
  113. t.Fatalf("extractOpenAICodexProbeUpdates() error = %v", err)
  114. }
  115. if len(updates) == 0 {
  116. t.Fatal("expected codex probe updates from 429 headers")
  117. }
  118. if got := updates["codex_5h_used_percent"]; got != 100.0 {
  119. t.Fatalf("codex_5h_used_percent = %v, want 100", got)
  120. }
  121. if got := updates["codex_7d_used_percent"]; got != 100.0 {
  122. t.Fatalf("codex_7d_used_percent = %v, want 100", got)
  123. }
  124. }
  125. func TestAccountUsageService_PersistOpenAICodexProbeSnapshotOnlyUpdatesExtra(t *testing.T) {
  126. t.Parallel()
  127. repo := &accountUsageCodexProbeRepo{
  128. updateExtraCh: make(chan map[string]any, 1),
  129. rateLimitCh: make(chan time.Time, 1),
  130. }
  131. svc := &AccountUsageService{accountRepo: repo}
  132. svc.persistOpenAICodexProbeSnapshot(321, map[string]any{
  133. "codex_7d_used_percent": 100.0,
  134. "codex_7d_reset_at": time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second).Format(time.RFC3339),
  135. })
  136. select {
  137. case updates := <-repo.updateExtraCh:
  138. if got := updates["codex_7d_used_percent"]; got != 100.0 {
  139. t.Fatalf("codex_7d_used_percent = %v, want 100", got)
  140. }
  141. case <-time.After(2 * time.Second):
  142. t.Fatal("等待 codex 探测快照写入 extra 超时")
  143. }
  144. select {
  145. case got := <-repo.rateLimitCh:
  146. t.Fatalf("不应将探测快照写入运行时限流状态: %v", got)
  147. case <-time.After(200 * time.Millisecond):
  148. }
  149. }
  150. func TestAccountUsageService_GetOpenAIUsage_DoesNotPromoteCodexExtraToRateLimit(t *testing.T) {
  151. t.Parallel()
  152. resetAt := time.Now().Add(6 * 24 * time.Hour).UTC().Truncate(time.Second)
  153. repo := &accountUsageCodexProbeRepo{
  154. rateLimitCh: make(chan time.Time, 1),
  155. }
  156. svc := &AccountUsageService{accountRepo: repo}
  157. account := &Account{
  158. Platform: PlatformOpenAI,
  159. Type: AccountTypeOAuth,
  160. Extra: map[string]any{
  161. "codex_5h_used_percent": 1.0,
  162. "codex_5h_reset_at": time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second).Format(time.RFC3339),
  163. "codex_7d_used_percent": 100.0,
  164. "codex_7d_reset_at": resetAt.Format(time.RFC3339),
  165. },
  166. }
  167. usage, err := svc.getOpenAIUsage(context.Background(), account, false)
  168. if err != nil {
  169. t.Fatalf("getOpenAIUsage() error = %v", err)
  170. }
  171. if usage.SevenDay == nil || usage.SevenDay.Utilization != 100.0 {
  172. t.Fatalf("预期 7 天用量仍然可见,实际为 %#v", usage.SevenDay)
  173. }
  174. if account.RateLimitResetAt != nil {
  175. t.Fatalf("不应让已耗尽的 codex extra 改写运行时限流状态: %v", account.RateLimitResetAt)
  176. }
  177. select {
  178. case got := <-repo.rateLimitCh:
  179. t.Fatalf("不应将已耗尽的 codex extra 持久化为运行时限流状态: %v", got)
  180. case <-time.After(200 * time.Millisecond):
  181. }
  182. }
  183. func TestBuildCodexUsageProgressFromExtra_ZerosExpiredWindow(t *testing.T) {
  184. t.Parallel()
  185. now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
  186. t.Run("expired 5h window zeroes utilization", func(t *testing.T) {
  187. extra := map[string]any{
  188. "codex_5h_used_percent": 42.0,
  189. "codex_5h_reset_at": "2026-03-16T10:00:00Z", // 2h ago
  190. }
  191. progress := buildCodexUsageProgressFromExtra(extra, "5h", now)
  192. if progress == nil {
  193. t.Fatal("expected non-nil progress")
  194. }
  195. if progress.Utilization != 0 {
  196. t.Fatalf("expected Utilization=0 for expired window, got %v", progress.Utilization)
  197. }
  198. if progress.RemainingSeconds != 0 {
  199. t.Fatalf("expected RemainingSeconds=0, got %v", progress.RemainingSeconds)
  200. }
  201. })
  202. t.Run("active 5h window keeps utilization", func(t *testing.T) {
  203. resetAt := now.Add(2 * time.Hour).Format(time.RFC3339)
  204. extra := map[string]any{
  205. "codex_5h_used_percent": 42.0,
  206. "codex_5h_reset_at": resetAt,
  207. }
  208. progress := buildCodexUsageProgressFromExtra(extra, "5h", now)
  209. if progress == nil {
  210. t.Fatal("expected non-nil progress")
  211. }
  212. if progress.Utilization != 42.0 {
  213. t.Fatalf("expected Utilization=42, got %v", progress.Utilization)
  214. }
  215. })
  216. t.Run("expired 7d window zeroes utilization", func(t *testing.T) {
  217. extra := map[string]any{
  218. "codex_7d_used_percent": 88.0,
  219. "codex_7d_reset_at": "2026-03-15T00:00:00Z", // yesterday
  220. }
  221. progress := buildCodexUsageProgressFromExtra(extra, "7d", now)
  222. if progress == nil {
  223. t.Fatal("expected non-nil progress")
  224. }
  225. if progress.Utilization != 0 {
  226. t.Fatalf("expected Utilization=0 for expired 7d window, got %v", progress.Utilization)
  227. }
  228. })
  229. }