ratelimit_service_401_db_fallback_test.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // dbFallbackRepoStub extends errorPolicyRepoStub with a configurable DB account
  11. // returned by GetByID, simulating cache miss + DB fallback.
  12. type dbFallbackRepoStub struct {
  13. errorPolicyRepoStub
  14. dbAccount *Account // returned by GetByID when non-nil
  15. }
  16. func (r *dbFallbackRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) {
  17. if r.dbAccount != nil && r.dbAccount.ID == id {
  18. return r.dbAccount, nil
  19. }
  20. return nil, nil // not found, no error
  21. }
  22. func TestCheckErrorPolicy_401_DBFallback_Escalates(t *testing.T) {
  23. // Scenario: cache account has empty TempUnschedulableReason (cache miss),
  24. // but DB account has a previous 401 record.
  25. // Non-Antigravity: should escalate to ErrorPolicyNone (second 401 = permanent error).
  26. // Antigravity: skips escalation logic (401 handled by applyErrorPolicy rules).
  27. t.Run("gemini_escalates", func(t *testing.T) {
  28. repo := &dbFallbackRepoStub{
  29. dbAccount: &Account{
  30. ID: 20,
  31. TempUnschedulableReason: `{"status_code":401,"until_unix":1735689600}`,
  32. },
  33. }
  34. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  35. account := &Account{
  36. ID: 20,
  37. Type: AccountTypeOAuth,
  38. Platform: PlatformGemini,
  39. TempUnschedulableReason: "",
  40. Credentials: map[string]any{
  41. "temp_unschedulable_enabled": true,
  42. "temp_unschedulable_rules": []any{
  43. map[string]any{
  44. "error_code": float64(401),
  45. "keywords": []any{"unauthorized"},
  46. "duration_minutes": float64(10),
  47. },
  48. },
  49. },
  50. }
  51. result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`))
  52. require.Equal(t, ErrorPolicyNone, result, "gemini 401 with DB fallback showing previous 401 should escalate")
  53. })
  54. t.Run("antigravity_stays_temp", func(t *testing.T) {
  55. repo := &dbFallbackRepoStub{
  56. dbAccount: &Account{
  57. ID: 20,
  58. TempUnschedulableReason: `{"status_code":401,"until_unix":1735689600}`,
  59. },
  60. }
  61. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  62. account := &Account{
  63. ID: 20,
  64. Type: AccountTypeOAuth,
  65. Platform: PlatformAntigravity,
  66. TempUnschedulableReason: "",
  67. Credentials: map[string]any{
  68. "temp_unschedulable_enabled": true,
  69. "temp_unschedulable_rules": []any{
  70. map[string]any{
  71. "error_code": float64(401),
  72. "keywords": []any{"unauthorized"},
  73. "duration_minutes": float64(10),
  74. },
  75. },
  76. },
  77. }
  78. result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`))
  79. require.Equal(t, ErrorPolicyTempUnscheduled, result, "antigravity 401 skips escalation, stays temp-unscheduled")
  80. })
  81. }
  82. func TestCheckErrorPolicy_401_DBFallback_NoDBRecord_FirstHit(t *testing.T) {
  83. // Scenario: cache account has empty TempUnschedulableReason,
  84. // DB also has no previous 401 record → should NOT escalate (first hit → temp unscheduled).
  85. repo := &dbFallbackRepoStub{
  86. dbAccount: &Account{
  87. ID: 21,
  88. TempUnschedulableReason: "", // DB also empty
  89. },
  90. }
  91. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  92. account := &Account{
  93. ID: 21,
  94. Type: AccountTypeOAuth,
  95. Platform: PlatformAntigravity,
  96. TempUnschedulableReason: "",
  97. Credentials: map[string]any{
  98. "temp_unschedulable_enabled": true,
  99. "temp_unschedulable_rules": []any{
  100. map[string]any{
  101. "error_code": float64(401),
  102. "keywords": []any{"unauthorized"},
  103. "duration_minutes": float64(10),
  104. },
  105. },
  106. },
  107. }
  108. result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`))
  109. require.Equal(t, ErrorPolicyTempUnscheduled, result, "401 first hit with no DB record should temp-unschedule")
  110. }
  111. func TestCheckErrorPolicy_401_DBFallback_DBError_FirstHit(t *testing.T) {
  112. // Scenario: cache account has empty TempUnschedulableReason,
  113. // DB lookup returns nil (not found) → should treat as first hit → temp unscheduled.
  114. repo := &dbFallbackRepoStub{
  115. dbAccount: nil, // GetByID returns nil, nil
  116. }
  117. svc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  118. account := &Account{
  119. ID: 22,
  120. Type: AccountTypeOAuth,
  121. Platform: PlatformAntigravity,
  122. TempUnschedulableReason: "",
  123. Credentials: map[string]any{
  124. "temp_unschedulable_enabled": true,
  125. "temp_unschedulable_rules": []any{
  126. map[string]any{
  127. "error_code": float64(401),
  128. "keywords": []any{"unauthorized"},
  129. "duration_minutes": float64(10),
  130. },
  131. },
  132. },
  133. }
  134. result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, []byte(`unauthorized`))
  135. require.Equal(t, ErrorPolicyTempUnscheduled, result, "401 first hit with DB not found should temp-unschedule")
  136. }