diff --git a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go new file mode 100644 index 00000000..2c1dbd59 --- /dev/null +++ b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go @@ -0,0 +1,203 @@ +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, int64(0), repo.capturedDim.GroupID) +} + +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) +} diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go new file mode 100644 index 00000000..ca63e0bc --- /dev/null +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -0,0 +1,29 @@ +//go:build unit + +package repository + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveEndpointColumn(t *testing.T) { + tests := []struct { + endpointType string + want string + }{ + {"inbound", "ul.inbound_endpoint"}, + {"upstream", "ul.upstream_endpoint"}, + {"path", "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint"}, + {"", "ul.inbound_endpoint"}, // default + {"unknown", "ul.inbound_endpoint"}, // fallback + } + + for _, tc := range tests { + t.Run(tc.endpointType, func(t *testing.T) { + got := resolveEndpointColumn(tc.endpointType) + require.Equal(t, tc.want, got) + }) + } +}