claude-code-proxy/proxy/internal/handler/handlers_dashboard_test.go
sid 8e550b9785 Local fork: hardening + ops improvements (timeout knob, demotion, /livez, drain)
This commit captures both the prior accumulated work-in-progress
(framework migration web/→svelte/, postgres storage, conversation
viewer, dashboard auth, OpenAPI spec, integration tests) AND today's
operational improvements layered on top. History wasn't checkpointed
incrementally; happy to split it via interactive rebase if a reviewer
wants smaller commits.

Today's changes (in addition to the older WIP):

1. Configurable upstream response-header timeout
   - ANTHROPIC_RESPONSE_HEADER_TIMEOUT env (default 300s)
   - Replaces hardcoded 300s in provider/anthropic.go that was firing
     on opus + 1M-context + extended thinking non-streaming requests
   - Files: internal/config/config.go, internal/provider/anthropic.go

2. Structured forward-error diagnostic logging
   - When a forward to Anthropic fails, log a single key=value line
     with request_id, model, stream, body_bytes, has_thinking,
     anthropic_beta, query, elapsed, ctx_err — alongside the existing
     human-readable error line for back-compat
   - Files: internal/handler/handlers.go (logForwardFailure)

3. Full SSE protocol passthrough + Flusher fix
   - handler/handlers.go: forward all SSE lines verbatim (event:, id:,
     retry:, : comments, blank-line terminators), not only data:.
     Previous code produced malformed SSE for strict parsers.
   - middleware/logging.go: explicit Flush() method on responseWriter.
     Embedding http.ResponseWriter (interface) does not auto-promote
     Flush(), so every w.(http.Flusher) check in the streaming
     handler was returning ok=false and SSE writes buffered in net/http
     until the body closed.

4. Non-streaming → streaming demotion (feature-flagged)
   - ANTHROPIC_DEMOTE_NONSTREAMING env (default false)
   - When enabled and the routed provider is anthropic, force stream=true
     upstream for clients that asked for stream=false. Receive SSE,
     accumulate via accumulateSSEToMessage (handles text, tool_use with
     partial_json reassembly, thinking, signature, citations_delta,
     usage merge), and synthesize a single non-streaming JSON response.
   - Eliminates the ResponseHeaderTimeout class of failure entirely.
   - Body rewrite uses json.Decoder + UseNumber() to preserve integer
     precision in unknown nested fields (tool inputs from prior turns).
   - Files: internal/config/config.go, internal/handler/handlers.go,
     cmd/proxy/main.go, cmd/proxy/main_test.go

5. Live operational state: /livez gauge + graceful drain
   - New internal/runtime package: atomic in-flight counter + draining flag
   - New middleware/inflight.go: increments runtime gauge, applied to
     /v1/* subrouter so Messages, ChatCompletions, and ProxyPassthrough
     are all counted
   - /v1/* moved to a gorilla/mux subrouter so the InFlight middleware
     applies surgically; /health, /livez, /openapi.* remain on parent
     router (unauthenticated, uncounted)
   - Health handler returns 503 draining when runtime.IsDraining() is
     true, so Traefik stops routing to a slot before drain begins
   - New /livez handler returns {status, in_flight, draining, timestamp}
   - SIGTERM handler in main.go: SetDraining(true), poll for in_flight==0
     with 32-min ceiling and 1s tick (logs every 10s), then srv.Shutdown
   - Auth bypass list extended with /livez
   - Files: internal/runtime/runtime.go (new),
     internal/middleware/inflight.go (new),
     internal/middleware/auth.go,
     internal/handler/handlers.go (Health, Livez, runtime import),
     cmd/proxy/main.go (subrouter, drain loop)

6. OpenAPI spec updates
   - Document Health 503 response and new DrainingResponse schema
   - Add /livez path with LivezResponse schema
   - Files: internal/handler/openapi.go

Verified: go build ./... clean, go test ./... all pass, go vet clean.
Three rounds of codex peer review across changes 1-5; all feedback
addressed (citations_delta, json.Number precision, drain-loop logging
via lastLog timestamp, PathPrefix tightened to "/v1/").
2026-05-02 15:15:58 -06:00

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