account_usage_service_spark_shadow_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // sparkShadowUsageTestRepo is a minimal AccountRepository stub for spark shadow
  13. // usage tests. GetByID serves both shadow and parent accounts from a map;
  14. // UpdateExtra records the persisted updates for assertion.
  15. type sparkShadowUsageTestRepo struct {
  16. AccountRepository
  17. accounts map[int64]*Account
  18. updateExtraCh chan map[string]any
  19. }
  20. func (r *sparkShadowUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) {
  21. if acc, ok := r.accounts[id]; ok {
  22. return acc, nil
  23. }
  24. return nil, fmt.Errorf("account %d not found", id)
  25. }
  26. func (r *sparkShadowUsageTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
  27. if r.updateExtraCh != nil {
  28. copied := make(map[string]any, len(updates))
  29. for k, v := range updates {
  30. copied[k] = v
  31. }
  32. r.updateExtraCh <- copied
  33. }
  34. return nil
  35. }
  36. // TestGetOpenAIUsage_SparkShadow_WritesExtraAndReturnsNonEmptyWindows covers
  37. // two assertions required by Task 3.2:
  38. //
  39. // A) After getOpenAIUsage on a spark shadow account the shadow row's
  40. // Extra["codex_5h_used_percent"] is persisted, and the upstream call carried
  41. // the PARENT account's chatgpt-account-id (not the shadow's empty one).
  42. //
  43. // B) (P1-b regression guard) The UsageInfo RETURNED by the same call has
  44. // non-nil FiveHour AND SevenDay windows — proving that the rebuild happened
  45. // and not just the DB write.
  46. func TestGetOpenAIUsage_SparkShadow_WritesExtraAndReturnsNonEmptyWindows(t *testing.T) {
  47. t.Parallel()
  48. ctx := context.Background()
  49. pid := int64(100)
  50. shadow := &Account{
  51. ID: 200,
  52. ParentAccountID: &pid,
  53. Platform: PlatformOpenAI,
  54. Type: AccountTypeOAuth,
  55. Status: StatusActive,
  56. QuotaDimension: QuotaDimensionSpark,
  57. }
  58. parent := &Account{
  59. ID: 100,
  60. Platform: PlatformOpenAI,
  61. Type: AccountTypeOAuth,
  62. Status: StatusActive,
  63. Credentials: map[string]any{
  64. "chatgpt_account_id": "org-spark-parent",
  65. },
  66. }
  67. // Repo shared by both the OpenAIQuotaService (needs shadow+parent for resolve)
  68. // and the AccountUsageService (needs UpdateExtra for persist).
  69. updateExtraCh := make(chan map[string]any, 1)
  70. repo := &sparkShadowUsageTestRepo{
  71. accounts: map[int64]*Account{200: shadow, 100: parent},
  72. updateExtraCh: updateExtraCh,
  73. }
  74. // Token cache: return a fake token for the parent account key.
  75. tokenCache := &stubQuotaTokenCache{tokens: map[string]string{
  76. OpenAITokenCacheKey(parent): "fake-access-token",
  77. }}
  78. tokenProvider := NewOpenAITokenProvider(repo, tokenCache, nil)
  79. // httptest server: records the chatgpt-account-id header and returns a
  80. // synthetic OpenAIQuotaUsage with codex_bengalfox 5h+7d windows.
  81. var capturedAccountID string
  82. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  83. capturedAccountID = r.Header.Get("chatgpt-account-id")
  84. w.Header().Set("content-type", "application/json")
  85. resp := OpenAIQuotaUsage{
  86. AdditionalRateLimits: []OpenAIAdditionalRateLimit{
  87. {
  88. MeteredFeature: "codex_bengalfox",
  89. RateLimit: &OpenAIRateLimit{
  90. // Primary window → 5h (18000 s = 300 min)
  91. PrimaryWindow: &OpenAIRateLimitWindow{
  92. UsedPercent: 42.5,
  93. ResetAfterSeconds: 3600,
  94. LimitWindowSeconds: 18000,
  95. },
  96. // Secondary window → 7d (604800 s = 10080 min)
  97. SecondaryWindow: &OpenAIRateLimitWindow{
  98. UsedPercent: 10.0,
  99. ResetAfterSeconds: 86400,
  100. LimitWindowSeconds: 604800,
  101. },
  102. },
  103. },
  104. },
  105. }
  106. _ = json.NewEncoder(w).Encode(resp)
  107. }))
  108. defer srv.Close()
  109. quotaService := NewOpenAIQuotaService(repo, nil, tokenProvider, newQuotaRedirectingFactory(srv))
  110. svc := &AccountUsageService{
  111. accountRepo: repo,
  112. openAIQuotaService: quotaService,
  113. }
  114. usage, err := svc.getOpenAIUsage(ctx, shadow, true /*force*/)
  115. require.NoError(t, err)
  116. // Assertion A-1: upstream received the PARENT's chatgpt-account-id.
  117. require.Equal(t, "org-spark-parent", capturedAccountID,
  118. "QueryUsage must use parent's chatgpt-account-id for spark shadow accounts")
  119. // Assertion A-2: shadow Extra was persisted with codex_5h_used_percent.
  120. select {
  121. case updates := <-updateExtraCh:
  122. require.Contains(t, updates, "codex_5h_used_percent",
  123. "persisted extra must contain codex_5h_used_percent")
  124. require.InDelta(t, 42.5, updates["codex_5h_used_percent"], 0.01,
  125. "codex_5h_used_percent must match the upstream value")
  126. case <-time.After(2 * time.Second):
  127. t.Fatal("UpdateExtra was not called within timeout — spark shadow persist did not happen")
  128. }
  129. // Assertion B (P1-b regression guard): returned UsageInfo must have
  130. // non-nil windows. This FAILS if the code only writes Extra without
  131. // rebuilding the returned UsageInfo.
  132. require.NotNil(t, usage.FiveHour,
  133. "returned UsageInfo.FiveHour must be non-nil (rebuild from merged Extra must happen)")
  134. require.NotNil(t, usage.SevenDay,
  135. "returned UsageInfo.SevenDay must be non-nil (rebuild from merged Extra must happen)")
  136. }