| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- package admin
- import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- // --- mock repo ---
- type userBreakdownRepoCapture struct {
- service.UsageLogRepository
- capturedDim usagestats.UserBreakdownDimension
- capturedLimit int
- result []usagestats.UserBreakdownItem
- }
- func (r *userBreakdownRepoCapture) GetUserBreakdownStats(
- _ context.Context, _, _ time.Time,
- dim usagestats.UserBreakdownDimension, limit int,
- ) ([]usagestats.UserBreakdownItem, error) {
- r.capturedDim = dim
- r.capturedLimit = limit
- if r.result != nil {
- return r.result, nil
- }
- return []usagestats.UserBreakdownItem{}, nil
- }
- func newUserBreakdownRouter(repo *userBreakdownRepoCapture) *gin.Engine {
- gin.SetMode(gin.TestMode)
- svc := service.NewDashboardService(repo, nil, nil, nil)
- h := NewDashboardHandler(svc, nil)
- router := gin.New()
- router.GET("/admin/dashboard/user-breakdown", h.GetUserBreakdown)
- return router
- }
- // --- tests ---
- func TestGetUserBreakdown_GroupIDFilter(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=42", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, int64(42), repo.capturedDim.GroupID)
- require.Empty(t, repo.capturedDim.Model)
- require.Empty(t, repo.capturedDim.Endpoint)
- require.Equal(t, 50, repo.capturedLimit) // default limit
- require.Empty(t, repo.capturedDim.SortBy) // no sort_by => empty (repo falls back to default)
- }
- func TestGetUserBreakdown_SortBy(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&sort_by=total_tokens", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, "total_tokens", repo.capturedDim.SortBy)
- }
- func TestGetUserBreakdown_ModelFilter(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, "claude-opus-4-6", repo.capturedDim.Model)
- require.Equal(t, usagestats.ModelSourceRequested, repo.capturedDim.ModelType)
- require.Equal(t, int64(0), repo.capturedDim.GroupID)
- }
- func TestGetUserBreakdown_ModelSourceFilter(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=claude-opus-4-6&model_source=upstream", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, usagestats.ModelSourceUpstream, repo.capturedDim.ModelType)
- }
- func TestGetUserBreakdown_InvalidModelSource(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model_source=foobar", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusBadRequest, w.Code)
- }
- func TestGetUserBreakdown_EndpointFilter(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/v1/messages&endpoint_type=upstream", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, "/v1/messages", repo.capturedDim.Endpoint)
- require.Equal(t, "upstream", repo.capturedDim.EndpointType)
- }
- func TestGetUserBreakdown_DefaultEndpointType(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&endpoint=/chat", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, "inbound", repo.capturedDim.EndpointType)
- }
- func TestGetUserBreakdown_CustomLimit(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=100", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, 100, repo.capturedLimit)
- }
- func TestGetUserBreakdown_LimitClamped(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- // limit > 200 should fall back to default 50
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&model=test&limit=999", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, 50, repo.capturedLimit)
- }
- func TestGetUserBreakdown_ResponseFormat(t *testing.T) {
- repo := &userBreakdownRepoCapture{
- result: []usagestats.UserBreakdownItem{
- {UserID: 1, Email: "alice@test.com", Requests: 100, TotalTokens: 50000, Cost: 1.5, ActualCost: 1.2},
- {UserID: 2, Email: "bob@test.com", Requests: 50, TotalTokens: 25000, Cost: 0.8, ActualCost: 0.6},
- },
- }
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=1", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- var resp struct {
- Code int `json:"code"`
- Data struct {
- Users []usagestats.UserBreakdownItem `json:"users"`
- StartDate string `json:"start_date"`
- EndDate string `json:"end_date"`
- } `json:"data"`
- }
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
- require.Equal(t, 0, resp.Code)
- require.Len(t, resp.Data.Users, 2)
- require.Equal(t, int64(1), resp.Data.Users[0].UserID)
- require.Equal(t, "alice@test.com", resp.Data.Users[0].Email)
- require.Equal(t, int64(100), resp.Data.Users[0].Requests)
- require.InDelta(t, 1.2, resp.Data.Users[0].ActualCost, 0.001)
- require.Equal(t, "2026-03-01", resp.Data.StartDate)
- require.Equal(t, "2026-03-16", resp.Data.EndDate)
- }
- func TestGetUserBreakdown_EmptyResult(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&group_id=999", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- var resp struct {
- Data struct {
- Users []usagestats.UserBreakdownItem `json:"users"`
- } `json:"data"`
- }
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
- require.Empty(t, resp.Data.Users)
- }
- func TestGetUserBreakdown_NoFilters(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, int64(0), repo.capturedDim.GroupID)
- require.Empty(t, repo.capturedDim.Model)
- require.Empty(t, repo.capturedDim.Endpoint)
- }
- func TestGetUserBreakdown_RequestTypeStringFilter(t *testing.T) {
- cases := []struct {
- name string
- value string
- want int16
- }{
- {"ws_v2", "ws_v2", int16(service.RequestTypeWSV2)},
- {"stream", "stream", int16(service.RequestTypeStream)},
- {"sync", "sync", int16(service.RequestTypeSync)},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&request_type="+tc.value, nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
- require.NotNil(t, repo.capturedDim.RequestType, "request_type=%s should set filter", tc.value)
- require.Equal(t, tc.want, *repo.capturedDim.RequestType)
- })
- }
- }
- func TestGetUserBreakdown_InvalidRequestType(t *testing.T) {
- repo := &userBreakdownRepoCapture{}
- router := newUserBreakdownRouter(repo)
- req := httptest.NewRequest(http.MethodGet,
- "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&request_type=bogus", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- require.Equal(t, http.StatusBadRequest, w.Code)
- }
|