dashboard_handler_user_breakdown_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. package admin
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. // --- mock repo ---
  15. type userBreakdownRepoCapture struct {
  16. service.UsageLogRepository
  17. capturedDim usagestats.UserBreakdownDimension
  18. capturedLimit int
  19. result []usagestats.UserBreakdownItem
  20. }
  21. func (r *userBreakdownRepoCapture) GetUserBreakdownStats(
  22. _ context.Context, _, _ time.Time,
  23. dim usagestats.UserBreakdownDimension, limit int,
  24. ) ([]usagestats.UserBreakdownItem, error) {
  25. r.capturedDim = dim
  26. r.capturedLimit = limit
  27. if r.result != nil {
  28. return r.result, nil
  29. }
  30. return []usagestats.UserBreakdownItem{}, nil
  31. }
  32. func newUserBreakdownRouter(repo *userBreakdownRepoCapture) *gin.Engine {
  33. gin.SetMode(gin.TestMode)
  34. svc := service.NewDashboardService(repo, nil, nil, nil)
  35. h := NewDashboardHandler(svc, nil)
  36. router := gin.New()
  37. router.GET("/admin/dashboard/user-breakdown", h.GetUserBreakdown)
  38. return router
  39. }
  40. // --- tests ---
  41. func TestGetUserBreakdown_GroupIDFilter(t *testing.T) {
  42. repo := &userBreakdownRepoCapture{}
  43. router := newUserBreakdownRouter(repo)
  44. req := httptest.NewRequest(http.MethodGet,
  45. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=42", nil)
  46. w := httptest.NewRecorder()
  47. router.ServeHTTP(w, req)
  48. require.Equal(t, http.StatusOK, w.Code)
  49. require.Equal(t, int64(42), repo.capturedDim.GroupID)
  50. require.Empty(t, repo.capturedDim.Model)
  51. require.Empty(t, repo.capturedDim.Endpoint)
  52. require.Equal(t, 50, repo.capturedLimit) // default limit
  53. require.Empty(t, repo.capturedDim.SortBy) // no sort_by => empty (repo falls back to default)
  54. }
  55. func TestGetUserBreakdown_SortBy(t *testing.T) {
  56. repo := &userBreakdownRepoCapture{}
  57. router := newUserBreakdownRouter(repo)
  58. req := httptest.NewRequest(http.MethodGet,
  59. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&sort_by=total_tokens", nil)
  60. w := httptest.NewRecorder()
  61. router.ServeHTTP(w, req)
  62. require.Equal(t, http.StatusOK, w.Code)
  63. require.Equal(t, "total_tokens", repo.capturedDim.SortBy)
  64. }
  65. func TestGetUserBreakdown_ModelFilter(t *testing.T) {
  66. repo := &userBreakdownRepoCapture{}
  67. router := newUserBreakdownRouter(repo)
  68. req := httptest.NewRequest(http.MethodGet,
  69. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6", nil)
  70. w := httptest.NewRecorder()
  71. router.ServeHTTP(w, req)
  72. require.Equal(t, http.StatusOK, w.Code)
  73. require.Equal(t, "claude-opus-4-6", repo.capturedDim.Model)
  74. require.Equal(t, usagestats.ModelSourceRequested, repo.capturedDim.ModelType)
  75. require.Equal(t, int64(0), repo.capturedDim.GroupID)
  76. }
  77. func TestGetUserBreakdown_ModelSourceFilter(t *testing.T) {
  78. repo := &userBreakdownRepoCapture{}
  79. router := newUserBreakdownRouter(repo)
  80. req := httptest.NewRequest(http.MethodGet,
  81. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6&model_source=upstream", nil)
  82. w := httptest.NewRecorder()
  83. router.ServeHTTP(w, req)
  84. require.Equal(t, http.StatusOK, w.Code)
  85. require.Equal(t, usagestats.ModelSourceUpstream, repo.capturedDim.ModelType)
  86. }
  87. func TestGetUserBreakdown_InvalidModelSource(t *testing.T) {
  88. repo := &userBreakdownRepoCapture{}
  89. router := newUserBreakdownRouter(repo)
  90. req := httptest.NewRequest(http.MethodGet,
  91. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model_source=foobar", nil)
  92. w := httptest.NewRecorder()
  93. router.ServeHTTP(w, req)
  94. require.Equal(t, http.StatusBadRequest, w.Code)
  95. }
  96. func TestGetUserBreakdown_EndpointFilter(t *testing.T) {
  97. repo := &userBreakdownRepoCapture{}
  98. router := newUserBreakdownRouter(repo)
  99. req := httptest.NewRequest(http.MethodGet,
  100. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/v1/messages&endpoint_type=upstream", nil)
  101. w := httptest.NewRecorder()
  102. router.ServeHTTP(w, req)
  103. require.Equal(t, http.StatusOK, w.Code)
  104. require.Equal(t, "/v1/messages", repo.capturedDim.Endpoint)
  105. require.Equal(t, "upstream", repo.capturedDim.EndpointType)
  106. }
  107. func TestGetUserBreakdown_DefaultEndpointType(t *testing.T) {
  108. repo := &userBreakdownRepoCapture{}
  109. router := newUserBreakdownRouter(repo)
  110. req := httptest.NewRequest(http.MethodGet,
  111. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/chat", nil)
  112. w := httptest.NewRecorder()
  113. router.ServeHTTP(w, req)
  114. require.Equal(t, http.StatusOK, w.Code)
  115. require.Equal(t, "inbound", repo.capturedDim.EndpointType)
  116. }
  117. func TestGetUserBreakdown_CustomLimit(t *testing.T) {
  118. repo := &userBreakdownRepoCapture{}
  119. router := newUserBreakdownRouter(repo)
  120. req := httptest.NewRequest(http.MethodGet,
  121. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=100", nil)
  122. w := httptest.NewRecorder()
  123. router.ServeHTTP(w, req)
  124. require.Equal(t, http.StatusOK, w.Code)
  125. require.Equal(t, 100, repo.capturedLimit)
  126. }
  127. func TestGetUserBreakdown_LimitClamped(t *testing.T) {
  128. repo := &userBreakdownRepoCapture{}
  129. router := newUserBreakdownRouter(repo)
  130. // limit > 200 should fall back to default 50
  131. req := httptest.NewRequest(http.MethodGet,
  132. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=999", nil)
  133. w := httptest.NewRecorder()
  134. router.ServeHTTP(w, req)
  135. require.Equal(t, http.StatusOK, w.Code)
  136. require.Equal(t, 50, repo.capturedLimit)
  137. }
  138. func TestGetUserBreakdown_ResponseFormat(t *testing.T) {
  139. repo := &userBreakdownRepoCapture{
  140. result: []usagestats.UserBreakdownItem{
  141. {UserID: 1, Email: "alice@test.com", Requests: 100, TotalTokens: 50000, Cost: 1.5, ActualCost: 1.2},
  142. {UserID: 2, Email: "bob@test.com", Requests: 50, TotalTokens: 25000, Cost: 0.8, ActualCost: 0.6},
  143. },
  144. }
  145. router := newUserBreakdownRouter(repo)
  146. req := httptest.NewRequest(http.MethodGet,
  147. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=1", nil)
  148. w := httptest.NewRecorder()
  149. router.ServeHTTP(w, req)
  150. require.Equal(t, http.StatusOK, w.Code)
  151. var resp struct {
  152. Code int `json:"code"`
  153. Data struct {
  154. Users []usagestats.UserBreakdownItem `json:"users"`
  155. StartDate string `json:"start_date"`
  156. EndDate string `json:"end_date"`
  157. } `json:"data"`
  158. }
  159. err := json.Unmarshal(w.Body.Bytes(), &resp)
  160. require.NoError(t, err)
  161. require.Equal(t, 0, resp.Code)
  162. require.Len(t, resp.Data.Users, 2)
  163. require.Equal(t, int64(1), resp.Data.Users[0].UserID)
  164. require.Equal(t, "alice@test.com", resp.Data.Users[0].Email)
  165. require.Equal(t, int64(100), resp.Data.Users[0].Requests)
  166. require.InDelta(t, 1.2, resp.Data.Users[0].ActualCost, 0.001)
  167. require.Equal(t, "2026-03-01", resp.Data.StartDate)
  168. require.Equal(t, "2026-03-16", resp.Data.EndDate)
  169. }
  170. func TestGetUserBreakdown_EmptyResult(t *testing.T) {
  171. repo := &userBreakdownRepoCapture{}
  172. router := newUserBreakdownRouter(repo)
  173. req := httptest.NewRequest(http.MethodGet,
  174. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=999", nil)
  175. w := httptest.NewRecorder()
  176. router.ServeHTTP(w, req)
  177. require.Equal(t, http.StatusOK, w.Code)
  178. var resp struct {
  179. Data struct {
  180. Users []usagestats.UserBreakdownItem `json:"users"`
  181. } `json:"data"`
  182. }
  183. err := json.Unmarshal(w.Body.Bytes(), &resp)
  184. require.NoError(t, err)
  185. require.Empty(t, resp.Data.Users)
  186. }
  187. func TestGetUserBreakdown_NoFilters(t *testing.T) {
  188. repo := &userBreakdownRepoCapture{}
  189. router := newUserBreakdownRouter(repo)
  190. req := httptest.NewRequest(http.MethodGet,
  191. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16", nil)
  192. w := httptest.NewRecorder()
  193. router.ServeHTTP(w, req)
  194. require.Equal(t, http.StatusOK, w.Code)
  195. require.Equal(t, int64(0), repo.capturedDim.GroupID)
  196. require.Empty(t, repo.capturedDim.Model)
  197. require.Empty(t, repo.capturedDim.Endpoint)
  198. }
  199. func TestGetUserBreakdown_RequestTypeStringFilter(t *testing.T) {
  200. cases := []struct {
  201. name string
  202. value string
  203. want int16
  204. }{
  205. {"ws_v2", "ws_v2", int16(service.RequestTypeWSV2)},
  206. {"stream", "stream", int16(service.RequestTypeStream)},
  207. {"sync", "sync", int16(service.RequestTypeSync)},
  208. }
  209. for _, tc := range cases {
  210. t.Run(tc.name, func(t *testing.T) {
  211. repo := &userBreakdownRepoCapture{}
  212. router := newUserBreakdownRouter(repo)
  213. req := httptest.NewRequest(http.MethodGet,
  214. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&request_type="+tc.value, nil)
  215. w := httptest.NewRecorder()
  216. router.ServeHTTP(w, req)
  217. require.Equal(t, http.StatusOK, w.Code)
  218. require.NotNil(t, repo.capturedDim.RequestType, "request_type=%s should set filter", tc.value)
  219. require.Equal(t, tc.want, *repo.capturedDim.RequestType)
  220. })
  221. }
  222. }
  223. func TestGetUserBreakdown_InvalidRequestType(t *testing.T) {
  224. repo := &userBreakdownRepoCapture{}
  225. router := newUserBreakdownRouter(repo)
  226. req := httptest.NewRequest(http.MethodGet,
  227. "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&request_type=bogus", nil)
  228. w := httptest.NewRecorder()
  229. router.ServeHTTP(w, req)
  230. require.Equal(t, http.StatusBadRequest, w.Code)
  231. }