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 } 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) }