user_platform_quota_admin_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. //go:build unit
  2. package admin
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "time"
  13. "github.com/gin-gonic/gin"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. )
  16. // upsertCapturingQuotaRepo 实现 service.UserPlatformQuotaRepository,捕获 UpsertForUser 调用。
  17. type upsertCapturingQuotaRepo struct {
  18. service.UserPlatformQuotaRepository
  19. listRecords []service.UserPlatformQuotaRecord
  20. listErr error
  21. upsertCalls []upsertCall
  22. upsertErr error
  23. resetCalls []resetCall
  24. resetErr error
  25. }
  26. type upsertCall struct {
  27. userID int64
  28. records []service.UserPlatformQuotaRecord
  29. }
  30. type resetCall struct {
  31. userID int64
  32. platform string
  33. window string
  34. newStart time.Time
  35. }
  36. func (r *upsertCapturingQuotaRepo) ListByUser(_ context.Context, _ int64) ([]service.UserPlatformQuotaRecord, error) {
  37. return r.listRecords, r.listErr
  38. }
  39. func (r *upsertCapturingQuotaRepo) UpsertForUser(_ context.Context, userID int64, records []service.UserPlatformQuotaRecord) error {
  40. cloned := make([]service.UserPlatformQuotaRecord, len(records))
  41. copy(cloned, records)
  42. r.upsertCalls = append(r.upsertCalls, upsertCall{userID: userID, records: cloned})
  43. return r.upsertErr
  44. }
  45. func (r *upsertCapturingQuotaRepo) ResetExpiredWindow(_ context.Context, userID int64, platform string, window string, newStart time.Time) error {
  46. r.resetCalls = append(r.resetCalls, resetCall{userID, platform, window, newStart})
  47. return r.resetErr
  48. }
  49. // billingCacheStub 实现 service.BillingCache 中本测试关心的 Delete 方法;其他方法 panic。
  50. type billingCacheStub struct {
  51. service.BillingCache
  52. deleteCalls []deleteCall
  53. deleteErr error
  54. }
  55. type deleteCall struct {
  56. userID int64
  57. platform string
  58. }
  59. func (b *billingCacheStub) DeleteUserPlatformQuotaCache(_ context.Context, userID int64, platform string) error {
  60. b.deleteCalls = append(b.deleteCalls, deleteCall{userID, platform})
  61. return b.deleteErr
  62. }
  63. func buildTestHandler(repo service.UserPlatformQuotaRepository, cache service.BillingCache) *UserHandler {
  64. return &UserHandler{
  65. userPlatformQuotaRepo: repo,
  66. billingCache: cache,
  67. adminService: newStubAdminService(),
  68. }
  69. }
  70. func putReq(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) {
  71. t.Helper()
  72. gin.SetMode(gin.TestMode)
  73. w := httptest.NewRecorder()
  74. c, _ := gin.CreateTestContext(w)
  75. req, _ := http.NewRequest(http.MethodPut, "/", bytes.NewBufferString(body))
  76. req.Header.Set("Content-Type", "application/json")
  77. c.Request = req
  78. c.Params = []gin.Param{{Key: "id", Value: "42"}}
  79. return c, w
  80. }
  81. func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
  82. repo := &upsertCapturingQuotaRepo{}
  83. cache := &billingCacheStub{}
  84. h := buildTestHandler(repo, cache)
  85. body := `{"quotas":[
  86. {"platform":"anthropic","daily_limit_usd":10.0,"weekly_limit_usd":null,"monthly_limit_usd":100.0},
  87. {"platform":"openai","daily_limit_usd":80.0,"weekly_limit_usd":300.0,"monthly_limit_usd":null},
  88. {"platform":"gemini","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
  89. {"platform":"antigravity","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
  90. {"platform":"grok","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
  91. ]}`
  92. c, w := putReq(t, body)
  93. h.UpdateUserPlatformQuotas(c)
  94. if w.Code != http.StatusOK {
  95. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  96. }
  97. if len(repo.upsertCalls) != 1 {
  98. t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
  99. }
  100. // upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
  101. if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
  102. t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
  103. }
  104. // 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
  105. if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
  106. t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
  107. }
  108. }
  109. func TestUpdateUserPlatformQuotas_RejectsDuplicatePlatform(t *testing.T) {
  110. h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
  111. body := `{"quotas":[
  112. {"platform":"anthropic","daily_limit_usd":1},
  113. {"platform":"anthropic","daily_limit_usd":2}
  114. ]}`
  115. c, w := putReq(t, body)
  116. h.UpdateUserPlatformQuotas(c)
  117. if w.Code != http.StatusBadRequest {
  118. t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
  119. }
  120. }
  121. func TestUpdateUserPlatformQuotas_RejectsInvalidPlatform(t *testing.T) {
  122. h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
  123. body := `{"quotas":[{"platform":"unknown","daily_limit_usd":1}]}`
  124. c, w := putReq(t, body)
  125. h.UpdateUserPlatformQuotas(c)
  126. if w.Code != http.StatusBadRequest {
  127. t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
  128. }
  129. }
  130. func TestUpdateUserPlatformQuotas_RejectsNegativeLimit(t *testing.T) {
  131. h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
  132. body := `{"quotas":[{"platform":"anthropic","daily_limit_usd":-1}]}`
  133. c, w := putReq(t, body)
  134. h.UpdateUserPlatformQuotas(c)
  135. if w.Code != http.StatusBadRequest {
  136. t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
  137. }
  138. }
  139. func TestUpdateUserPlatformQuotas_RejectsTooManyEntries(t *testing.T) {
  140. h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
  141. body := `{"quotas":[
  142. {"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"grok"},{"platform":"anthropic"}
  143. ]}`
  144. c, w := putReq(t, body)
  145. h.UpdateUserPlatformQuotas(c)
  146. if w.Code != http.StatusBadRequest {
  147. t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
  148. }
  149. }
  150. func TestUpdateUserPlatformQuotas_ReturnsLatestState(t *testing.T) {
  151. repo := &upsertCapturingQuotaRepo{
  152. listRecords: []service.UserPlatformQuotaRecord{
  153. {UserID: 42, Platform: "anthropic"},
  154. },
  155. }
  156. cache := &billingCacheStub{}
  157. h := buildTestHandler(repo, cache)
  158. body := `{"quotas":[{"platform":"anthropic","daily_limit_usd":10}]}`
  159. c, w := putReq(t, body)
  160. h.UpdateUserPlatformQuotas(c)
  161. if !strings.Contains(w.Body.String(), `"platform_quotas"`) {
  162. t.Errorf("response should contain platform_quotas array: %s", w.Body.String())
  163. }
  164. }
  165. // ───────── T4: Reset 测试 ─────────
  166. func postReq(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) {
  167. t.Helper()
  168. gin.SetMode(gin.TestMode)
  169. w := httptest.NewRecorder()
  170. c, _ := gin.CreateTestContext(w)
  171. req, _ := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
  172. req.Header.Set("Content-Type", "application/json")
  173. c.Request = req
  174. c.Params = []gin.Param{{Key: "id", Value: "42"}}
  175. return c, w
  176. }
  177. func TestResetUserPlatformQuotaWindow_Success(t *testing.T) {
  178. repo := &upsertCapturingQuotaRepo{}
  179. cache := &billingCacheStub{}
  180. h := buildTestHandler(repo, cache)
  181. body := `{"platform":"anthropic","window":"daily"}`
  182. c, w := postReq(t, body)
  183. h.ResetUserPlatformQuotaWindow(c)
  184. if w.Code != http.StatusOK {
  185. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  186. }
  187. if len(repo.resetCalls) != 1 {
  188. t.Fatalf("ResetExpiredWindow should be called once, got %d", len(repo.resetCalls))
  189. }
  190. if repo.resetCalls[0].userID != 42 ||
  191. repo.resetCalls[0].platform != "anthropic" ||
  192. repo.resetCalls[0].window != "daily" {
  193. t.Errorf("unexpected reset call: %+v", repo.resetCalls[0])
  194. }
  195. if len(cache.deleteCalls) != 1 ||
  196. cache.deleteCalls[0].userID != 42 ||
  197. cache.deleteCalls[0].platform != "anthropic" {
  198. t.Errorf("expected 1 cache delete for anthropic, got %+v", cache.deleteCalls)
  199. }
  200. }
  201. func TestResetUserPlatformQuotaWindow_RejectsInvalidWindow(t *testing.T) {
  202. h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
  203. c, w := postReq(t, `{"platform":"anthropic","window":"yearly"}`)
  204. h.ResetUserPlatformQuotaWindow(c)
  205. if w.Code != http.StatusBadRequest {
  206. t.Errorf("expected 400, got %d", w.Code)
  207. }
  208. }
  209. func TestResetUserPlatformQuotaWindow_RejectsInvalidPlatform(t *testing.T) {
  210. h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
  211. c, w := postReq(t, `{"platform":"unknown","window":"daily"}`)
  212. h.ResetUserPlatformQuotaWindow(c)
  213. if w.Code != http.StatusBadRequest {
  214. t.Errorf("expected 400, got %d", w.Code)
  215. }
  216. }
  217. func TestResetUserPlatformQuotaWindow_NotFound(t *testing.T) {
  218. // handler 检查 service.ErrUserPlatformQuotaNotFound(由 adapter 包装而来)
  219. repo := &upsertCapturingQuotaRepo{resetErr: service.ErrUserPlatformQuotaNotFound}
  220. h := buildTestHandler(repo, &billingCacheStub{})
  221. c, w := postReq(t, `{"platform":"anthropic","window":"daily"}`)
  222. h.ResetUserPlatformQuotaWindow(c)
  223. if w.Code != http.StatusNotFound {
  224. t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
  225. }
  226. }
  227. func TestUpdateUserPlatformQuotas_JSONErrorOnRepoFailure(t *testing.T) {
  228. repo := &upsertCapturingQuotaRepo{upsertErr: errors.New("db down")}
  229. cache := &billingCacheStub{}
  230. h := buildTestHandler(repo, cache)
  231. body := `{"quotas":[{"platform":"anthropic","daily_limit_usd":10}]}`
  232. c, w := putReq(t, body)
  233. h.UpdateUserPlatformQuotas(c)
  234. if w.Code < 500 {
  235. t.Errorf("expected 5xx, got %d", w.Code)
  236. }
  237. // 返回 JSON 错误响应
  238. var body2 map[string]any
  239. if err := json.Unmarshal(w.Body.Bytes(), &body2); err != nil {
  240. t.Errorf("expected JSON error body, got: %s", w.Body.String())
  241. }
  242. }
  243. func TestUpdateUserPlatformQuotas_UserNotFound(t *testing.T) {
  244. repo := &upsertCapturingQuotaRepo{}
  245. cache := &billingCacheStub{}
  246. adminSvc := newStubAdminService()
  247. adminSvc.getUserErr = service.ErrUserNotFound
  248. h := &UserHandler{
  249. userPlatformQuotaRepo: repo,
  250. billingCache: cache,
  251. adminService: adminSvc,
  252. }
  253. body := `{"quotas":[{"platform":"anthropic","daily_limit_usd":10}]}`
  254. c, w := putReq(t, body)
  255. h.UpdateUserPlatformQuotas(c)
  256. if w.Code != http.StatusNotFound {
  257. t.Errorf("expected 404 when user not found, got %d: %s", w.Code, w.Body.String())
  258. }
  259. }
  260. func TestResetUserPlatformQuotaWindow_UserNotFound(t *testing.T) {
  261. repo := &upsertCapturingQuotaRepo{}
  262. cache := &billingCacheStub{}
  263. adminSvc := newStubAdminService()
  264. adminSvc.getUserErr = service.ErrUserNotFound
  265. h := &UserHandler{
  266. userPlatformQuotaRepo: repo,
  267. billingCache: cache,
  268. adminService: adminSvc,
  269. }
  270. c, w := postReq(t, `{"platform":"anthropic","window":"daily"}`)
  271. h.ResetUserPlatformQuotaWindow(c)
  272. if w.Code != http.StatusNotFound {
  273. t.Errorf("expected 404 when user not found, got %d: %s", w.Code, w.Body.String())
  274. }
  275. }