account_proxy_selection_test.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "time"
  7. )
  8. type randomProxySelectorStub struct {
  9. proxy *Proxy
  10. err error
  11. calls int
  12. }
  13. // randomHydrationRepo only supplies the optional selector capability. The
  14. // embedded interface keeps this test independent of the many account-repository
  15. // methods unrelated to request-time proxy resolution.
  16. type randomHydrationRepo struct {
  17. AccountRepository
  18. selector *randomProxySelectorStub
  19. }
  20. func (r *randomHydrationRepo) SelectRandomActiveProxy(ctx context.Context) (*Proxy, error) {
  21. return r.selector.SelectRandomActiveProxy(ctx)
  22. }
  23. func (s *randomProxySelectorStub) SelectRandomActiveProxy(context.Context) (*Proxy, error) {
  24. s.calls++
  25. return s.proxy, s.err
  26. }
  27. // rotatingProxySelectorStub cycles through a fixed pool so tests can assert
  28. // that repeated request-time resolution actually rotates proxies instead of
  29. // sticking to the first one sampled.
  30. type rotatingProxySelectorStub struct {
  31. proxies []*Proxy
  32. calls int
  33. }
  34. func (s *rotatingProxySelectorStub) SelectRandomActiveProxy(context.Context) (*Proxy, error) {
  35. if len(s.proxies) == 0 {
  36. return nil, nil
  37. }
  38. proxy := s.proxies[s.calls%len(s.proxies)]
  39. s.calls++
  40. return proxy, nil
  41. }
  42. func randomProxyAccount() *Account {
  43. return &Account{
  44. ID: 42,
  45. Extra: map[string]any{ProxyModeExtraKey: ProxyModeRandom},
  46. }
  47. }
  48. func TestResolveRandomProxyAssignsEphemeralProxy(t *testing.T) {
  49. proxy := &Proxy{ID: 7, Status: StatusActive}
  50. selector := &randomProxySelectorStub{proxy: proxy}
  51. account := randomProxyAccount()
  52. if err := ResolveRandomProxy(context.Background(), account, selector); err != nil {
  53. t.Fatalf("ResolveRandomProxy() error = %v", err)
  54. }
  55. if selector.calls != 1 {
  56. t.Fatalf("selector calls = %d, want 1", selector.calls)
  57. }
  58. if account.Proxy != proxy || account.ProxyID == nil || *account.ProxyID != proxy.ID {
  59. t.Fatalf("account proxy was not assigned: proxy=%#v proxy_id=%v", account.Proxy, account.ProxyID)
  60. }
  61. }
  62. func TestResolveRandomProxyFailsClosedWhenPoolUnavailable(t *testing.T) {
  63. cases := []struct {
  64. name string
  65. stub *randomProxySelectorStub
  66. }{
  67. {name: "nil selector", stub: nil},
  68. {name: "empty pool", stub: &randomProxySelectorStub{}},
  69. {name: "inactive proxy", stub: &randomProxySelectorStub{proxy: &Proxy{ID: 1, Status: StatusDisabled}}},
  70. {name: "expired proxy", stub: &randomProxySelectorStub{proxy: &Proxy{ID: 1, Status: StatusActive, ExpiresAt: accountProxyTimePtr(time.Now().Add(-time.Minute))}}},
  71. }
  72. for _, tc := range cases {
  73. t.Run(tc.name, func(t *testing.T) {
  74. account := randomProxyAccount()
  75. account.ProxyID = accountProxyInt64Ptr(99)
  76. account.Proxy = &Proxy{ID: 99, Status: StatusActive}
  77. var err error
  78. if tc.stub == nil {
  79. err = ResolveRandomProxy(context.Background(), account, nil)
  80. } else {
  81. err = ResolveRandomProxy(context.Background(), account, tc.stub)
  82. }
  83. if !errors.Is(err, ErrRandomProxyUnavailable) {
  84. t.Fatalf("error = %v, want ErrRandomProxyUnavailable", err)
  85. }
  86. // A failed refresh must not leave a stale fixed proxy usable.
  87. if account.ProxyID != nil || account.Proxy != nil {
  88. t.Fatalf("failed selection left a stale proxy association")
  89. }
  90. })
  91. }
  92. }
  93. func TestResolveRandomProxyRotatesAcrossCalls(t *testing.T) {
  94. proxies := []*Proxy{
  95. {ID: 21, Status: StatusActive},
  96. {ID: 22, Status: StatusActive},
  97. {ID: 23, Status: StatusActive},
  98. }
  99. selector := &rotatingProxySelectorStub{proxies: proxies}
  100. seen := map[int64]int{}
  101. const resolves = 30
  102. for i := 0; i < resolves; i++ {
  103. account := randomProxyAccount()
  104. if err := ResolveRandomProxy(context.Background(), account, selector); err != nil {
  105. t.Fatalf("ResolveRandomProxy() error = %v", err)
  106. }
  107. if account.Proxy == nil || account.ProxyID == nil {
  108. t.Fatal("rotating selection left the account without a proxy")
  109. }
  110. seen[*account.ProxyID]++
  111. }
  112. for _, proxy := range proxies {
  113. if seen[proxy.ID] == 0 {
  114. t.Fatalf("proxy %d was never selected in %d resolves (seen=%v)", proxy.ID, resolves, seen)
  115. }
  116. }
  117. }
  118. func TestNormalizeProxyModeExtra(t *testing.T) {
  119. cases := []struct {
  120. name string
  121. input map[string]any
  122. want map[string]any
  123. }{
  124. {name: "nil map stays nil", input: nil, want: nil},
  125. {name: "missing key untouched", input: map[string]any{"other": 1}, want: map[string]any{"other": 1}},
  126. {name: "random kept", input: map[string]any{ProxyModeExtraKey: "random"}, want: map[string]any{ProxyModeExtraKey: "random"}},
  127. {name: "case and whitespace canonicalized", input: map[string]any{ProxyModeExtraKey: " Random "}, want: map[string]any{ProxyModeExtraKey: "random"}},
  128. {name: "other string dropped", input: map[string]any{ProxyModeExtraKey: "fixed", "other": 1}, want: map[string]any{"other": 1}},
  129. {name: "empty string dropped", input: map[string]any{ProxyModeExtraKey: ""}, want: map[string]any{}},
  130. {name: "non-string dropped", input: map[string]any{ProxyModeExtraKey: 7}, want: map[string]any{}},
  131. {name: "nil value dropped", input: map[string]any{ProxyModeExtraKey: nil}, want: map[string]any{}},
  132. }
  133. for _, tc := range cases {
  134. t.Run(tc.name, func(t *testing.T) {
  135. got := NormalizeProxyModeExtra(tc.input)
  136. if len(got) != len(tc.want) {
  137. t.Fatalf("NormalizeProxyModeExtra() = %v, want %v", got, tc.want)
  138. }
  139. for key, wantValue := range tc.want {
  140. if got[key] != wantValue {
  141. t.Fatalf("NormalizeProxyModeExtra()[%q] = %v, want %v", key, got[key], wantValue)
  142. }
  143. }
  144. })
  145. }
  146. }
  147. func TestResolveRandomProxyIgnoresNonRandomAccount(t *testing.T) {
  148. selector := &randomProxySelectorStub{proxy: &Proxy{ID: 1, Status: StatusActive}}
  149. account := &Account{ID: 1, Extra: map[string]any{ProxyModeExtraKey: "fixed"}}
  150. if err := ResolveRandomProxy(context.Background(), account, selector); err != nil {
  151. t.Fatalf("ResolveRandomProxy() error = %v", err)
  152. }
  153. if selector.calls != 0 {
  154. t.Fatalf("selector calls = %d, want 0", selector.calls)
  155. }
  156. }
  157. func TestGatewayHydrationResolvesRandomProxyWithoutSnapshot(t *testing.T) {
  158. selector := &randomProxySelectorStub{proxy: &Proxy{ID: 11, Status: StatusActive}}
  159. repo := &randomHydrationRepo{selector: selector}
  160. service := &GatewayService{accountRepo: repo}
  161. account := randomProxyAccount()
  162. account.ProxyID = accountProxyInt64Ptr(99)
  163. account.Proxy = &Proxy{ID: 99, Status: StatusActive}
  164. hydrated, err := service.hydrateSelectedAccount(context.Background(), account)
  165. if err != nil {
  166. t.Fatalf("hydrateSelectedAccount() error = %v", err)
  167. }
  168. if hydrated != account || hydrated.Proxy == nil || hydrated.Proxy.ID != 11 {
  169. t.Fatalf("hydrated proxy = %#v, want proxy 11", hydrated.Proxy)
  170. }
  171. }
  172. func TestOpenAIHydrationResolvesRandomProxyWithoutSnapshot(t *testing.T) {
  173. selector := &randomProxySelectorStub{proxy: &Proxy{ID: 12, Status: StatusActive}}
  174. repo := &randomHydrationRepo{selector: selector}
  175. service := &OpenAIGatewayService{accountRepo: repo}
  176. hydrated, err := service.hydrateSelectedAccount(context.Background(), randomProxyAccount())
  177. if err != nil {
  178. t.Fatalf("hydrateSelectedAccount() error = %v", err)
  179. }
  180. if hydrated == nil || hydrated.Proxy == nil || hydrated.Proxy.ID != 12 {
  181. t.Fatalf("hydrated proxy = %#v, want proxy 12", hydrated.Proxy)
  182. }
  183. }
  184. func TestGeminiHydrationResolvesRandomProxyWithoutSnapshot(t *testing.T) {
  185. selector := &randomProxySelectorStub{proxy: &Proxy{ID: 13, Status: StatusActive}}
  186. repo := &randomHydrationRepo{selector: selector}
  187. service := &GeminiMessagesCompatService{accountRepo: repo}
  188. hydrated, err := service.hydrateSelectedAccount(context.Background(), randomProxyAccount())
  189. if err != nil {
  190. t.Fatalf("hydrateSelectedAccount() error = %v", err)
  191. }
  192. if hydrated == nil || hydrated.Proxy == nil || hydrated.Proxy.ID != 13 {
  193. t.Fatalf("hydrated proxy = %#v, want proxy 13", hydrated.Proxy)
  194. }
  195. }
  196. func accountProxyTimePtr(value time.Time) *time.Time { return &value }
  197. func accountProxyInt64Ptr(value int64) *int64 { return &value }