package handler import ( "encoding/json" "errors" "io" "log" "net/http" "net/http/httptest" "testing" "time" "github.com/gorilla/mux" "github.com/seifghazi/claude-code-monitor/internal/config" "github.com/seifghazi/claude-code-monitor/internal/model" ) type dashboardStorageStub struct { getRequestsFn func(page, limit int, modelFilter string) ([]model.RequestLog, int, error) getUsageStatsFn func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) getRequestsSummaryPaginatedFn func(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) getRequestByShortIDFn func(shortID string) (*model.RequestLog, string, error) getStatsFn func(startDate, endDate, orgFilter string) (*model.DashboardStats, error) getHourlyStatsFn func(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) getModelStatsFn func(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) getDistinctOrganizationsFn func() ([]string, error) getLatestRequestDateFn func() (*time.Time, error) getSettingsFn func() (*model.ProxySettings, error) saveSettingsFn func(settings *model.ProxySettings) error clearRequestsFn func() (int, error) } func (s *dashboardStorageStub) SaveRequest(*model.RequestLog) (string, error) { panic("unexpected call") } func (s *dashboardStorageStub) GetRequests(page, limit int, modelFilter string) ([]model.RequestLog, int, error) { if s.getRequestsFn != nil { return s.getRequestsFn(page, limit, modelFilter) } panic("unexpected call") } func (s *dashboardStorageStub) GetAllRequests(string) ([]*model.RequestLog, error) { panic("unexpected call") } func (s *dashboardStorageStub) GetRequestByShortID(shortID string) (*model.RequestLog, string, error) { if s.getRequestByShortIDFn != nil { return s.getRequestByShortIDFn(shortID) } panic("unexpected call") } func (s *dashboardStorageStub) ClearRequests() (int, error) { if s.clearRequestsFn != nil { return s.clearRequestsFn() } panic("unexpected call") } func (s *dashboardStorageStub) UpdateRequestWithGrading(string, *model.PromptGrade) error { panic("unexpected call") } func (s *dashboardStorageStub) UpdateRequestWithResponse(*model.RequestLog) error { panic("unexpected call") } func (s *dashboardStorageStub) DeleteRequestsOlderThan(time.Duration) (int, error) { panic("unexpected call") } func (s *dashboardStorageStub) GetDatabaseStats() (map[string]interface{}, error) { panic("unexpected call") } func (s *dashboardStorageStub) GetUsageStats(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) { if s.getUsageStatsFn != nil { return s.getUsageStatsFn(startDate, endDate, modelFilter, orgFilter) } panic("unexpected call") } func (s *dashboardStorageStub) GetRequestsSummary(string) ([]*model.RequestSummary, error) { panic("unexpected call") } func (s *dashboardStorageStub) GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) { if s.getRequestsSummaryPaginatedFn != nil { return s.getRequestsSummaryPaginatedFn(modelFilter, startTime, endTime, offset, limit) } panic("unexpected call") } func (s *dashboardStorageStub) GetStats(startDate, endDate, orgFilter string) (*model.DashboardStats, error) { if s.getStatsFn != nil { return s.getStatsFn(startDate, endDate, orgFilter) } panic("unexpected call") } func (s *dashboardStorageStub) GetHourlyStats(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) { if s.getHourlyStatsFn != nil { return s.getHourlyStatsFn(startTime, endTime, bucketMinutes, orgFilter) } panic("unexpected call") } func (s *dashboardStorageStub) GetModelStats(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) { if s.getModelStatsFn != nil { return s.getModelStatsFn(startTime, endTime, orgFilter) } panic("unexpected call") } func (s *dashboardStorageStub) GetLatestRequestDate() (*time.Time, error) { if s.getLatestRequestDateFn != nil { return s.getLatestRequestDateFn() } panic("unexpected call") } func (s *dashboardStorageStub) GetDistinctOrganizations() ([]string, error) { if s.getDistinctOrganizationsFn != nil { return s.getDistinctOrganizationsFn() } panic("unexpected call") } func (s *dashboardStorageStub) GetSettings() (*model.ProxySettings, error) { if s.getSettingsFn != nil { return s.getSettingsFn() } return &model.ProxySettings{}, nil } func (s *dashboardStorageStub) SaveSettings(settings *model.ProxySettings) error { if s.saveSettingsFn != nil { return s.saveSettingsFn(settings) } return nil } func (s *dashboardStorageStub) GetConfig() *config.StorageConfig { return &config.StorageConfig{} } func (s *dashboardStorageStub) EnsureDirectoryExists() error { return nil } func (s *dashboardStorageStub) Close() error { return nil } func newTestHandler(storage *dashboardStorageStub) *Handler { return &Handler{ storageService: storage, logger: log.New(io.Discard, "", 0), } } func decodeJSONBody(t *testing.T, rr *httptest.ResponseRecorder, dest interface{}) { t.Helper() if err := json.NewDecoder(rr.Body).Decode(dest); err != nil { t.Fatalf("failed decoding JSON response: %v", err) } } func TestGetRequestsUsesDefaultPaginationAndModelFilter(t *testing.T) { storage := &dashboardStorageStub{ getRequestsFn: func(page, limit int, modelFilter string) ([]model.RequestLog, int, error) { if page != defaultPage { t.Fatalf("expected page %d, got %d", defaultPage, page) } if limit != defaultPageLimit { t.Fatalf("expected limit %d, got %d", defaultPageLimit, limit) } if modelFilter != "all" { t.Fatalf("expected model filter all, got %q", modelFilter) } return []model.RequestLog{{RequestID: "req-1"}}, 7, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/requests", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetRequests(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { Requests []model.RequestLog `json:"requests"` Total int `json:"total"` } decodeJSONBody(t, rr, &response) if len(response.Requests) != 1 || response.Requests[0].RequestID != "req-1" { t.Fatalf("unexpected requests payload: %#v", response.Requests) } if response.Total != 7 { t.Fatalf("expected total 7, got %d", response.Total) } } func TestGetRequestsReturnsInternalServerErrorOnStorageFailure(t *testing.T) { storage := &dashboardStorageStub{ getRequestsFn: func(page, limit int, modelFilter string) ([]model.RequestLog, int, error) { return nil, 0, errors.New("boom") }, } req := httptest.NewRequest(http.MethodGet, "/api/requests?page=2&limit=25&model=opus", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetRequests(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "Failed to get requests" { t.Fatalf("unexpected error response: %#v", response) } } func TestDeleteRequestsReturnsDeletedCountAndStorageErrors(t *testing.T) { t.Run("success", func(t *testing.T) { storage := &dashboardStorageStub{ clearRequestsFn: func() (int, error) { return 12, nil }, } req := httptest.NewRequest(http.MethodDelete, "/api/requests", nil) rr := httptest.NewRecorder() newTestHandler(storage).DeleteRequests(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { Message string `json:"message"` Deleted int `json:"deleted"` } decodeJSONBody(t, rr, &response) if response.Message != "Request history cleared" || response.Deleted != 12 { t.Fatalf("unexpected delete response: %#v", response) } }) t.Run("storage error", func(t *testing.T) { storage := &dashboardStorageStub{ clearRequestsFn: func() (int, error) { return 0, errors.New("boom") }, } req := httptest.NewRequest(http.MethodDelete, "/api/requests", nil) rr := httptest.NewRecorder() newTestHandler(storage).DeleteRequests(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "Error clearing request history" { t.Fatalf("unexpected error response: %#v", response) } }) } func TestGetRequestsSummaryNormalizesPaginationInputs(t *testing.T) { storage := &dashboardStorageStub{ getRequestsSummaryPaginatedFn: func(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error) { if modelFilter != "all" { t.Fatalf("expected default model filter all, got %q", modelFilter) } if startTime != "2026-03-01T00:00:00Z" { t.Fatalf("unexpected start time %q", startTime) } if endTime != "2026-03-02T00:00:00Z" { t.Fatalf("unexpected end time %q", endTime) } if offset != 0 { t.Fatalf("expected invalid negative offset to normalize to 0, got %d", offset) } if limit != 0 { t.Fatalf("expected invalid oversize limit to normalize to 0, got %d", limit) } return []*model.RequestSummary{{RequestID: "req-summary-1"}}, 1, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/requests/summary?start=2026-03-01T00:00:00Z&end=2026-03-02T00:00:00Z&offset=-4&limit=100001", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetRequestsSummary(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { Requests []*model.RequestSummary `json:"requests"` Total int `json:"total"` Offset int `json:"offset"` Limit int `json:"limit"` } decodeJSONBody(t, rr, &response) if len(response.Requests) != 1 || response.Requests[0].RequestID != "req-summary-1" { t.Fatalf("unexpected summaries payload: %#v", response.Requests) } if response.Total != 1 || response.Offset != 0 || response.Limit != 0 { t.Fatalf("unexpected summary metadata: %#v", response) } } func TestGetRequestByIDHandlesMissingAndNotFoundIDs(t *testing.T) { t.Run("missing id", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/requests/", nil) rr := httptest.NewRecorder() newTestHandler(&dashboardStorageStub{}).GetRequestByID(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "Request ID is required" { t.Fatalf("unexpected error response: %#v", response) } }) t.Run("not found", func(t *testing.T) { storage := &dashboardStorageStub{ getRequestByShortIDFn: func(shortID string) (*model.RequestLog, string, error) { if shortID != "abc123" { t.Fatalf("expected short ID abc123, got %q", shortID) } return nil, "", nil }, } req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil), map[string]string{"id": "abc123"}) rr := httptest.NewRecorder() newTestHandler(storage).GetRequestByID(rr, req) if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "Request not found" { t.Fatalf("unexpected error response: %#v", response) } }) } func TestGetRequestByIDReturnsRequestPayloadAndStorageErrors(t *testing.T) { t.Run("success", func(t *testing.T) { storage := &dashboardStorageStub{ getRequestByShortIDFn: func(shortID string) (*model.RequestLog, string, error) { if shortID != "abc123" { t.Fatalf("expected short ID abc123, got %q", shortID) } return &model.RequestLog{RequestID: "full-request-id", Model: "claude-opus-4-6"}, "full-request-id", nil }, } req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil), map[string]string{"id": "abc123"}) rr := httptest.NewRecorder() newTestHandler(storage).GetRequestByID(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { Request *model.RequestLog `json:"request"` FullID string `json:"fullId"` } decodeJSONBody(t, rr, &response) if response.Request == nil || response.Request.RequestID != "full-request-id" || response.FullID != "full-request-id" { t.Fatalf("unexpected request payload: %#v", response) } }) t.Run("storage error", func(t *testing.T) { storage := &dashboardStorageStub{ getRequestByShortIDFn: func(shortID string) (*model.RequestLog, string, error) { return nil, "", errors.New("boom") }, } req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/requests/abc123", nil), map[string]string{"id": "abc123"}) rr := httptest.NewRecorder() newTestHandler(storage).GetRequestByID(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "Failed to get request" { t.Fatalf("unexpected error response: %#v", response) } }) } func TestGetDashboardStatsFallsBackToLastSevenDays(t *testing.T) { storage := &dashboardStorageStub{ getStatsFn: func(startDate, endDate, orgFilter string) (*model.DashboardStats, error) { if orgFilter != "org-1" { t.Fatalf("expected org filter org-1, got %q", orgFilter) } start, err := time.Parse(time.RFC3339, startDate) if err != nil { t.Fatalf("expected RFC3339 start date, got %q: %v", startDate, err) } end, err := time.Parse(time.RFC3339, endDate) if err != nil { t.Fatalf("expected RFC3339 end date, got %q: %v", endDate, err) } diff := end.Sub(start) if diff < (7*24*time.Hour-time.Second) || diff > (7*24*time.Hour+time.Second) { t.Fatalf("expected ~7 day fallback window, got %v", diff) } return &model.DashboardStats{ DailyStats: []model.DailyTokens{{Date: "2026-03-20", Tokens: 42, Requests: 2}}, }, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/dashboard/stats?org=org-1", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetDashboardStats(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response model.DashboardStats decodeJSONBody(t, rr, &response) if len(response.DailyStats) != 1 || response.DailyStats[0].Tokens != 42 { t.Fatalf("unexpected dashboard stats payload: %#v", response) } } func TestGetStatsPassesQueryFiltersThrough(t *testing.T) { storage := &dashboardStorageStub{ getUsageStatsFn: func(startDate, endDate, modelFilter, orgFilter string) (*model.UsageStats, error) { if startDate != "2026-03-01" { t.Fatalf("expected start date 2026-03-01, got %q", startDate) } if endDate != "2026-03-07" { t.Fatalf("expected end date 2026-03-07, got %q", endDate) } if modelFilter != "claude-sonnet-4-5" { t.Fatalf("expected model filter claude-sonnet-4-5, got %q", modelFilter) } if orgFilter != "org-usage" { t.Fatalf("expected org filter org-usage, got %q", orgFilter) } return &model.UsageStats{ TotalRequests: 3, RequestsByModel: map[string]model.ModelStats{ "claude-sonnet-4-5": {RequestCount: 3}, }, }, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/stats?start_date=2026-03-01&end_date=2026-03-07&model=claude-sonnet-4-5&org=org-usage", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetStats(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response model.UsageStats decodeJSONBody(t, rr, &response) if response.TotalRequests != 3 { t.Fatalf("expected total requests 3, got %d", response.TotalRequests) } if response.RequestsByModel["claude-sonnet-4-5"].RequestCount != 3 { t.Fatalf("unexpected usage stats payload: %#v", response) } } func TestGetHourlyStatsValidatesRangeAndDefaultsBucket(t *testing.T) { t.Run("missing range", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/dashboard/hourly?start=2026-03-01T00:00:00Z", nil) rr := httptest.NewRecorder() newTestHandler(&dashboardStorageStub{}).GetHourlyStats(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "start and end parameters are required" { t.Fatalf("unexpected error response: %#v", response) } }) t.Run("invalid bucket falls back to default", func(t *testing.T) { storage := &dashboardStorageStub{ getHourlyStatsFn: func(startTime, endTime string, bucketMinutes int, orgFilter string) (*model.HourlyStatsResponse, error) { if startTime != "2026-03-01T00:00:00Z" || endTime != "2026-03-01T12:00:00Z" { t.Fatalf("unexpected time range %q - %q", startTime, endTime) } if bucketMinutes != defaultBucketMinutes { t.Fatalf("expected default bucket %d, got %d", defaultBucketMinutes, bucketMinutes) } if orgFilter != "org-2" { t.Fatalf("expected org filter org-2, got %q", orgFilter) } return &model.HourlyStatsResponse{ HourlyStats: []model.HourlyTokens{{Hour: 9, Tokens: 123, Requests: 3}}, }, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/dashboard/hourly?start=2026-03-01T00:00:00Z&end=2026-03-01T12:00:00Z&bucket=bad&org=org-2", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetHourlyStats(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response model.HourlyStatsResponse decodeJSONBody(t, rr, &response) if len(response.HourlyStats) != 1 || response.HourlyStats[0].Tokens != 123 { t.Fatalf("unexpected hourly stats payload: %#v", response) } }) } func TestGetModelStatsRejectsMissingRange(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/dashboard/models?end=2026-03-01T12:00:00Z", nil) rr := httptest.NewRecorder() newTestHandler(&dashboardStorageStub{}).GetModelStats(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } var response model.ErrorResponse decodeJSONBody(t, rr, &response) if response.Error != "start and end parameters are required" { t.Fatalf("unexpected error response: %#v", response) } } func TestGetModelStatsPassesFiltersThrough(t *testing.T) { storage := &dashboardStorageStub{ getModelStatsFn: func(startTime, endTime, orgFilter string) (*model.ModelStatsResponse, error) { if startTime != "2026-03-01T00:00:00Z" || endTime != "2026-03-01T12:00:00Z" { t.Fatalf("unexpected range %q - %q", startTime, endTime) } if orgFilter != "org-models" { t.Fatalf("expected org filter org-models, got %q", orgFilter) } return &model.ModelStatsResponse{ ModelStats: []model.ModelTokens{{Model: "claude-opus-4-6", Tokens: 321, Requests: 4}}, }, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/dashboard/models?start=2026-03-01T00:00:00Z&end=2026-03-01T12:00:00Z&org=org-models", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetModelStats(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response model.ModelStatsResponse decodeJSONBody(t, rr, &response) if len(response.ModelStats) != 1 || response.ModelStats[0].Model != "claude-opus-4-6" { t.Fatalf("unexpected model stats payload: %#v", response) } } func TestGetOrganizationsReturnsEmptySliceWhenStorageReturnsNil(t *testing.T) { storage := &dashboardStorageStub{ getDistinctOrganizationsFn: func() ([]string, error) { return nil, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/organizations", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetOrganizations(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { Organizations []string `json:"organizations"` } decodeJSONBody(t, rr, &response) if len(response.Organizations) != 0 { t.Fatalf("expected empty organizations list, got %#v", response.Organizations) } } func TestGetLatestRequestDateReturnsNullWhenStorageHasNoData(t *testing.T) { storage := &dashboardStorageStub{ getLatestRequestDateFn: func() (*time.Time, error) { return nil, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/latest-request-date", nil) rr := httptest.NewRecorder() newTestHandler(storage).GetLatestRequestDate(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { LatestDate *time.Time `json:"latestDate"` } decodeJSONBody(t, rr, &response) if response.LatestDate != nil { t.Fatalf("expected latestDate to be null, got %#v", response.LatestDate) } }