gateway_key_billing_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  11. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. type keyBillingUserGroupRateRepo struct {
  17. service.UserGroupRateRepository
  18. rate *float64
  19. err error
  20. gotUserID int64
  21. gotGroupID int64
  22. lookupCalls int
  23. }
  24. func (r *keyBillingUserGroupRateRepo) GetByUserAndGroup(_ context.Context, userID, groupID int64) (*float64, error) {
  25. r.gotUserID = userID
  26. r.gotGroupID = groupID
  27. r.lookupCalls++
  28. return r.rate, r.err
  29. }
  30. func newKeyBillingHandler(repo service.UserGroupRateRepository) *GatewayHandler {
  31. return &GatewayHandler{
  32. gatewayService: newKeyBillingGatewayService(repo),
  33. openAIGatewayService: newKeyBillingOpenAIGatewayService(repo),
  34. }
  35. }
  36. func newKeyBillingGatewayService(repo service.UserGroupRateRepository) *service.GatewayService {
  37. return service.NewGatewayService(
  38. nil, nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil, nil, nil,
  39. nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
  40. )
  41. }
  42. func newKeyBillingOpenAIGatewayService(repo service.UserGroupRateRepository) *service.OpenAIGatewayService {
  43. return service.NewOpenAIGatewayService(
  44. nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil,
  45. nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
  46. )
  47. }
  48. func newKeyBillingContext(apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) {
  49. gin.SetMode(gin.TestMode)
  50. w := httptest.NewRecorder()
  51. c, _ := gin.CreateTestContext(w)
  52. c.Request = httptest.NewRequest(http.MethodGet, "/v1/sub2api/billing", nil)
  53. if apiKey != nil {
  54. c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
  55. }
  56. return c, w
  57. }
  58. func TestGatewayHandlerKeyBillingInfoUsesGroupRate(t *testing.T) {
  59. groupID := int64(7)
  60. apiKey := &service.APIKey{
  61. UserID: 11,
  62. GroupID: &groupID,
  63. Key: "sk-sensitive-value",
  64. Group: &service.Group{
  65. ID: groupID,
  66. Name: "private-group-name",
  67. RateMultiplier: 0.75,
  68. },
  69. }
  70. c, w := newKeyBillingContext(apiKey)
  71. newKeyBillingHandler(nil).KeyBillingInfo(c)
  72. require.Equal(t, http.StatusOK, w.Code)
  73. require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
  74. var got keyBillingInfoResponse
  75. require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
  76. require.Equal(t, "sub2api.key_billing", got.Object)
  77. require.Equal(t, 1, got.SchemaVersion)
  78. require.Equal(t, "token", got.BillingScope)
  79. require.Equal(t, 0.75, got.GroupRateMultiplier)
  80. require.Nil(t, got.UserRateMultiplier)
  81. require.Equal(t, 0.75, got.ResolvedRateMultiplier)
  82. require.False(t, got.PeakRateEnabled)
  83. require.Nil(t, got.PeakStart)
  84. require.Nil(t, got.PeakEnd)
  85. require.Nil(t, got.PeakRateMultiplier)
  86. require.Nil(t, got.AppliedPeakMultiplier)
  87. require.Equal(t, 0.75, got.EffectiveRateMultiplier)
  88. require.Nil(t, got.Timezone)
  89. require.False(t, got.ObservedAt.IsZero())
  90. var fields map[string]json.RawMessage
  91. require.NoError(t, json.Unmarshal(w.Body.Bytes(), &fields))
  92. require.NotContains(t, fields, "user_rate_multiplier")
  93. require.NotContains(t, fields, "peak_start")
  94. require.NotContains(t, fields, "peak_end")
  95. require.NotContains(t, fields, "peak_rate_multiplier")
  96. require.NotContains(t, fields, "applied_peak_multiplier")
  97. require.NotContains(t, fields, "timezone")
  98. require.NotContains(t, w.Body.String(), apiKey.Key)
  99. require.NotContains(t, w.Body.String(), apiKey.Group.Name)
  100. }
  101. func TestGatewayHandlerKeyBillingInfoUsesUserOverride(t *testing.T) {
  102. groupID := int64(7)
  103. userRate := 0.5
  104. apiKey := &service.APIKey{
  105. UserID: 11,
  106. GroupID: &groupID,
  107. Group: &service.Group{ID: groupID, RateMultiplier: 0.75},
  108. }
  109. c, w := newKeyBillingContext(apiKey)
  110. repo := &keyBillingUserGroupRateRepo{rate: &userRate}
  111. newKeyBillingHandler(repo).KeyBillingInfo(c)
  112. require.Equal(t, http.StatusOK, w.Code)
  113. require.Equal(t, 1, repo.lookupCalls)
  114. require.Equal(t, apiKey.UserID, repo.gotUserID)
  115. require.Equal(t, groupID, repo.gotGroupID)
  116. var got keyBillingInfoResponse
  117. require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
  118. require.NotNil(t, got.UserRateMultiplier)
  119. require.Equal(t, 0.5, *got.UserRateMultiplier)
  120. require.Equal(t, 0.5, got.ResolvedRateMultiplier)
  121. require.Equal(t, 0.5, got.EffectiveRateMultiplier)
  122. }
  123. func TestBuildKeyBillingInfoAppliesPeakMultiplier(t *testing.T) {
  124. groupID := int64(7)
  125. apiKey := &service.APIKey{
  126. GroupID: &groupID,
  127. Group: &service.Group{
  128. ID: groupID,
  129. RateMultiplier: 1.2,
  130. SubscriptionType: service.SubscriptionTypeSubscription,
  131. PeakRateEnabled: true,
  132. PeakStart: "09:00",
  133. PeakEnd: "18:00",
  134. PeakRateMultiplier: 1.5,
  135. },
  136. }
  137. now := time.Date(2026, time.July, 12, 10, 0, 0, 0, timezone.Location())
  138. userRate := 0.8
  139. got := buildKeyBillingInfo(apiKey, userRate, now)
  140. require.Equal(t, 1.2, got.GroupRateMultiplier)
  141. require.NotNil(t, got.UserRateMultiplier)
  142. require.Equal(t, 0.8, *got.UserRateMultiplier)
  143. require.Equal(t, 0.8, got.ResolvedRateMultiplier)
  144. require.True(t, got.PeakRateEnabled)
  145. require.NotNil(t, got.PeakStart)
  146. require.Equal(t, "09:00", *got.PeakStart)
  147. require.NotNil(t, got.PeakEnd)
  148. require.Equal(t, "18:00", *got.PeakEnd)
  149. require.NotNil(t, got.PeakRateMultiplier)
  150. require.Equal(t, 1.5, *got.PeakRateMultiplier)
  151. require.NotNil(t, got.AppliedPeakMultiplier)
  152. require.Equal(t, 1.5, *got.AppliedPeakMultiplier)
  153. require.InDelta(t, 1.2, got.EffectiveRateMultiplier, 1e-12)
  154. require.NotNil(t, got.Timezone)
  155. require.Equal(t, timezone.Location().String(), *got.Timezone)
  156. require.Equal(t, now.UTC(), got.ObservedAt)
  157. encoded, err := json.Marshal(got)
  158. require.NoError(t, err)
  159. var fields map[string]json.RawMessage
  160. require.NoError(t, json.Unmarshal(encoded, &fields))
  161. for _, field := range []string{
  162. "user_rate_multiplier",
  163. "peak_start",
  164. "peak_end",
  165. "peak_rate_multiplier",
  166. "applied_peak_multiplier",
  167. "timezone",
  168. } {
  169. require.Contains(t, fields, field)
  170. }
  171. }
  172. func TestKeyBillingInfoJSONKeepsZeroPeakMultiplierWhenEnabled(t *testing.T) {
  173. groupID := int64(7)
  174. apiKey := &service.APIKey{
  175. GroupID: &groupID,
  176. Group: &service.Group{
  177. ID: groupID,
  178. SubscriptionType: service.SubscriptionTypeSubscription,
  179. PeakRateEnabled: true,
  180. PeakStart: "00:00",
  181. PeakEnd: "23:59",
  182. PeakRateMultiplier: 0,
  183. },
  184. }
  185. now := time.Date(2026, time.July, 12, 12, 0, 0, 0, timezone.Location())
  186. encoded, err := json.Marshal(buildKeyBillingInfo(apiKey, apiKey.Group.RateMultiplier, now))
  187. require.NoError(t, err)
  188. var fields map[string]json.RawMessage
  189. require.NoError(t, json.Unmarshal(encoded, &fields))
  190. require.JSONEq(t, "0", string(fields["peak_rate_multiplier"]))
  191. require.JSONEq(t, "0", string(fields["applied_peak_multiplier"]))
  192. }
  193. func TestGatewayHandlerKeyBillingInfoErrorsAreSafe(t *testing.T) {
  194. t.Run("missing API key", func(t *testing.T) {
  195. c, w := newKeyBillingContext(nil)
  196. newKeyBillingHandler(nil).KeyBillingInfo(c)
  197. require.Equal(t, http.StatusUnauthorized, w.Code)
  198. })
  199. t.Run("ungrouped API key", func(t *testing.T) {
  200. c, w := newKeyBillingContext(&service.APIKey{})
  201. newKeyBillingHandler(nil).KeyBillingInfo(c)
  202. require.Equal(t, http.StatusForbidden, w.Code)
  203. })
  204. t.Run("missing billing service", func(t *testing.T) {
  205. groupID := int64(7)
  206. c, w := newKeyBillingContext(&service.APIKey{
  207. UserID: 11,
  208. GroupID: &groupID,
  209. Group: &service.Group{ID: groupID, RateMultiplier: 1},
  210. })
  211. (&GatewayHandler{}).KeyBillingInfo(c)
  212. require.Equal(t, http.StatusInternalServerError, w.Code)
  213. })
  214. t.Run("rate lookup failure matches billing fallback", func(t *testing.T) {
  215. groupID := int64(7)
  216. c, w := newKeyBillingContext(&service.APIKey{
  217. UserID: 11,
  218. GroupID: &groupID,
  219. Group: &service.Group{ID: groupID, RateMultiplier: 1},
  220. })
  221. newKeyBillingHandler(&keyBillingUserGroupRateRepo{err: errors.New("database password leaked")}).KeyBillingInfo(c)
  222. require.Equal(t, http.StatusOK, w.Code)
  223. var got keyBillingInfoResponse
  224. require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
  225. require.Equal(t, 1.0, got.ResolvedRateMultiplier)
  226. require.NotContains(t, w.Body.String(), "database password leaked")
  227. })
  228. }
  229. func TestGatewayHandlerKeyBillingInfoSharesBillingResolverCacheByPlatform(t *testing.T) {
  230. for _, tc := range []struct {
  231. name string
  232. platform string
  233. openAI bool
  234. }{
  235. {name: "anthropic", platform: service.PlatformAnthropic},
  236. {name: "openai", platform: service.PlatformOpenAI, openAI: true},
  237. {name: "grok", platform: service.PlatformGrok, openAI: true},
  238. } {
  239. t.Run(tc.name, func(t *testing.T) {
  240. groupID := int64(7)
  241. oldRate, newRate := 0.5, 1.8
  242. repo := &keyBillingUserGroupRateRepo{rate: &oldRate}
  243. gatewayService := newKeyBillingGatewayService(repo)
  244. openAIGatewayService := newKeyBillingOpenAIGatewayService(repo)
  245. h := &GatewayHandler{
  246. gatewayService: gatewayService,
  247. openAIGatewayService: openAIGatewayService,
  248. }
  249. apiKey := &service.APIKey{
  250. UserID: 11,
  251. GroupID: &groupID,
  252. Group: &service.Group{
  253. ID: groupID,
  254. Platform: tc.platform,
  255. RateMultiplier: 0.75,
  256. },
  257. }
  258. if tc.openAI {
  259. require.Equal(t, oldRate, openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier))
  260. } else {
  261. require.Equal(t, oldRate, gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier))
  262. }
  263. repo.rate = &newRate
  264. for range 2 {
  265. c, w := newKeyBillingContext(apiKey)
  266. h.KeyBillingInfo(c)
  267. require.Equal(t, http.StatusOK, w.Code)
  268. var got keyBillingInfoResponse
  269. require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
  270. require.Equal(t, oldRate, got.ResolvedRateMultiplier)
  271. require.Equal(t, oldRate, got.EffectiveRateMultiplier)
  272. }
  273. var billedRate float64
  274. if tc.openAI {
  275. billedRate = openAIGatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)
  276. } else {
  277. billedRate = gatewayService.ResolveUserGroupRateMultiplier(context.Background(), apiKey.UserID, groupID, apiKey.Group.RateMultiplier)
  278. }
  279. require.Equal(t, oldRate, billedRate)
  280. require.Equal(t, 1, repo.lookupCalls)
  281. })
  282. }
  283. }