Spaces:
Running
Running
| package api | |
| import ( | |
| "context" | |
| "net/http" | |
| "net/http/httptest" | |
| "testing" | |
| "time" | |
| "cpa-usage-keeper/internal/cpa" | |
| "cpa-usage-keeper/internal/service" | |
| ) | |
| type usageFilterStub struct { | |
| usage *cpa.StatisticsSnapshot | |
| overview *service.UsageOverviewSnapshot | |
| err error | |
| lastFilter service.UsageFilter | |
| filterCalls int | |
| overviewCalls int | |
| } | |
| func (s *usageFilterStub) GetUsageWithFilter(_ context.Context, filter service.UsageFilter) (*cpa.StatisticsSnapshot, error) { | |
| s.lastFilter = filter | |
| s.filterCalls++ | |
| return s.usage, s.err | |
| } | |
| func (s *usageFilterStub) GetUsageOverview(_ context.Context, filter service.UsageFilter) (*service.UsageOverviewSnapshot, error) { | |
| s.lastFilter = filter | |
| s.overviewCalls++ | |
| return s.overview, s.err | |
| } | |
| func (s *usageFilterStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) { | |
| return nil, s.err | |
| } | |
| func (s *usageFilterStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) { | |
| return nil, s.err | |
| } | |
| func (s *usageFilterStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) { | |
| return nil, s.err | |
| } | |
| func (s *usageFilterStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) { | |
| return nil, s.err | |
| } | |
| func mustParseTime(t *testing.T, value string) time.Time { | |
| t.Helper() | |
| parsed, err := time.Parse(time.RFC3339, value) | |
| if err != nil { | |
| t.Fatalf("time.Parse returned error: %v", err) | |
| } | |
| return parsed | |
| } | |
| func TestUsageOverviewResponseIncludesResolvedRangeAndTimezone(t *testing.T) { | |
| previousLocal := time.Local | |
| location, err := time.LoadLocation("Asia/Shanghai") | |
| if err != nil { | |
| t.Fatalf("load location: %v", err) | |
| } | |
| t.Cleanup(func() { time.Local = previousLocal }) | |
| time.Local = location | |
| provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{}} | |
| router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") | |
| req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil) | |
| resp := httptest.NewRecorder() | |
| router.ServeHTTP(resp, req) | |
| if resp.Code != http.StatusOK { | |
| t.Fatalf("expected status 200, got %d", resp.Code) | |
| } | |
| expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC().Format(time.RFC3339Nano) | |
| expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC().Format(time.RFC3339Nano) | |
| body := resp.Body.String() | |
| if !contains(body, `"timezone":"Asia/Shanghai"`) || !contains(body, `"range_start":"`+expectedStart+`"`) || !contains(body, `"range_end":"`+expectedEnd+`"`) { | |
| t.Fatalf("expected overview response to include resolved range and timezone, got %s", body) | |
| } | |
| } | |
| func TestUsageOverviewReturnsFilteredSnapshot(t *testing.T) { | |
| provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{ | |
| Usage: &cpa.StatisticsSnapshot{ | |
| TotalRequests: 1, | |
| SuccessCount: 1, | |
| TotalTokens: 20, | |
| RequestsByHour: map[string]int64{ | |
| "2026-04-22T11:00:00Z": 1, | |
| }, | |
| TokensByHour: map[string]int64{ | |
| "2026-04-22T11:00:00Z": 20, | |
| }, | |
| APIs: map[string]cpa.APISnapshot{ | |
| "provider-a": { | |
| TotalRequests: 1, | |
| SuccessCount: 1, | |
| TotalTokens: 20, | |
| Models: map[string]cpa.ModelSnapshot{ | |
| "claude-sonnet": { | |
| TotalRequests: 1, | |
| SuccessCount: 1, | |
| TotalTokens: 20, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }, | |
| Summary: service.UsageOverviewSummary{ | |
| RequestCount: 1, | |
| TokenCount: 20, | |
| WindowMinutes: 1440, | |
| RPM: 1.0 / 1440.0, | |
| TPM: 20.0 / 1440.0, | |
| TotalCost: 0.123, | |
| CostAvailable: true, | |
| CachedTokens: 2, | |
| ReasoningTokens: 3, | |
| }, | |
| Series: service.UsageOverviewSeries{ | |
| Requests: map[string]int64{"2026-04-22T11:00:00Z": 1}, | |
| Tokens: map[string]int64{"2026-04-22T11:00:00Z": 20}, | |
| RPM: map[string]float64{"2026-04-22T11:00:00Z": 1.0 / 60.0}, | |
| TPM: map[string]float64{"2026-04-22T11:00:00Z": 20.0 / 60.0}, | |
| Cost: map[string]float64{"2026-04-22T11:00:00Z": 0.123}, | |
| InputTokens: map[string]int64{"2026-04-22T11:00:00Z": 11}, | |
| OutputTokens: map[string]int64{"2026-04-22T11:00:00Z": 7}, | |
| CachedTokens: map[string]int64{"2026-04-22T11:00:00Z": 2}, | |
| ReasoningTokens: map[string]int64{"2026-04-22T11:00:00Z": 3}, | |
| }, | |
| Health: service.UsageOverviewHealth{ | |
| TotalSuccess: 1, | |
| TotalFailure: 0, | |
| SuccessRate: 100, | |
| BlockDetails: []service.UsageOverviewHealthBlock{{ | |
| StartTime: mustParseTime(t, "2026-04-22T11:00:00Z"), | |
| EndTime: mustParseTime(t, "2026-04-22T11:15:00Z"), | |
| Success: 1, | |
| Failure: 0, | |
| Rate: 1, | |
| }}, | |
| }, | |
| }} | |
| router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") | |
| req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=24h", nil) | |
| resp := httptest.NewRecorder() | |
| router.ServeHTTP(resp, req) | |
| if resp.Code != http.StatusOK { | |
| t.Fatalf("expected status 200, got %d", resp.Code) | |
| } | |
| body := resp.Body.String() | |
| if !contains(body, `"usage":`) || !contains(body, `"total_requests":1`) { | |
| t.Fatalf("unexpected response body: %s", body) | |
| } | |
| if !contains(body, `"summary":{"request_count":1,"token_count":20`) { | |
| t.Fatalf("expected backend summary in response body: %s", body) | |
| } | |
| if !contains(body, `"cost_available":true`) { | |
| t.Fatalf("expected backend cost availability in response body: %s", body) | |
| } | |
| if !contains(body, `"series":{"requests":{"2026-04-22T11:00:00Z":1}`) { | |
| t.Fatalf("expected backend series in response body: %s", body) | |
| } | |
| if !contains(body, `"input_tokens":{"2026-04-22T11:00:00Z":11}`) || | |
| !contains(body, `"output_tokens":{"2026-04-22T11:00:00Z":7}`) || | |
| !contains(body, `"cached_tokens":{"2026-04-22T11:00:00Z":2}`) || | |
| !contains(body, `"reasoning_tokens":{"2026-04-22T11:00:00Z":3}`) { | |
| t.Fatalf("expected token breakdown series in response body: %s", body) | |
| } | |
| if !contains(body, `"service_health":{"total_success":1,"total_failure":0,"success_rate":100`) || | |
| !contains(body, `"block_details":[{"start_time":"2026-04-22T11:00:00Z","end_time":"2026-04-22T11:15:00Z","success":1,"failure":0,"rate":1}]`) { | |
| t.Fatalf("expected service health in response body: %s", body) | |
| } | |
| if contains(body, `"details":`) { | |
| t.Fatalf("expected overview response to omit request details: %s", body) | |
| } | |
| if provider.filterCalls != 0 { | |
| t.Fatalf("expected GetUsageWithFilter not to be called, got %d", provider.filterCalls) | |
| } | |
| if provider.overviewCalls != 1 { | |
| t.Fatalf("expected GetUsageOverview to be called once, got %d", provider.overviewCalls) | |
| } | |
| if provider.lastFilter.Range != "24h" { | |
| t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter) | |
| } | |
| if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil { | |
| t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter) | |
| } | |
| } | |