631 lines
21 KiB
Go
631 lines
21 KiB
Go
|
|
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)
|
||
|
|
}
|
||
|
|
}
|