Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

439 lines
11 KiB
Go

package server_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/server"
"go.kenn.io/agentsview/internal/service"
)
// tokenUsageJSON is a valid token_usage blob for test messages.
var tokenUsageJSON = json.RawMessage(
`{"input_tokens":100,"output_tokens":50,` +
`"cache_creation_input_tokens":10,` +
`"cache_read_input_tokens":20}`,
)
func TestParseUsageFilterDefaults(t *testing.T) {
te := setup(t)
// No params at all -> defaults should kick in.
w := te.get(t, "/api/v1/usage/summary")
assertStatus(t, w, http.StatusOK)
resp := decode[service.UsageSummaryResult](t, w)
assert.NotEmpty(t, resp.From, "expected defaulted From")
assert.NotEmpty(t, resp.To, "expected defaulted To")
// from should be ~30 days before to.
assert.Less(t, resp.From, resp.To)
}
func TestParseUsageFilterExplicit(t *testing.T) {
te := setup(t)
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-15",
"timezone": "America/New_York",
"project": "myproj",
"agent": "claude",
"model": "claude-sonnet-4-20250514",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[service.UsageSummaryResult](t, w)
assert.Equal(t, "2024-06-01", resp.From)
assert.Equal(t, "2024-06-15", resp.To)
}
func TestParseUsageFilterDefaultsIncludeOneShot(t *testing.T) {
te := setup(t)
te.seedSession(t, "usage-one-shot", "alpha", 1,
func(sess *db.Session) {
ts := "2024-06-01T09:00:00Z"
sess.Agent = "claude"
sess.StartedAt = &ts
sess.EndedAt = &ts
sess.UserMessageCount = 1
},
)
te.seedMessages(t, "usage-one-shot", 1,
func(_ int, m *db.Message) {
m.Role = "assistant"
m.Timestamp = "2024-06-01T09:00:00Z"
m.Model = "claude-sonnet-4-20250514"
m.TokenUsage = tokenUsageJSON
},
)
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-02",
"timezone": "UTC",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[service.UsageSummaryResult](t, w)
require.Equal(t, 1, resp.SessionCounts.Total)
}
func TestParseUsageFilterInvalidDate(t *testing.T) {
te := setup(t)
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{"from": "yesterday"}))
assertStatus(t, w, http.StatusBadRequest)
}
// seedUsageEnv populates sessions with token_usage data for
// usage endpoint testing.
func seedUsageEnv(t *testing.T, te *testEnv) {
t.Helper()
type entry struct {
id, project, agent, started string
msgs int
}
entries := []entry{
{"u1", "alpha", "claude", "2024-06-01T09:00:00Z", 4},
{"u2", "beta", "codex", "2024-06-02T10:00:00Z", 4},
}
for _, e := range entries {
te.seedSession(t, e.id, e.project, e.msgs,
func(sess *db.Session) {
sess.Agent = e.agent
sess.StartedAt = &e.started
sess.EndedAt = &e.started
sess.FirstMessage = new("Usage test")
},
)
started := e.started
te.seedMessages(t, e.id, e.msgs,
func(i int, m *db.Message) {
m.Timestamp = started
if m.Role == "assistant" {
m.Model = "claude-sonnet-4-20250514"
m.TokenUsage = tokenUsageJSON
}
},
)
}
}
func seedCopilotNoTokenSession(t *testing.T, te *testEnv, id string) {
t.Helper()
te.seedSession(t, id, "alpha", 1, func(sess *db.Session) {
ts := "2024-06-01T09:00:00Z"
sess.Agent = "copilot"
sess.StartedAt = &ts
sess.EndedAt = &ts
sess.UserMessageCount = 1
})
te.seedMessages(t, id, 1, func(_ int, m *db.Message) {
m.Role = "assistant"
m.Timestamp = "2024-06-01T09:00:00Z"
m.Model = "gpt-5.3-codex"
m.TokenUsage = nil
})
}
func seedUsagePairwiseEnv(t *testing.T, te *testEnv) {
t.Helper()
type entry struct {
id string
project string
started string
model string
usage json.RawMessage
}
entries := []entry{
{
id: "pairwise-alpha-sonnet",
project: "alpha",
started: "2024-06-01T09:00:00Z",
model: "claude-sonnet-4-20250514",
usage: json.RawMessage(
`{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20}`,
),
},
{
id: "pairwise-beta-gpt",
project: "beta",
started: "2024-06-01T10:00:00Z",
model: "gpt-4o",
usage: json.RawMessage(
`{"input_tokens":30,"output_tokens":15,"cache_creation_input_tokens":0,"cache_read_input_tokens":5}`,
),
},
}
for _, e := range entries {
te.seedSession(t, e.id, e.project, 2, func(sess *db.Session) {
sess.Agent = "claude"
sess.StartedAt = &e.started
sess.EndedAt = &e.started
sess.UserMessageCount = 1
})
te.seedMessages(t, e.id, 2, func(_ int, m *db.Message) {
m.Timestamp = e.started
if m.Role == "assistant" {
m.Model = e.model
m.TokenUsage = e.usage
}
})
}
}
func TestHandleUsageSummaryJSONShape(t *testing.T) {
te := setup(t)
seedUsageEnv(t, te)
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-03",
"timezone": "UTC",
}))
assertStatus(t, w, http.StatusOK)
// Verify all expected top-level keys exist.
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
required := []string{
"from", "to", "totals", "daily",
"projectTotals", "modelTotals", "agentTotals",
"sessionCounts", "cacheStats",
}
for _, key := range required {
assert.Contains(t, raw, key, "missing key in response")
}
resp := decode[service.UsageSummaryResult](t, w)
assert.NotEmpty(t, resp.Daily)
assert.NotEmpty(t, resp.ProjectTotals)
assert.NotEmpty(t, resp.ModelTotals)
assert.NotEmpty(t, resp.AgentTotals)
}
func TestHandleUsageSummaryIncludesUnsupportedCopilotSignal(t *testing.T) {
te := setup(t)
seedCopilotNoTokenSession(t, te, "copilot-unsupported")
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-01",
"timezone": "UTC",
"agent": "copilot",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[server.UsageSummaryResponse](t, w)
require.NotNil(t, resp.UnsupportedUsage)
assert.Equal(t, service.UnsupportedUsageKindCopilotNoTokenData, resp.UnsupportedUsage.Kind)
assert.Equal(t, 0, resp.SessionCounts.Total)
assert.Equal(t, 0.0, resp.Totals.TotalCost)
}
func TestHandleUsageSummarySkipsUnsupportedCopilotSignalForMixedFilters(t *testing.T) {
te := setup(t)
seedCopilotNoTokenSession(t, te, "copilot-unsupported")
seedUsageEnv(t, te)
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-02",
"timezone": "UTC",
"agent": "copilot,claude",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[server.UsageSummaryResponse](t, w)
assert.Nil(t, resp.UnsupportedUsage)
}
func TestHandleUsageSummaryIncludesCursorUsageEvents(t *testing.T) {
te := setup(t)
require.NoError(t, te.db.InsertCursorUsageEvents([]db.CursorUsageEvent{{
OccurredAt: "2026-05-14T10:05:00Z",
Model: "claude-4.6-opus-high-thinking",
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
InputTokens: 1234,
OutputTokens: 567,
CacheWriteTokens: 0,
CacheReadTokens: 8901,
ChargedCents: 15.66,
CursorTokenFee: 3.32,
UserID: "152683922",
UserEmail: "member@example.com",
IsHeadless: false,
}}))
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2026-05-14",
"to": "2026-05-14",
"timezone": "UTC",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[server.UsageSummaryResponse](t, w)
require.Len(t, resp.Daily, 1)
assert.InDelta(t, 0.1566, resp.Totals.TotalCost, 1e-9)
require.NotEmpty(t, resp.AgentTotals)
assert.Equal(t, "cursor", resp.AgentTotals[0].Agent)
}
func TestHandleUsageTopSessionsEmpty(t *testing.T) {
te := setup(t)
w := te.get(t, buildPathURL(
"/api/v1/usage/top-sessions",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-03",
"timezone": "UTC",
}))
assertStatus(t, w, http.StatusOK)
var entries []db.TopSessionEntry
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &entries))
assert.NotNil(t, entries, "expected non-null JSON array")
}
func TestHandleUsageTopSessionsLimit(t *testing.T) {
te := setup(t)
seedUsageEnv(t, te)
w := te.get(t, buildPathURL(
"/api/v1/usage/top-sessions",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-03",
"timezone": "UTC",
"limit": "1",
}))
assertStatus(t, w, http.StatusOK)
var entries []db.TopSessionEntry
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &entries))
assert.LessOrEqual(t, len(entries), 1)
}
func TestHandleUsagePairwiseComparisonJSONShape(t *testing.T) {
te := setup(t)
seedUsagePairwiseEnv(t, te)
w := te.get(t, buildPathURL(
"/api/v1/usage/pairwise-comparison",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-01",
"timezone": "UTC",
"left_dimension": "model",
"left_value": "claude-sonnet-4-20250514",
"right_dimension": "project",
"right_value": "beta",
},
))
assertStatus(t, w, http.StatusOK)
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.Contains(t, raw, "left")
assert.Contains(t, raw, "right")
assert.Contains(t, raw, "deltas")
resp := decode[service.UsagePairwiseComparisonResponse](t, w)
assert.Equal(t, 1, resp.Left.SessionCount)
assert.Equal(t, 1, resp.Right.SessionCount)
}
func TestHandleUsagePairwiseComparisonValidation(t *testing.T) {
te := setup(t)
cases := []map[string]string{
{
"from": "2024-06-01",
"to": "2024-06-01",
"timezone": "UTC",
"left_dimension": "model",
"left_value": "claude-sonnet-4-20250514",
"right_dimension": "machine",
"right_value": "beta",
},
{
"from": "2024-06-01",
"to": "2024-06-01",
"timezone": "UTC",
"right_dimension": "model",
"right_value": "gpt-4o",
},
{
"from": "2024-06-01",
"to": "2024-06-01",
"timezone": "UTC",
"left_dimension": "model",
},
}
for _, q := range cases {
w := te.get(t, buildPathURL(
"/api/v1/usage/pairwise-comparison",
q,
))
assertStatus(t, w, http.StatusBadRequest)
}
}
// TestUsageSummaryErrorRedaction verifies internal errors
// don't leak DB details.
func TestUsageSummaryErrorRedaction(t *testing.T) {
te := setup(t)
te.db.Close()
w := te.get(t, buildPathURL("/api/v1/usage/summary",
map[string]string{
"from": "2024-06-01",
"to": "2024-06-03",
}))
assertStatus(t, w, http.StatusInternalServerError)
}
// Verify the route is actually registered by checking we
// don't get a 404 for usage endpoints.
func TestUsageRoutesRegistered(t *testing.T) {
te := setup(t)
endpoints := []string{
"/api/v1/usage/summary",
"/api/v1/usage/pairwise-comparison",
"/api/v1/usage/top-sessions",
}
for _, ep := range endpoints {
t.Run(ep, func(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet, ep, nil,
)
w := httptest.NewRecorder()
te.handler.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusNotFound, w.Code, "%s returned 404", ep)
})
}
}