* feat(usage): 统一时间区间统计与快捷筛选 * fix(usage): 精简时间筛选与趋势提示布局 * fix(usage): 保留未修改时间端点的原始时刻
988 lines
37 KiB
Go
988 lines
37 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/platform/config"
|
|
"gpt-load/internal/requestlog"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
func TestUsageAPIRouteUsesManagementAuthentication(t *testing.T) {
|
|
t.Parallel()
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.now = func() time.Time {
|
|
return time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
fixture.service.usageStats = &recordingUsageStatReader{}
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/api/usage?"+usageTestDayQuery, nil)
|
|
request.Header.Set("Authorization", "Bearer test-auth-key")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/usage = %d %s, want 200", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIUsesSuppliedWindowAndPreservesFilters(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, time.September, 7, 14, 5, 23, 123_000_000, time.UTC)
|
|
reader := &recordingUsageStatReader{}
|
|
engine, fixture := newUsageTestEngine(t, now, reader)
|
|
fixture.service.now = func() time.Time { return now }
|
|
for _, observation := range []time.Time{now, now.Add(2 * time.Minute)} {
|
|
now = observation
|
|
recorder := performUsageRequest(engine, "test-auth-key",
|
|
usageTestTimeQuery(now.Add(-time.Hour), now)+"&group_id=7&channel_id=openai&credential_id=11&upstream_model=usage-model")
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("rolling usage response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var envelope struct {
|
|
Data usageResponse `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode rolling usage response: %v", err)
|
|
}
|
|
data := envelope.Data
|
|
if data.Granularity != "minute" ||
|
|
data.BucketWidthMS != int64(5*time.Minute/time.Millisecond) ||
|
|
data.FromMS != now.Add(-time.Hour).UnixMilli() || data.ToMS != now.UnixMilli() ||
|
|
data.ObservedAtMS != now.UnixMilli() {
|
|
t.Fatalf("rolling usage response window = %+v", data)
|
|
}
|
|
query := reader.queries[len(reader.queries)-1]
|
|
if query.GroupID == nil || *query.GroupID != 7 || query.ChannelID != "openai" ||
|
|
query.CredentialID == nil || *query.CredentialID != 11 ||
|
|
query.UpstreamModel != "usage-model" {
|
|
t.Fatalf("rolling usage filters = %+v", query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIReturnsDistributionWithoutCredentialIdentity(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, time.August, 12, 8, 0, 0, 0, time.UTC)
|
|
other := requestlog.UsageDistributionAggregate{
|
|
RequestCount: 3,
|
|
EstimatedCostNanoUSD: 300_000_000,
|
|
}
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Summary: requestlog.UsageAggregate{
|
|
RequestCount: 13, SuccessCount: 11, FailureCount: 2,
|
|
EstimatedCostNanoUSD: 2_300_000_000,
|
|
},
|
|
Distributions: usageTestDistributions(
|
|
requestlog.UsageAggregate{
|
|
RequestCount: 13, SuccessCount: 11, FailureCount: 2,
|
|
EstimatedCostNanoUSD: 2_300_000_000,
|
|
},
|
|
requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionGroup,
|
|
Metric: requestlog.UsageDistributionMetricCost,
|
|
Items: []requestlog.UsageDistributionItem{{
|
|
GroupID: 7,
|
|
UsageDistributionAggregate: requestlog.UsageDistributionAggregate{
|
|
RequestCount: 10,
|
|
EstimatedCostNanoUSD: 2_000_000_000,
|
|
},
|
|
}},
|
|
Other: &other,
|
|
},
|
|
),
|
|
}}
|
|
engine, _ := newUsageTestEngine(t, now, reader)
|
|
recorder := performUsageRequest(
|
|
engine,
|
|
"test-auth-key",
|
|
usageTestDayQuery,
|
|
)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 {
|
|
t.Fatalf("usage query = %#v", reader.queries)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Distributions struct {
|
|
Group struct {
|
|
Cost struct {
|
|
Dimension string `json:"dimension"`
|
|
Metric string `json:"metric"`
|
|
Items []struct {
|
|
GroupID uint `json:"group_id"`
|
|
RequestCount int64 `json:"request_count"`
|
|
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
|
} `json:"items"`
|
|
Other *struct {
|
|
RequestCount int64 `json:"request_count"`
|
|
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
|
} `json:"other"`
|
|
} `json:"cost"`
|
|
} `json:"group"`
|
|
} `json:"distributions"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
distribution := envelope.Data.Distributions.Group.Cost
|
|
if distribution.Dimension != "group" ||
|
|
distribution.Metric != "cost" ||
|
|
len(distribution.Items) != 1 ||
|
|
distribution.Items[0].GroupID != 7 ||
|
|
distribution.Items[0].RequestCount != 10 ||
|
|
distribution.Items[0].EstimatedCostNanoUSD != "2000000000" ||
|
|
distribution.Other == nil ||
|
|
distribution.Other.RequestCount != 3 {
|
|
t.Fatalf("distribution response = %#v", distribution)
|
|
}
|
|
for _, forbidden := range []string{`"credential_id"`, `"channel_id"`} {
|
|
if strings.Contains(recorder.Body.String(), forbidden) {
|
|
t.Fatalf("usage response exposes removed field %s: %s", forbidden, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIReturnsExplicitWindowAndZeroArrays(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, time.July, 27, 12, 34, 56, 789, time.FixedZone("UTC+8", 8*60*60))
|
|
reader := &recordingUsageStatReader{}
|
|
engine, fixture := newUsageTestEngine(t, now, reader)
|
|
fixture.requestLogStats.value.DroppedTotal = 2
|
|
fixture.requestLogStats.value.WriteFailureTotal = 1
|
|
fixture.requestLogStats.value.LastWriteFailureAt = time.Date(
|
|
2026, time.July, 27, 3, 0, 0, 0, time.UTC,
|
|
)
|
|
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 {
|
|
t.Fatalf("QueryUsage calls = %d, want one", len(reader.queries))
|
|
}
|
|
query := reader.queries[0]
|
|
if query.Granularity != requestlog.UsageGranularityHour ||
|
|
query.FromMS != time.Date(2026, time.July, 26, 5, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
query.ToMS != time.Date(2026, time.July, 27, 5, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
query.GroupID != nil || query.UpstreamModel != "" {
|
|
t.Fatalf("default UsageQuery = %#v", query)
|
|
}
|
|
var envelope struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
Granularity requestlog.UsageGranularity `json:"granularity"`
|
|
FromMS int64 `json:"from_ms"`
|
|
ToMS int64 `json:"to_ms"`
|
|
ObservedAtMS int64 `json:"observed_at_ms"`
|
|
Series []json.RawMessage `json:"series"`
|
|
Distributions struct {
|
|
Model struct {
|
|
Cost struct {
|
|
Items []json.RawMessage `json:"items"`
|
|
Other json.RawMessage `json:"other"`
|
|
} `json:"cost"`
|
|
} `json:"model"`
|
|
} `json:"distributions"`
|
|
Health struct {
|
|
Scope string `json:"scope"`
|
|
DroppedTotal uint64 `json:"dropped_total"`
|
|
WriteFailureTotal uint64 `json:"write_failure_total"`
|
|
LastWriteFailureAtMS *int64 `json:"last_write_failure_at_ms"`
|
|
} `json:"collection_health"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if envelope.Code != 0 ||
|
|
envelope.Data.Granularity != requestlog.UsageGranularityHour ||
|
|
envelope.Data.FromMS != time.Date(2026, time.July, 26, 5, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
envelope.Data.ToMS != time.Date(2026, time.July, 27, 5, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
envelope.Data.ObservedAtMS != now.UnixMilli() ||
|
|
envelope.Data.Series == nil || len(envelope.Data.Series) != 0 ||
|
|
envelope.Data.Distributions.Model.Cost.Items == nil ||
|
|
len(envelope.Data.Distributions.Model.Cost.Items) != 0 ||
|
|
string(envelope.Data.Distributions.Model.Cost.Other) != "null" ||
|
|
envelope.Data.Health.Scope != "current_process" ||
|
|
envelope.Data.Health.DroppedTotal != 2 ||
|
|
envelope.Data.Health.WriteFailureTotal != 1 ||
|
|
envelope.Data.Health.LastWriteFailureAtMS == nil ||
|
|
*envelope.Data.Health.LastWriteFailureAtMS != time.Date(2026, time.July, 27, 3, 0, 0, 0, time.UTC).UnixMilli() {
|
|
t.Fatalf("default usage envelope = %#v", envelope)
|
|
}
|
|
var rawEnvelope struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &rawEnvelope); err != nil {
|
|
t.Fatalf("decode raw response: %v", err)
|
|
}
|
|
for _, forbidden := range []string{
|
|
"range", "filters", "request_log", "timezone", "from", "to", "observed_at",
|
|
"estimated_cost" + "_usd",
|
|
} {
|
|
if _, exists := rawEnvelope.Data[forbidden]; exists {
|
|
t.Fatalf("usage response exposes forbidden %q field: %s", forbidden, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIReturnsAllDistributionViewsInOneResponse(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, time.August, 12, 8, 0, 0, 0, time.UTC)
|
|
reader := &recordingUsageStatReader{}
|
|
engine, _ := newUsageTestEngine(t, now, reader)
|
|
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 {
|
|
t.Fatalf("QueryUsage calls = %d, want one", len(reader.queries))
|
|
}
|
|
|
|
var envelope struct {
|
|
Data struct {
|
|
Distribution json.RawMessage `json:"distribution"`
|
|
Distributions struct {
|
|
Group struct {
|
|
Requests json.RawMessage `json:"requests"`
|
|
Tokens json.RawMessage `json:"tokens"`
|
|
Cost json.RawMessage `json:"cost"`
|
|
} `json:"group"`
|
|
Model struct {
|
|
Requests json.RawMessage `json:"requests"`
|
|
Tokens json.RawMessage `json:"tokens"`
|
|
Cost json.RawMessage `json:"cost"`
|
|
} `json:"model"`
|
|
AccessKey struct {
|
|
Requests json.RawMessage `json:"requests"`
|
|
Tokens json.RawMessage `json:"tokens"`
|
|
Cost json.RawMessage `json:"cost"`
|
|
} `json:"access_key"`
|
|
} `json:"distributions"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(envelope.Data.Distribution) != 0 ||
|
|
len(envelope.Data.Distributions.Group.Requests) == 0 ||
|
|
len(envelope.Data.Distributions.Group.Tokens) == 0 ||
|
|
len(envelope.Data.Distributions.Group.Cost) == 0 ||
|
|
len(envelope.Data.Distributions.Model.Requests) == 0 ||
|
|
len(envelope.Data.Distributions.Model.Tokens) == 0 ||
|
|
len(envelope.Data.Distributions.Model.Cost) == 0 ||
|
|
len(envelope.Data.Distributions.AccessKey.Requests) == 0 ||
|
|
len(envelope.Data.Distributions.AccessKey.Tokens) == 0 ||
|
|
len(envelope.Data.Distributions.AccessKey.Cost) == 0 {
|
|
t.Fatalf("usage distributions = %#v; body=%s", envelope.Data.Distributions, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageAPISelectsThirtyUTCDaysAndAppliesFilters(t *testing.T) {
|
|
t.Parallel()
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Summary: requestlog.UsageAggregate{
|
|
RequestCount: 1, UncachedInputTokens: 2, CacheWriteUnknownTokens: 4,
|
|
OutputTokens: 3, PricingPartialCount: 1,
|
|
EstimatedCostNanoUSD: 250_000_000,
|
|
},
|
|
Series: []requestlog.UsageSeriesPoint{{
|
|
BucketStartMS: time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
|
BucketEndMS: time.Date(2026, time.June, 29, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
|
UsageAggregate: requestlog.UsageAggregate{
|
|
RequestCount: 1, UncachedInputTokens: 2, CacheWriteUnknownTokens: 4,
|
|
OutputTokens: 3, PricingPartialCount: 1,
|
|
EstimatedCostNanoUSD: 1_123_456_789_012,
|
|
},
|
|
}},
|
|
Distributions: usageTestDistributions(
|
|
requestlog.UsageAggregate{
|
|
RequestCount: 1, UncachedInputTokens: 2, CacheWriteUnknownTokens: 4,
|
|
OutputTokens: 3, PricingPartialCount: 1,
|
|
EstimatedCostNanoUSD: 250_000_000,
|
|
},
|
|
requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionModel,
|
|
Metric: requestlog.UsageDistributionMetricCost,
|
|
Items: []requestlog.UsageDistributionItem{{
|
|
Model: "upstream-model",
|
|
UsageDistributionAggregate: requestlog.UsageDistributionAggregate{
|
|
RequestCount: 1,
|
|
TotalTokens: 9,
|
|
EstimatedCostNanoUSD: 250_000_000,
|
|
},
|
|
}},
|
|
},
|
|
),
|
|
}}
|
|
engine, _ := newUsageTestEngine(t, now, reader)
|
|
recorder := performUsageRequest(
|
|
engine,
|
|
"test-auth-key",
|
|
"from_ms=1782604800000&to_ms=1785196800000&group_id=9&channel_id=openai&credential_id=13&upstream_model=upstream-model",
|
|
)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 {
|
|
t.Fatalf("QueryUsage calls = %d, want one", len(reader.queries))
|
|
}
|
|
query := reader.queries[0]
|
|
if query.Granularity != requestlog.UsageGranularityDay ||
|
|
query.FromMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
query.ToMS != time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
query.GroupID == nil || *query.GroupID != 9 ||
|
|
query.ChannelID != channel.OpenAI ||
|
|
query.CredentialID == nil || *query.CredentialID != 13 ||
|
|
query.UpstreamModel != "upstream-model" {
|
|
t.Fatalf("30 day UsageQuery = %#v", query)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Granularity requestlog.UsageGranularity `json:"granularity"`
|
|
FromMS int64 `json:"from_ms"`
|
|
ToMS int64 `json:"to_ms"`
|
|
Summary struct {
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
CacheWriteUnknownTokens int64 `json:"cache_write_unknown_tokens"`
|
|
PricingPartialCount int64 `json:"pricing_partial_count"`
|
|
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
|
} `json:"summary"`
|
|
Series []struct {
|
|
BucketStartMS int64 `json:"bucket_start_ms"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
CacheWriteUnknownTokens int64 `json:"cache_write_unknown_tokens"`
|
|
PricingPartialCount int64 `json:"pricing_partial_count"`
|
|
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
|
} `json:"series"`
|
|
Distributions struct {
|
|
Model struct {
|
|
Cost struct {
|
|
Dimension string `json:"dimension"`
|
|
Metric string `json:"metric"`
|
|
Items []struct {
|
|
Model string `json:"model"`
|
|
RequestCount int64 `json:"request_count"`
|
|
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
|
} `json:"items"`
|
|
} `json:"cost"`
|
|
} `json:"model"`
|
|
} `json:"distributions"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if envelope.Data.Granularity != requestlog.UsageGranularityDay ||
|
|
envelope.Data.FromMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
envelope.Data.ToMS != time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
envelope.Data.Summary.TotalTokens != 9 ||
|
|
envelope.Data.Summary.CacheWriteUnknownTokens != 4 ||
|
|
envelope.Data.Summary.PricingPartialCount != 1 ||
|
|
envelope.Data.Summary.EstimatedCostNanoUSD != "250000000" ||
|
|
len(envelope.Data.Series) != 1 ||
|
|
envelope.Data.Series[0].BucketStartMS != time.Date(2026, time.June, 28, 0, 0, 0, 0, time.UTC).UnixMilli() ||
|
|
envelope.Data.Series[0].TotalTokens != 9 ||
|
|
envelope.Data.Series[0].CacheWriteUnknownTokens != 4 ||
|
|
envelope.Data.Series[0].PricingPartialCount != 1 ||
|
|
envelope.Data.Series[0].EstimatedCostNanoUSD != "1123456789012" ||
|
|
envelope.Data.Distributions.Model.Cost.Dimension != "model" ||
|
|
envelope.Data.Distributions.Model.Cost.Metric != "cost" ||
|
|
len(envelope.Data.Distributions.Model.Cost.Items) != 1 ||
|
|
envelope.Data.Distributions.Model.Cost.Items[0].Model != "upstream-model" ||
|
|
envelope.Data.Distributions.Model.Cost.Items[0].RequestCount != 1 ||
|
|
envelope.Data.Distributions.Model.Cost.Items[0].EstimatedCostNanoUSD != "250000000" {
|
|
t.Fatalf("usage response = %#v", envelope.Data)
|
|
}
|
|
for _, legacyField := range []string{`"key_id"`, `"upstream_key_id"`} {
|
|
if strings.Contains(recorder.Body.String(), legacyField) {
|
|
t.Fatalf("usage response exposes legacy field %s: %s", legacyField, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIValidatesModelAsUTF8BytesWithoutBoundaryWhitespaceOrControls(t *testing.T) {
|
|
t.Parallel()
|
|
reader := &recordingUsageStatReader{}
|
|
engine, _ := newUsageTestEngine(
|
|
t,
|
|
time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC),
|
|
reader,
|
|
)
|
|
validModels := []string{
|
|
"a",
|
|
strings.Repeat("a", 252) + "猫",
|
|
"model variant",
|
|
}
|
|
for _, model := range validModels {
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery+"&upstream_model="+url.QueryEscape(model))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("valid model %q response = %d %s", model, recorder.Code, recorder.Body.String())
|
|
}
|
|
if got := reader.queries[len(reader.queries)-1].UpstreamModel; got != model {
|
|
t.Fatalf("reader model = %q, want %q", got, model)
|
|
}
|
|
}
|
|
validCalls := len(reader.queries)
|
|
|
|
invalidModels := []struct {
|
|
name string
|
|
query string
|
|
}{
|
|
{name: "invalid UTF-8", query: "upstream_model=%FF"},
|
|
{name: "leading whitespace", query: "upstream_model=" + url.QueryEscape(" model")},
|
|
{name: "trailing whitespace", query: "upstream_model=" + url.QueryEscape("model\u3000")},
|
|
{name: "embedded control", query: "upstream_model=" + url.QueryEscape("model\x00id")},
|
|
{name: "DEL control", query: "upstream_model=" + url.QueryEscape("model\x7fid")},
|
|
{name: "256 UTF-8 bytes", query: "upstream_model=" + url.QueryEscape(strings.Repeat("a", 253)+"猫")},
|
|
}
|
|
for _, test := range invalidModels {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery+"&"+test.query)
|
|
assertUsageErrorCode(t, recorder, "VALIDATION_FAILED")
|
|
})
|
|
}
|
|
if len(reader.queries) != validCalls {
|
|
t.Fatalf("reader calls = %d, want %d after invalid models", len(reader.queries), validCalls)
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsStrictInvalidQueriesWithoutCallingReader(t *testing.T) {
|
|
t.Parallel()
|
|
reader := &recordingUsageStatReader{}
|
|
engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader)
|
|
tests := []struct {
|
|
query string
|
|
code string
|
|
}{
|
|
{query: "unknown=1", code: "BAD_REQUEST"},
|
|
{query: "range=24h&range=30d", code: "BAD_REQUEST"},
|
|
{query: "at_ms=", code: "BAD_REQUEST"},
|
|
{query: "at_ms=-1", code: "BAD_REQUEST"},
|
|
{query: "at_ms=01", code: "BAD_REQUEST"},
|
|
{query: "at_ms=1.5", code: "BAD_REQUEST"},
|
|
{query: "at_ms=9007199254740991", code: "BAD_REQUEST"},
|
|
{query: "at_ms=9007199254740992", code: "BAD_REQUEST"},
|
|
{query: "at_ms=3600000&at_ms=7200000", code: "BAD_REQUEST"},
|
|
{query: "at_ms=3600000&from_ms=0&to_ms=3600000", code: "BAD_REQUEST"},
|
|
{query: "from=1&to=2", code: "BAD_REQUEST"},
|
|
{query: "from_ms=1", code: "VALIDATION_FAILED"},
|
|
{query: "from_ms=01&to_ms=2", code: "BAD_REQUEST"},
|
|
{query: "from_ms=-1&to_ms=2", code: "BAD_REQUEST"},
|
|
{query: "from_ms=1&to_ms=1", code: "VALIDATION_FAILED"},
|
|
{query: "range=24h&from_ms=1&to_ms=2", code: "BAD_REQUEST"},
|
|
{query: "group_id=0", code: "VALIDATION_FAILED"},
|
|
{query: "group_id=01", code: "BAD_REQUEST"},
|
|
{query: "group_id=%2B1", code: "BAD_REQUEST"},
|
|
{query: "group_id=%201", code: "BAD_REQUEST"},
|
|
{query: "group_id=1&group_id=2", code: "BAD_REQUEST"},
|
|
{query: "group_id=9007199254740992", code: "VALIDATION_FAILED"},
|
|
{query: "group_id=-1", code: "BAD_REQUEST"},
|
|
{query: "channel_id=", code: "VALIDATION_FAILED"},
|
|
{query: "channel_id=unknown", code: "VALIDATION_FAILED"},
|
|
{query: "channel_id=openai&channel_id=anthropic", code: "BAD_REQUEST"},
|
|
{query: "credential_id=0", code: "VALIDATION_FAILED"},
|
|
{query: "credential_id=01", code: "BAD_REQUEST"},
|
|
{query: "credential_id=9007199254740992", code: "VALIDATION_FAILED"},
|
|
{query: "credential_id=1&credential_id=2", code: "BAD_REQUEST"},
|
|
{query: "key_id=1", code: "BAD_REQUEST"},
|
|
{query: "upstream_key_id=1", code: "BAD_REQUEST"},
|
|
{query: "model=legacy", code: "BAD_REQUEST"},
|
|
{query: "upstream_model=", code: "VALIDATION_FAILED"},
|
|
{query: "distribution=", code: "BAD_REQUEST"},
|
|
{query: "distribution=credential", code: "BAD_REQUEST"},
|
|
{query: "distribution_metric=", code: "BAD_REQUEST"},
|
|
{query: "distribution_metric=tokens", code: "BAD_REQUEST"},
|
|
{
|
|
query: "distribution_metric=cost&distribution_metric=requests",
|
|
code: "BAD_REQUEST",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.query, func(t *testing.T) {
|
|
query := test.query
|
|
if !strings.Contains(query, "from_ms") && !strings.Contains(query, "to_ms") {
|
|
query = usageTestDayQuery + "&" + query
|
|
}
|
|
recorder := performUsageRequest(engine, "test-auth-key", query)
|
|
assertUsageErrorCode(t, recorder, test.code)
|
|
})
|
|
}
|
|
if len(reader.queries) != 0 {
|
|
t.Fatalf("QueryUsage calls = %d, want zero", len(reader.queries))
|
|
}
|
|
}
|
|
|
|
func TestMapUsageRejectsUnsafeOrMismatchedDistribution(t *testing.T) {
|
|
t.Parallel()
|
|
fixture := newServiceFixture(t)
|
|
query := requestlog.UsageQuery{
|
|
FromMS: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
|
ToMS: time.Date(2026, time.July, 28, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
|
Granularity: requestlog.UsageGranularityHour,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
report requestlog.UsageReport
|
|
}{
|
|
{
|
|
name: "response dimension mismatch",
|
|
report: requestlog.UsageReport{
|
|
Distributions: usageTestDistributionsWithOverride(
|
|
requestlog.UsageAggregate{},
|
|
requestlog.UsageDistributionDimensionGroup,
|
|
requestlog.UsageDistributionMetricRequests,
|
|
requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionModel,
|
|
Metric: requestlog.UsageDistributionMetricRequests,
|
|
},
|
|
),
|
|
},
|
|
},
|
|
{
|
|
name: "response metric mismatch",
|
|
report: requestlog.UsageReport{
|
|
Distributions: usageTestDistributionsWithOverride(
|
|
requestlog.UsageAggregate{},
|
|
requestlog.UsageDistributionDimensionGroup,
|
|
requestlog.UsageDistributionMetricRequests,
|
|
requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionGroup,
|
|
Metric: requestlog.UsageDistributionMetricCost,
|
|
},
|
|
),
|
|
},
|
|
},
|
|
{
|
|
name: "unsafe group identity",
|
|
report: requestlog.UsageReport{
|
|
Distributions: usageTestDistributions(requestlog.UsageAggregate{}, requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionGroup,
|
|
Metric: requestlog.UsageDistributionMetricRequests,
|
|
Items: []requestlog.UsageDistributionItem{{
|
|
GroupID: uint(maxSafeInteger) + 1,
|
|
}},
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
name: "more than five items",
|
|
report: requestlog.UsageReport{
|
|
Distributions: usageTestDistributions(requestlog.UsageAggregate{}, requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionGroup,
|
|
Metric: requestlog.UsageDistributionMetricRequests,
|
|
Items: []requestlog.UsageDistributionItem{
|
|
{GroupID: 1}, {GroupID: 2}, {GroupID: 3},
|
|
{GroupID: 4}, {GroupID: 5}, {GroupID: 6},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate group identity",
|
|
report: requestlog.UsageReport{
|
|
Distributions: usageTestDistributions(requestlog.UsageAggregate{}, requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionGroup,
|
|
Metric: requestlog.UsageDistributionMetricRequests,
|
|
Items: []requestlog.UsageDistributionItem{{GroupID: 1}, {GroupID: 1}},
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
name: "distribution total mismatch",
|
|
report: requestlog.UsageReport{
|
|
Summary: requestlog.UsageAggregate{RequestCount: 2, SuccessCount: 2},
|
|
Distributions: usageTestDistributions(
|
|
requestlog.UsageAggregate{RequestCount: 2, SuccessCount: 2},
|
|
requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionGroup,
|
|
Metric: requestlog.UsageDistributionMetricRequests,
|
|
Items: []requestlog.UsageDistributionItem{{
|
|
GroupID: 1,
|
|
UsageDistributionAggregate: requestlog.UsageDistributionAggregate{
|
|
RequestCount: 1,
|
|
},
|
|
}},
|
|
},
|
|
),
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if _, err := fixture.service.mapUsageResponse(
|
|
query.ToMS,
|
|
query,
|
|
test.report,
|
|
); err == nil {
|
|
t.Fatal("mapUsageResponse() error = nil, want invalid metadata rejection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsUnsafeAggregateAndKeepsErrorsSecret(t *testing.T) {
|
|
t.Parallel()
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Summary: requestlog.UsageAggregate{RequestCount: 9_007_199_254_740_992},
|
|
}}
|
|
engine, _ := newUsageTestEngine(t, time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC), reader)
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
|
|
if recorder.Code != http.StatusInternalServerError ||
|
|
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
|
|
strings.Contains(recorder.Body.String(), "unsafe") {
|
|
t.Fatalf("unsafe response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
reader.err = errors.New("usage database secret")
|
|
reader.report = requestlog.UsageReport{}
|
|
recorder = performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
|
|
if recorder.Code != http.StatusInternalServerError || strings.Contains(recorder.Body.String(), "usage database secret") {
|
|
t.Fatalf("reader error response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRejectsUnsafeProcessStatsWithoutLeakingCause(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*requestlog.Stats)
|
|
}{
|
|
{name: "dropped total", mutate: func(stats *requestlog.Stats) { stats.DroppedTotal = uint64(maxSafeInteger) + 1 }},
|
|
{name: "write failure", mutate: func(stats *requestlog.Stats) { stats.WriteFailureTotal = uint64(maxSafeInteger) + 1 }},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
engine, fixture := newUsageTestEngine(
|
|
t,
|
|
time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC),
|
|
&recordingUsageStatReader{},
|
|
)
|
|
test.mutate(&fixture.requestLogStats.value)
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery)
|
|
if recorder.Code != http.StatusInternalServerError ||
|
|
!strings.Contains(recorder.Body.String(), "INTERNAL_SERVER_ERROR") ||
|
|
strings.Contains(strings.ToLower(recorder.Body.String()), "queue") ||
|
|
strings.Contains(strings.ToLower(recorder.Body.String()), "safe") {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIExcludesLegacyZeroAttemptAggregateFromSQLite(t *testing.T) {
|
|
t.Parallel()
|
|
initControlI18n(t)
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
fixture := newServiceFixture(t)
|
|
if err := fixture.db.Create(&models.UsageStat{
|
|
BucketStartMS: now.Add(-time.Hour).UnixMilli(),
|
|
AccessKeyID: 1,
|
|
GroupID: 0,
|
|
Model: "",
|
|
RequestCount: 1,
|
|
FailureCount: 1,
|
|
}).Error; err != nil {
|
|
t.Fatalf("create legacy zero-attempt UsageStat: %v", err)
|
|
}
|
|
fixture.service.now = func() time.Time { return now }
|
|
fixture.service.usageStats = requestlog.NewService(fixture.db, nil, nil)
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestTimeQuery(now.Add(-24*time.Hour), now))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Summary struct {
|
|
RequestCount int64 `json:"request_count"`
|
|
FailureCount int64 `json:"failure_count"`
|
|
} `json:"summary"`
|
|
Distributions struct {
|
|
Model struct {
|
|
Cost struct {
|
|
Items []json.RawMessage `json:"items"`
|
|
Other json.RawMessage `json:"other"`
|
|
} `json:"cost"`
|
|
} `json:"model"`
|
|
} `json:"distributions"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if envelope.Data.Summary.RequestCount != 0 || envelope.Data.Summary.FailureCount != 0 ||
|
|
len(envelope.Data.Distributions.Model.Cost.Items) != 0 ||
|
|
string(envelope.Data.Distributions.Model.Cost.Other) != "null" {
|
|
t.Fatalf("usage data = %#v", envelope.Data)
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIAcceptsMaximumSafeCanonicalGroupID(t *testing.T) {
|
|
t.Parallel()
|
|
if strconv.IntSize != 64 {
|
|
t.Skip("maximum JavaScript safe integer does not fit uint on this architecture")
|
|
}
|
|
reader := &recordingUsageStatReader{}
|
|
engine, _ := newUsageTestEngine(
|
|
t,
|
|
time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC),
|
|
reader,
|
|
)
|
|
recorder := performUsageRequest(engine, "test-auth-key", usageTestDayQuery+"&group_id=9007199254740991")
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 || reader.queries[0].GroupID == nil ||
|
|
uint64(*reader.queries[0].GroupID) != uint64(maxSafeInteger) {
|
|
t.Fatalf("QueryUsage calls = %#v", reader.queries)
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIRequiresManagementAuthentication(t *testing.T) {
|
|
t.Parallel()
|
|
engine, _ := newUsageTestEngine(t, time.Now(), &recordingUsageStatReader{})
|
|
recorder := performUsageRequest(engine, "", "")
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("response = %d %s, want 401", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUsageAPIBindsAccessKeyScopeAndRedactsProcessHealth(t *testing.T) {
|
|
t.Parallel()
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
|
|
Name: "usage viewer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateAccessKey() error = %v", err)
|
|
}
|
|
now := time.Date(2026, time.August, 8, 17, 30, 0, 0, time.UTC)
|
|
reader := &recordingUsageStatReader{report: requestlog.UsageReport{
|
|
Distributions: usageTestDistributions(requestlog.UsageAggregate{}, requestlog.UsageDistribution{
|
|
Dimension: requestlog.UsageDistributionDimensionModel,
|
|
Metric: requestlog.UsageDistributionMetricRequests,
|
|
Items: []requestlog.UsageDistributionItem{{Model: "allowed-model"}},
|
|
}),
|
|
}}
|
|
fixture.service.now = func() time.Time { return now }
|
|
fixture.service.usageStats = reader
|
|
fixture.requestLogStats.value.DroppedTotal = 100
|
|
fixture.requestLogStats.value.WriteFailureTotal = 50
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
|
|
recorder := performUsageRequest(engine, created.Key, usageTestTimeQuery(now.AddDate(0, 0, -7), now)+"&upstream_model=allowed-model")
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("AccessKey usage response = %d %s, want 200", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(reader.queries) != 1 || reader.queries[0].AccessKeyID == nil ||
|
|
*reader.queries[0].AccessKeyID != created.ID || reader.queries[0].GroupID != nil {
|
|
t.Fatalf("AccessKey UsageQuery = %#v", reader.queries)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Distributions struct {
|
|
Model struct {
|
|
Cost struct {
|
|
Dimension string `json:"dimension"`
|
|
Items []struct {
|
|
Model string `json:"model"`
|
|
} `json:"items"`
|
|
} `json:"cost"`
|
|
} `json:"model"`
|
|
} `json:"distributions"`
|
|
CollectionHealth usageCollectionHealthResponse `json:"collection_health"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode AccessKey usage response: %v", err)
|
|
}
|
|
distribution := envelope.Data.Distributions.Model.Cost
|
|
if distribution.Dimension != "model" ||
|
|
len(distribution.Items) != 1 ||
|
|
distribution.Items[0].Model != "allowed-model" ||
|
|
envelope.Data.CollectionHealth.Scope != "access_key" ||
|
|
envelope.Data.CollectionHealth.DroppedTotal != 0 ||
|
|
envelope.Data.CollectionHealth.WriteFailureTotal != 0 ||
|
|
envelope.Data.CollectionHealth.LastWriteFailureAtMS != nil {
|
|
t.Fatalf("AccessKey usage redaction = %#v", envelope.Data)
|
|
}
|
|
|
|
for _, filter := range []string{"group_id=1", "channel_id=openai", "credential_id=101"} {
|
|
forbidden := performUsageRequest(engine, created.Key, usageTestDayQuery+"&"+filter)
|
|
if forbidden.Code != http.StatusBadRequest || len(reader.queries) != 1 {
|
|
t.Fatalf(
|
|
"AccessKey internal filter %q = %d %s, calls=%d, want 400/no query",
|
|
filter,
|
|
forbidden.Code,
|
|
forbidden.Body.String(),
|
|
len(reader.queries),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
type recordingUsageStatReader struct {
|
|
queries []requestlog.UsageQuery
|
|
report requestlog.UsageReport
|
|
err error
|
|
}
|
|
|
|
func usageTestDistributions(
|
|
summary requestlog.UsageAggregate,
|
|
source requestlog.UsageDistribution,
|
|
) requestlog.UsageDistributions {
|
|
result := requestlog.UsageDistributions{
|
|
Group: make(map[requestlog.UsageDistributionMetric]requestlog.UsageDistribution, 3),
|
|
Model: make(map[requestlog.UsageDistributionMetric]requestlog.UsageDistribution, 3),
|
|
AccessKey: make(map[requestlog.UsageDistributionMetric]requestlog.UsageDistribution, 3),
|
|
}
|
|
totalTokens := summary.UncachedInputTokens + summary.CacheReadTokens +
|
|
summary.CacheWrite5MTokens + summary.CacheWrite1HTokens +
|
|
summary.CacheWriteUnknownTokens + summary.OutputTokens
|
|
for _, dimension := range []requestlog.UsageDistributionDimension{
|
|
requestlog.UsageDistributionDimensionGroup,
|
|
requestlog.UsageDistributionDimensionModel,
|
|
requestlog.UsageDistributionDimensionAccessKey,
|
|
} {
|
|
for _, metric := range []requestlog.UsageDistributionMetric{
|
|
requestlog.UsageDistributionMetricRequests,
|
|
requestlog.UsageDistributionMetricTokens,
|
|
requestlog.UsageDistributionMetricCost,
|
|
} {
|
|
distribution := requestlog.UsageDistribution{
|
|
Dimension: dimension,
|
|
Metric: metric,
|
|
}
|
|
if source.Dimension == dimension {
|
|
distribution = source
|
|
distribution.Dimension = dimension
|
|
distribution.Metric = metric
|
|
} else if summary.RequestCount > 0 || totalTokens > 0 || summary.EstimatedCostNanoUSD > 0 {
|
|
other := requestlog.UsageDistributionAggregate{
|
|
RequestCount: summary.RequestCount,
|
|
TotalTokens: totalTokens,
|
|
EstimatedCostNanoUSD: summary.EstimatedCostNanoUSD,
|
|
}
|
|
distribution.Other = &other
|
|
}
|
|
switch dimension {
|
|
case requestlog.UsageDistributionDimensionGroup:
|
|
result.Group[metric] = distribution
|
|
case requestlog.UsageDistributionDimensionModel:
|
|
result.Model[metric] = distribution
|
|
case requestlog.UsageDistributionDimensionAccessKey:
|
|
result.AccessKey[metric] = distribution
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func usageTestDistributionsWithOverride(
|
|
summary requestlog.UsageAggregate,
|
|
dimension requestlog.UsageDistributionDimension,
|
|
metric requestlog.UsageDistributionMetric,
|
|
distribution requestlog.UsageDistribution,
|
|
) requestlog.UsageDistributions {
|
|
result := usageTestDistributions(summary, requestlog.UsageDistribution{})
|
|
switch dimension {
|
|
case requestlog.UsageDistributionDimensionGroup:
|
|
result.Group[metric] = distribution
|
|
case requestlog.UsageDistributionDimensionModel:
|
|
result.Model[metric] = distribution
|
|
case requestlog.UsageDistributionDimensionAccessKey:
|
|
result.AccessKey[metric] = distribution
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (reader *recordingUsageStatReader) QueryUsage(
|
|
_ context.Context,
|
|
query requestlog.UsageQuery,
|
|
) (requestlog.UsageReport, error) {
|
|
reader.queries = append(reader.queries, query)
|
|
if reader.err != nil {
|
|
return requestlog.UsageReport{}, reader.err
|
|
}
|
|
report := reader.report
|
|
if len(report.Distributions.Group) == 0 && len(report.Distributions.Model) == 0 &&
|
|
len(report.Distributions.AccessKey) == 0 {
|
|
report.Distributions = usageTestDistributions(report.Summary, requestlog.UsageDistribution{})
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func newUsageTestEngine(
|
|
t *testing.T,
|
|
now time.Time,
|
|
reader *recordingUsageStatReader,
|
|
) (*gin.Engine, serviceFixture) {
|
|
t.Helper()
|
|
initControlI18n(t)
|
|
fixture := newServiceFixture(t)
|
|
fixture.service.now = func() time.Time { return now }
|
|
fixture.service.usageStats = reader
|
|
engine := gin.New()
|
|
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
|
return engine, fixture
|
|
}
|
|
|
|
const usageTestDayQuery = "from_ms=1785042000000&to_ms=1785128400000"
|
|
|
|
func usageTestTimeQuery(from, to time.Time) string {
|
|
return fmt.Sprintf("from_ms=%d&to_ms=%d", from.UnixMilli(), to.UnixMilli())
|
|
}
|
|
|
|
func performUsageRequest(engine *gin.Engine, authKey, query string) *httptest.ResponseRecorder {
|
|
target := "/api/usage"
|
|
if query != "" {
|
|
target += "?" + query
|
|
}
|
|
request := httptest.NewRequest(http.MethodGet, target, nil)
|
|
if authKey != "" {
|
|
request.Header.Set("Authorization", "Bearer "+authKey)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
return recorder
|
|
}
|
|
|
|
func assertUsageErrorCode(t *testing.T, recorder *httptest.ResponseRecorder, want string) {
|
|
t.Helper()
|
|
var envelope struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode error response: %v; body=%s", err, recorder.Body.String())
|
|
}
|
|
if recorder.Code != http.StatusBadRequest || envelope.Code != want {
|
|
t.Fatalf("error response = %d/%q, want 400/%q; body=%s", recorder.Code, envelope.Code, want, recorder.Body.String())
|
|
}
|
|
}
|