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
1738 lines
51 KiB
Go
1738 lines
51 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/cursorusage"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/dbtest"
|
|
"go.kenn.io/agentsview/internal/export"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
"go.kenn.io/agentsview/internal/parsertest"
|
|
"go.kenn.io/agentsview/internal/pricing"
|
|
)
|
|
|
|
var goldenFixtureNow = time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
|
|
|
const (
|
|
goldenDatabaseID = "00000000-0000-4000-8000-000000000001"
|
|
goldenArchiveID = "00000000-0000-4000-8000-000000000002"
|
|
goldenArchiveSalt = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
|
goldenPricingUpdatedAt = "2026-07-03T12:00:00Z"
|
|
goldenComputedModel = "fixture-model-computed"
|
|
goldenReportedModel = "fixture-model-reported"
|
|
)
|
|
|
|
var goldenCursorSecret = []byte("agentsview-export-golden-secret-v1")
|
|
|
|
func TestFmtCost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in float64
|
|
want string
|
|
}{
|
|
{"zero is $0.00", 0, "$0.00"},
|
|
{"under half a cent shows <$0.01", 0.001, "<$0.01"},
|
|
{"half a cent rounds up to $0.01", 0.005, "$0.01"},
|
|
{"typical cents", 0.45, "$0.45"},
|
|
{"dollars", 12.34, "$12.34"},
|
|
{"rounds to two decimals", 1.23456, "$1.23"},
|
|
{"large value", 1234.56, "$1234.56"},
|
|
// A negative input shouldn't hit the <$0.01 branch.
|
|
{"negative passes through", -0.42, "$-0.42"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, fmtCost(tc.in),
|
|
"fmtCost(%v)", tc.in)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUsageDailyGolden(t *testing.T) {
|
|
setupExportGoldenDataDir(t)
|
|
|
|
cmd := newRootCommand()
|
|
cmd.SetArgs([]string{
|
|
"usage", "daily",
|
|
"--json",
|
|
"--since", "2026-07-01",
|
|
"--until", "2026-07-03",
|
|
"--timezone", "UTC",
|
|
"--offline",
|
|
"--no-sync",
|
|
})
|
|
var err error
|
|
stdout := captureStdout(t, func() {
|
|
_, err = cmd.ExecuteC()
|
|
})
|
|
require.NoError(t, err, "usage daily json golden command")
|
|
|
|
assertGoldenBytes(t, "usage_daily_v1.json", []byte(stdout))
|
|
}
|
|
|
|
func TestUsageDailyBreakdownGolden(t *testing.T) {
|
|
setupExportGoldenDataDir(t)
|
|
|
|
cmd := newRootCommand()
|
|
cmd.SetArgs([]string{
|
|
"usage", "daily",
|
|
"--json",
|
|
"--breakdown",
|
|
"--since", "2026-07-01",
|
|
"--until", "2026-07-03",
|
|
"--timezone", "UTC",
|
|
"--offline",
|
|
"--no-sync",
|
|
})
|
|
var err error
|
|
stdout := captureStdout(t, func() {
|
|
_, err = cmd.ExecuteC()
|
|
})
|
|
require.NoError(t, err, "usage daily json breakdown golden command")
|
|
|
|
assertGoldenBytes(t, "usage_daily_breakdown_v1.json", []byte(stdout))
|
|
}
|
|
|
|
func setupExportGoldenDataDir(t *testing.T) string {
|
|
t.Helper()
|
|
dataDir := testDataDir(t)
|
|
t.Setenv("TZ", "UTC")
|
|
t.Setenv("AGENTSVIEW_CURSOR_ATTRIBUTION_DB",
|
|
filepath.Join(dataDir, "missing-cursor-attribution.db"))
|
|
writeGoldenCursorSecretConfig(t, dataDir)
|
|
|
|
dbPath := sessionsDBPath(dataDir)
|
|
database := dbtest.OpenTestDBAt(t, dbPath)
|
|
seedExportGoldenArchive(t, database)
|
|
require.NoError(t, database.Close(), "close golden database")
|
|
setGoldenPricingUpdatedAt(t, dbPath)
|
|
return dataDir
|
|
}
|
|
|
|
func writeGoldenCursorSecretConfig(t *testing.T, dataDir string) {
|
|
t.Helper()
|
|
encoded := base64.StdEncoding.EncodeToString(goldenCursorSecret)
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(dataDir, "config.toml"),
|
|
[]byte("cursor_secret = \""+encoded+"\"\n"),
|
|
0o600,
|
|
), "write golden config")
|
|
}
|
|
|
|
func seedExportGoldenArchive(t *testing.T, database *db.DB) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
database.SetCursorSecret(goldenCursorSecret)
|
|
require.NoError(t, database.SetDatabaseIDForTest(ctx, goldenDatabaseID))
|
|
require.NoError(t, database.SetArchiveIdentityForTest(
|
|
ctx, goldenArchiveID, goldenArchiveSalt,
|
|
))
|
|
require.NoError(t, database.UpsertModelPricing([]db.ModelPricing{
|
|
{
|
|
ModelPattern: goldenComputedModel,
|
|
InputPerMTok: 2,
|
|
OutputPerMTok: 8,
|
|
CacheCreationPerMTok: 3,
|
|
CacheReadPerMTok: 0.5,
|
|
},
|
|
{
|
|
ModelPattern: goldenReportedModel,
|
|
InputPerMTok: 1,
|
|
OutputPerMTok: 4,
|
|
CacheCreationPerMTok: 2,
|
|
CacheReadPerMTok: 0.25,
|
|
},
|
|
}), "seed golden pricing")
|
|
seedGoldenExportSession(t, database, goldenExportSessionSpec{
|
|
id: "path-current", project: "path-project", agent: "claude",
|
|
startedAt: "2026-07-03T11:00:00Z",
|
|
endedAt: "2026-07-03T11:10:00Z",
|
|
model: goldenReportedModel,
|
|
costUSD: dbtest.Ptr(0.0125),
|
|
cwd: "/fixtures/path-project/pkg",
|
|
})
|
|
seedGoldenExportSession(t, database, goldenExportSessionSpec{
|
|
id: "remote-current", project: "remote-project", agent: "codex",
|
|
startedAt: "2026-07-03T10:00:00Z",
|
|
endedAt: "2026-07-03T10:30:00Z",
|
|
model: goldenComputedModel,
|
|
tokenJSON: json.RawMessage(`{"input_tokens":1200,"output_tokens":240,` +
|
|
`"cache_creation_input_tokens":80,"cache_read_input_tokens":400}`),
|
|
outputTokens: 240,
|
|
cwd: "/fixtures/remote-project/worktrees/feature/app",
|
|
gitBranch: "feature/golden",
|
|
})
|
|
seedGoldenExportSession(t, database, goldenExportSessionSpec{
|
|
id: "remote-yesterday", project: "remote-project", agent: "codex",
|
|
startedAt: "2026-07-02T09:00:00Z",
|
|
endedAt: "2026-07-02T09:20:00Z",
|
|
model: goldenComputedModel,
|
|
tokenJSON: json.RawMessage(`{"input_tokens":800,"output_tokens":160}`),
|
|
outputTokens: 160,
|
|
cwd: "/fixtures/remote-project/worktrees/feature/cli",
|
|
gitBranch: "feature/golden",
|
|
})
|
|
seedGoldenExportSession(t, database, goldenExportSessionSpec{
|
|
id: "unknown-older", project: "unknown-project", agent: "cursor",
|
|
startedAt: "2026-07-01T08:00:00Z",
|
|
endedAt: "2026-07-01T08:05:00Z",
|
|
model: goldenReportedModel,
|
|
costUSD: dbtest.Ptr(0.0042),
|
|
})
|
|
seedGoldenProjectIdentities(t, database)
|
|
}
|
|
|
|
func seedGoldenProjectIdentities(t *testing.T, database *db.DB) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
for _, sessionID := range []string{"remote-current", "remote-yesterday"} {
|
|
require.NoError(t, database.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
SessionID: sessionID,
|
|
Project: "remote-project",
|
|
Machine: "golden-host",
|
|
RootPath: "/fixtures/remote-project/worktrees/feature",
|
|
GitRemote: "https://github.com/acme/remote-project.git",
|
|
GitRemoteName: "origin",
|
|
RepositoryPath: "/fixtures/remote-project",
|
|
WorktreeName: "feature",
|
|
WorktreeRootPath: "/fixtures/remote-project/worktrees/feature",
|
|
WorktreeRelationship: export.WorktreeLinked,
|
|
CheckoutState: export.CheckoutBranch,
|
|
GitBranch: "feature/golden",
|
|
ObservedAt: goldenFixtureNow,
|
|
}), "seed remote project identity")
|
|
}
|
|
require.NoError(t, database.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
SessionID: "path-current",
|
|
Project: "path-project",
|
|
Machine: "golden-host",
|
|
RootPath: "/fixtures/path-project",
|
|
RepositoryPath: "/fixtures/path-project",
|
|
WorktreeRootPath: "/fixtures/path-project",
|
|
WorktreeRelationship: export.WorktreeMain,
|
|
CheckoutState: export.CheckoutUnknown,
|
|
ObservedAt: goldenFixtureNow,
|
|
}), "seed path project identity")
|
|
require.NoError(t, database.UpsertProjectIdentityObservation(ctx,
|
|
export.ProjectIdentityObservation{
|
|
SessionID: "unknown-older",
|
|
Project: "unknown-project",
|
|
Machine: "golden-host",
|
|
ObservedAt: goldenFixtureNow,
|
|
}), "seed unknown project identity")
|
|
}
|
|
|
|
type goldenExportSessionSpec struct {
|
|
id string
|
|
project string
|
|
agent string
|
|
startedAt string
|
|
endedAt string
|
|
model string
|
|
tokenJSON json.RawMessage
|
|
outputTokens int
|
|
costUSD *float64
|
|
cwd string
|
|
gitBranch string
|
|
}
|
|
|
|
func seedGoldenExportSession(
|
|
t *testing.T, database *db.DB, spec goldenExportSessionSpec,
|
|
) {
|
|
t.Helper()
|
|
session := db.Session{
|
|
ID: spec.id,
|
|
Project: spec.project,
|
|
Machine: "golden-host",
|
|
Agent: spec.agent,
|
|
StartedAt: &spec.startedAt,
|
|
EndedAt: &spec.endedAt,
|
|
CreatedAt: spec.startedAt,
|
|
MessageCount: 4,
|
|
UserMessageCount: 2,
|
|
RelationshipType: "root",
|
|
DataVersion: db.CurrentDataVersion(),
|
|
Cwd: spec.cwd,
|
|
GitBranch: spec.gitBranch,
|
|
}
|
|
if spec.outputTokens > 0 {
|
|
session.TotalOutputTokens = spec.outputTokens
|
|
session.HasTotalOutputTokens = true
|
|
}
|
|
require.NoError(t, database.UpsertSession(session),
|
|
"upsert golden session %s", spec.id)
|
|
|
|
msgs := []db.Message{
|
|
{
|
|
SessionID: spec.id, Ordinal: 0, Role: "user",
|
|
Content: "golden request", ContentLength: len("golden request"),
|
|
Timestamp: spec.startedAt,
|
|
},
|
|
{
|
|
SessionID: spec.id, Ordinal: 1, Role: "assistant",
|
|
Content: "golden response", ContentLength: len("golden response"),
|
|
Timestamp: addSeconds(spec.startedAt, 30), Model: spec.model,
|
|
},
|
|
{
|
|
SessionID: spec.id, Ordinal: 2, Role: "user",
|
|
Content: "follow up", ContentLength: len("follow up"),
|
|
Timestamp: addMinutes(spec.startedAt, 2),
|
|
},
|
|
{
|
|
SessionID: spec.id, Ordinal: 3, Role: "assistant",
|
|
Content: "done", ContentLength: len("done"),
|
|
Timestamp: addMinutes(spec.startedAt, 3), Model: spec.model,
|
|
},
|
|
}
|
|
if len(spec.tokenJSON) > 0 {
|
|
msgs[1].TokenUsage = spec.tokenJSON
|
|
msgs[1].OutputTokens = spec.outputTokens
|
|
msgs[1].HasOutputTokens = true
|
|
}
|
|
require.NoError(t, database.InsertMessages(msgs),
|
|
"insert golden messages %s", spec.id)
|
|
|
|
if spec.costUSD != nil {
|
|
ordinal := 1
|
|
require.NoError(t, database.ReplaceSessionUsageEvents(
|
|
spec.id, []db.UsageEvent{{
|
|
SessionID: spec.id,
|
|
MessageOrdinal: &ordinal,
|
|
Source: "provider",
|
|
Model: spec.model,
|
|
InputTokens: 300,
|
|
OutputTokens: 60,
|
|
CostUSD: spec.costUSD,
|
|
OccurredAt: addMinutes(spec.startedAt, 1),
|
|
DedupKey: spec.id + ":provider-usage",
|
|
}},
|
|
), "seed golden usage event %s", spec.id)
|
|
}
|
|
}
|
|
|
|
func setGoldenPricingUpdatedAt(t *testing.T, dbPath string) {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err, "open golden db for pricing timestamp")
|
|
defer func() {
|
|
require.NoError(t, conn.Close(), "close pricing timestamp db")
|
|
}()
|
|
_, err = conn.Exec(`UPDATE model_pricing SET updated_at = ?`,
|
|
goldenPricingUpdatedAt)
|
|
require.NoError(t, err, "set deterministic pricing updated_at")
|
|
}
|
|
|
|
func assertGoldenBytes(t *testing.T, name string, got []byte) {
|
|
t.Helper()
|
|
path := filepath.Join("..", "..", "testdata", "golden", name)
|
|
if *updateGolden {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755),
|
|
"mkdir golden dir")
|
|
require.NoError(t, os.WriteFile(path, got, 0o644),
|
|
"write golden %s", name)
|
|
t.Logf("rewrote %s (%d bytes)", path, len(got))
|
|
return
|
|
}
|
|
want, err := os.ReadFile(path)
|
|
require.NoError(t, err, "read golden (run with -update to generate)")
|
|
if !bytes.Equal(want, got) {
|
|
assert.Equal(t, string(want), string(got),
|
|
"golden mismatch for %s", name)
|
|
}
|
|
}
|
|
|
|
func TestDefaultUsageDateRange(t *testing.T) {
|
|
now := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
from string
|
|
to string
|
|
wantF string
|
|
wantT string
|
|
}{
|
|
{
|
|
name: "no flags returns 30-day window",
|
|
wantF: "2024-05-16",
|
|
wantT: "2024-06-15",
|
|
},
|
|
{
|
|
name: "explicit from fills to",
|
|
from: "2024-01-01",
|
|
wantF: "2024-01-01",
|
|
wantT: "2024-06-15",
|
|
},
|
|
{
|
|
name: "explicit to fills from",
|
|
to: "2024-01-31",
|
|
wantF: "2024-01-01",
|
|
wantT: "2024-01-31",
|
|
},
|
|
{
|
|
name: "explicit range preserved",
|
|
from: "2024-01-01",
|
|
to: "2024-01-31",
|
|
wantF: "2024-01-01",
|
|
wantT: "2024-01-31",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotF, gotT := defaultUsageDateRange(tc.from, tc.to, now)
|
|
assert.Equal(t, tc.wantF, gotF)
|
|
assert.Equal(t, tc.wantT, gotT)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchHTTPDailyUsage(t *testing.T) {
|
|
var gotAuth string
|
|
ts := httptest.NewServer(http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
assert.Equal(t, "/api/v1/usage/summary", r.URL.Path)
|
|
assert.Equal(t, "2026-06-01", r.URL.Query().Get("from"))
|
|
assert.Equal(t, "2026-06-02", r.URL.Query().Get("to"))
|
|
assert.Equal(t, "America/Chicago", r.URL.Query().Get("timezone"))
|
|
assert.Equal(t, "codex", r.URL.Query().Get("agent"))
|
|
assert.Equal(t, "true", r.URL.Query().Get("no_default_range"))
|
|
assert.Equal(t, "false", r.URL.Query().Get("breakdowns"))
|
|
assert.Equal(t, "false", r.URL.Query().Get("session_counts"))
|
|
assert.Equal(t, "true", r.URL.Query().Get("include_one_shot"))
|
|
assert.Equal(t, "true", r.URL.Query().Get("include_automated"))
|
|
gotAuth = r.Header.Get("Authorization")
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
got, err := fetchHTTPDailyUsage(
|
|
context.Background(),
|
|
transport{Mode: transportHTTP, URL: ts.URL},
|
|
"secret-token",
|
|
dailyUsageQuery{
|
|
Filter: db.UsageFilter{
|
|
From: "2026-06-01",
|
|
To: "2026-06-02",
|
|
Timezone: "America/Chicago",
|
|
Agent: "codex",
|
|
},
|
|
NoDefaultRange: true,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, got.Daily, 1)
|
|
assert.Equal(t, "Bearer secret-token", gotAuth)
|
|
assert.Equal(t, export.UsageDailySchemaVersion, got.SchemaVersion)
|
|
require.NotNil(t, got.Pricing)
|
|
assert.Contains(t, got.Pricing.Models, "gpt-5.1")
|
|
require.Len(t, got.Projects, 1)
|
|
for key, project := range got.Projects {
|
|
assert.NotContains(t, key, "proj")
|
|
assert.Equal(t, "proj", project.DisplayLabel)
|
|
assert.Equal(t, export.ProjectResolutionUnknown, project.Resolution)
|
|
}
|
|
assert.Equal(t, 10, got.Totals.InputTokens)
|
|
assert.Equal(t, 20, got.Daily[0].OutputTokens)
|
|
assert.Equal(t, 1, got.SessionCounts.Total)
|
|
}
|
|
|
|
func TestFetchHTTPDailyUsageMissingProjectsDefaultsEmptyMap(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
writeJSONResponse(w, `{
|
|
"schema_version": 1,
|
|
"totals": {},
|
|
"daily": [],
|
|
"sessionCounts": {}
|
|
}`)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
got, err := fetchHTTPDailyUsage(
|
|
context.Background(),
|
|
transport{Mode: transportHTTP, URL: ts.URL},
|
|
"",
|
|
dailyUsageQuery{NoDefaultRange: true},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, got.Projects)
|
|
assert.Empty(t, got.Projects)
|
|
}
|
|
|
|
func TestFetchHTTPDailyUsagePreservesExcludedSessionFilters(t *testing.T) {
|
|
var gotQuery url.Values
|
|
ts := httptest.NewServer(http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
gotQuery = r.URL.Query()
|
|
writeJSONResponse(w, emptyDailyUsageJSON)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
_, err := fetchHTTPDailyUsage(
|
|
context.Background(),
|
|
transport{Mode: transportHTTP, URL: ts.URL},
|
|
"",
|
|
dailyUsageQuery{
|
|
Filter: db.UsageFilter{
|
|
From: "2026-06-01",
|
|
ExcludeOneShot: true,
|
|
ExcludeAutomated: true,
|
|
},
|
|
NoDefaultRange: true,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "false", gotQuery.Get("include_one_shot"))
|
|
assert.Equal(t, "false", gotQuery.Get("include_automated"))
|
|
}
|
|
|
|
func TestFetchHTTPDailyUsagePreservesOpenEndedRange(t *testing.T) {
|
|
var gotQuery string
|
|
ts := httptest.NewServer(http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
gotQuery = r.URL.RawQuery
|
|
writeJSONResponse(w, emptyDailyUsageJSON)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
_, err := fetchHTTPDailyUsage(
|
|
context.Background(),
|
|
transport{Mode: transportHTTP, URL: ts.URL},
|
|
"",
|
|
dailyUsageQuery{
|
|
Filter: db.UsageFilter{To: "2026-06-02", Timezone: "UTC"},
|
|
NoDefaultRange: true,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, gotQuery, "no_default_range=true")
|
|
assert.NotContains(t, gotQuery, "from=")
|
|
assert.Contains(t, gotQuery, "to=2026-06-02")
|
|
}
|
|
|
|
func TestFetchHTTPDailyUsageAllowsDefaultRangeWhenRangeEmpty(t *testing.T) {
|
|
var gotQuery url.Values
|
|
ts := httptest.NewServer(http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
gotQuery = r.URL.Query()
|
|
writeJSONResponse(w, emptyDailyUsageJSON)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
_, err := fetchHTTPDailyUsage(
|
|
context.Background(),
|
|
transport{Mode: transportHTTP, URL: ts.URL},
|
|
"",
|
|
dailyUsageQuery{
|
|
Filter: db.UsageFilter{Timezone: "UTC"},
|
|
NoDefaultRange: false,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "false", gotQuery.Get("no_default_range"))
|
|
assert.NotContains(t, gotQuery, "from")
|
|
assert.NotContains(t, gotQuery, "to")
|
|
}
|
|
|
|
func TestRunUsageDailyUsesDiscoveredDaemon(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotPath string
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
assert.Equal(t, "2026-06-01", r.URL.Query().Get("from"))
|
|
assert.Equal(t, "2026-06-02", r.URL.Query().Get("to"))
|
|
assert.Equal(t, "true", r.URL.Query().Get("no_default_range"))
|
|
assert.Equal(t, "false", r.URL.Query().Get("breakdowns"))
|
|
assert.Equal(t, "true", r.URL.Query().Get("session_counts"))
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
Since: "2026-06-01",
|
|
Until: "2026-06-02",
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
assert.Equal(t, "/api/v1/usage/summary", gotPath)
|
|
assert.Contains(t, out, `"totalCost": 0.42`)
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
// TestRunUsageDailyResolvesDurationSince proves a duration --since (e.g.
|
|
// "14d") is resolved to a concrete YYYY-MM-DD before it reaches the query
|
|
// layer, not forwarded verbatim.
|
|
func TestRunUsageDailyResolvesDurationSince(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotFrom string
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotFrom = r.URL.Query().Get("from")
|
|
assert.Equal(t, "true", r.URL.Query().Get("no_default_range"))
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
_ = captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
Since: "14d",
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
// Exact date math is pinned by TestResolveUsageWindow; here we only
|
|
// prove the duration resolved to a concrete date, not forwarded as
|
|
// "14d".
|
|
assert.NotEqual(t, "14d", gotFrom, "duration should be resolved, not forwarded")
|
|
assert.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, gotFrom,
|
|
"duration --since should resolve to a concrete YYYY-MM-DD date")
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
func TestResolveUsageWindow(t *testing.T) {
|
|
now := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
since, until string
|
|
wantFrom, wantTo string
|
|
wantErrSubstring string
|
|
}{
|
|
{name: "empty passes through"},
|
|
{name: "duration since anchors at now", since: "14d", wantFrom: "2026-04-04"},
|
|
{name: "Nh duration since", since: "48h", wantFrom: "2026-04-16"},
|
|
{name: "duration since anchors to explicit until", since: "14d",
|
|
until: "2026-04-10", wantFrom: "2026-03-27", wantTo: "2026-04-10"},
|
|
{name: "duration since anchors to duration until", since: "7d",
|
|
until: "30d", wantFrom: "2026-03-12", wantTo: "2026-03-19"},
|
|
{name: "dates pass through", since: "2026-04-01", until: "2026-04-10",
|
|
wantFrom: "2026-04-01", wantTo: "2026-04-10"},
|
|
{name: "equal bounds are a valid single day", since: "2026-04-10",
|
|
until: "2026-04-10", wantFrom: "2026-04-10", wantTo: "2026-04-10"},
|
|
{name: "garbage since", since: "7x", wantErrSubstring: "invalid --since"},
|
|
{name: "garbage until", until: "nope", wantErrSubstring: "invalid --until"},
|
|
{name: "inverted explicit window", since: "2026-06-20", until: "2026-06-13",
|
|
wantErrSubstring: "must not be after"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
from, to, err := resolveUsageWindow(tc.since, tc.until, now, time.UTC)
|
|
if tc.wantErrSubstring != "" {
|
|
require.Error(t, err, "expected an error")
|
|
assert.Contains(t, err.Error(), tc.wantErrSubstring)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.wantFrom, from, "from")
|
|
assert.Equal(t, tc.wantTo, to, "to")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveUsageWindowUsesReportTimezone(t *testing.T) {
|
|
loc, err := time.LoadLocation("America/Los_Angeles")
|
|
require.NoError(t, err)
|
|
now := time.Date(2026, 4, 17, 19, 0, 0, 0, loc)
|
|
|
|
tests := []struct {
|
|
name string
|
|
since, until string
|
|
wantFrom, wantTo string
|
|
}{
|
|
{
|
|
name: "duration since formats report local date",
|
|
since: "1d",
|
|
wantFrom: "2026-04-16",
|
|
},
|
|
{
|
|
name: "duration since anchors to until in report timezone",
|
|
since: "1d",
|
|
until: "2026-04-10",
|
|
wantFrom: "2026-04-09",
|
|
wantTo: "2026-04-10",
|
|
},
|
|
{
|
|
name: "explicit dates pass through unchanged",
|
|
since: "2026-04-01",
|
|
until: "2026-04-10",
|
|
wantFrom: "2026-04-01",
|
|
wantTo: "2026-04-10",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
from, to, err := resolveUsageWindow(tc.since, tc.until, now, loc)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.wantFrom, from, "from")
|
|
assert.Equal(t, tc.wantTo, to, "to")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunUsageDailyTableSkipsDaemonSessionCounts(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotSessionCounts string
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotSessionCounts = r.URL.Query().Get("session_counts")
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{Timezone: "UTC"})
|
|
})
|
|
|
|
assert.Equal(t, "false", gotSessionCounts)
|
|
assert.Contains(t, out, "TOTAL")
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
func TestRunUsageDailyBreakdownUsesDaemonBreakdowns(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotBreakdowns string
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotBreakdowns = r.URL.Query().Get("breakdowns")
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
_ = captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
Breakdown: true,
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
assert.Equal(t, "true", gotBreakdowns)
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
func TestRunUsageDailyDefaultRangeUsesDaemonDefaults(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotQuery url.Values
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
assert.Equal(t, "false", gotQuery.Get("no_default_range"))
|
|
assert.NotContains(t, gotQuery, "from")
|
|
assert.NotContains(t, gotQuery, "to")
|
|
assert.Contains(t, out, `"totalCost": 0.42`)
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
func TestRunUsageDailyAllPreservesEmptyRangeWithDiscoveredDaemon(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotQuery url.Values
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
writeJSONResponse(w, totalCostOnlyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
All: true,
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
assert.Equal(t, "true", gotQuery.Get("no_default_range"))
|
|
assert.NotContains(t, gotQuery, "from")
|
|
assert.NotContains(t, gotQuery, "to")
|
|
assert.Contains(t, out, `"totalCost": 0.42`)
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
func TestRunUsageDailyNoSyncUsesDiscoveredDaemon(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotPath string
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
writeJSONResponse(w, totalCostOnlyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
NoSync: true,
|
|
Since: "2026-06-01",
|
|
Until: "2026-06-02",
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
assert.Equal(t, "/api/v1/usage/summary", gotPath)
|
|
assert.Contains(t, out, `"totalCost": 0.42`)
|
|
assertNoLocalSessionsDB(t, dataDir)
|
|
}
|
|
|
|
func TestRunUsageDailyOfflineUsesReadOnlyDBWhenWriteLockHeld(t *testing.T) {
|
|
dataDir := setupGoldenStatsDataDir(t)
|
|
writeCustomModelPricingConfig(t, dataDir)
|
|
|
|
lock, err := acquireWriteOwnerLock(context.Background(), dataDir)
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, lock.Close()) }()
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true,
|
|
Offline: true,
|
|
Since: "2026-04-01",
|
|
Until: "2026-04-15",
|
|
Timezone: "UTC",
|
|
})
|
|
})
|
|
|
|
assert.Contains(t, out, `"daily"`)
|
|
assert.Contains(t, out, `"totalCost"`)
|
|
var got db.DailyUsageResult
|
|
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
|
assert.Greater(t, got.Totals.TotalCost, 600.0,
|
|
"offline read-only usage must preserve custom pricing")
|
|
}
|
|
|
|
func TestArchiveQueryBackendNoSyncStartsNoSyncDaemonForDailyUsage(t *testing.T) {
|
|
newAgentDataDir(t)
|
|
var started bool
|
|
stubStartBackgroundServeForTransport(t, func(
|
|
_ context.Context, cfg *config.Config, _ time.Duration,
|
|
) (*DaemonRuntime, error) {
|
|
started = true
|
|
assert.True(t, cfg.NoSync)
|
|
return &DaemonRuntime{Host: "127.0.0.1", Port: 12345}, nil
|
|
})
|
|
|
|
backend := resolveTestArchiveQueryBackend(t, defaultArchiveQueryPolicy(
|
|
func(p *archiveQueryPolicy) {
|
|
p.NoSync = true
|
|
p.AutoStart = true
|
|
},
|
|
))
|
|
assert.True(t, started)
|
|
assert.IsType(t, daemonArchiveQueryBackend{}, backend)
|
|
}
|
|
|
|
func TestArchiveQueryBackendRefusesReadOnlyDaemonForDailyUsage(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var called bool
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
registerTestRuntime(t, dataDir, ts.URL, true)
|
|
|
|
_, cleanup, err := resolveArchiveQueryBackend(
|
|
context.Background(), defaultArchiveQueryPolicy(
|
|
func(p *archiveQueryPolicy) { p.AutoStart = true },
|
|
),
|
|
)
|
|
if cleanup != nil {
|
|
t.Cleanup(cleanup)
|
|
}
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "read-only")
|
|
assert.False(t, called)
|
|
}
|
|
|
|
func TestArchiveQueryBackendOfflineSkipsDaemonForDailyUsage(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
copyGoldenFixtureDB(t, sessionsDBPath(dataDir))
|
|
|
|
var called bool
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
backend := resolveTestArchiveQueryBackend(t, defaultArchiveQueryPolicy(
|
|
func(p *archiveQueryPolicy) {
|
|
p.Offline = true
|
|
p.AutoStart = true
|
|
},
|
|
))
|
|
assert.IsType(t, localArchiveQueryBackend{}, backend)
|
|
assert.False(t, called)
|
|
}
|
|
|
|
func TestLocalArchiveQueryDailyUsageAppliesDefaultRange(t *testing.T) {
|
|
d := newTestDB(t)
|
|
require.NoError(t, d.UpsertModelPricing([]db.ModelPricing{{
|
|
ModelPattern: "test-model",
|
|
InputPerMTok: 1,
|
|
OutputPerMTok: 1,
|
|
}}))
|
|
|
|
recent := time.Now().UTC().AddDate(0, 0, -2).Format(time.RFC3339)
|
|
old := time.Now().UTC().AddDate(0, 0, -60).Format(time.RFC3339)
|
|
future := time.Now().UTC().AddDate(0, 0, 2).Format(time.RFC3339)
|
|
upsertSession(t, d, "recent", "codex", recent)
|
|
upsertSession(t, d, "old", "codex", old)
|
|
upsertSession(t, d, "future", "codex", future)
|
|
require.NoError(t, d.InsertMessages([]db.Message{
|
|
{
|
|
SessionID: "recent",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Timestamp: recent,
|
|
Model: "test-model",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":10,"output_tokens":1}`),
|
|
},
|
|
{
|
|
SessionID: "old",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Timestamp: old,
|
|
Model: "test-model",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":20,"output_tokens":2}`),
|
|
},
|
|
{
|
|
SessionID: "future",
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Timestamp: future,
|
|
Model: "test-model",
|
|
TokenUsage: json.RawMessage(`{"input_tokens":40,"output_tokens":4}`),
|
|
},
|
|
}))
|
|
|
|
backend := localArchiveQueryBackend{
|
|
cfg: config.Config{},
|
|
database: d,
|
|
offline: true,
|
|
skipFreshData: true,
|
|
}
|
|
defaulted, err := backend.DailyUsage(context.Background(), dailyUsageQuery{
|
|
Filter: db.UsageFilter{Timezone: "UTC"},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 10, defaulted.Totals.InputTokens)
|
|
|
|
all, err := backend.DailyUsage(context.Background(), dailyUsageQuery{
|
|
Filter: db.UsageFilter{Timezone: "UTC"},
|
|
NoDefaultRange: true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 70, all.Totals.InputTokens)
|
|
|
|
withBreakdowns, err := backend.DailyUsage(context.Background(), dailyUsageQuery{
|
|
Filter: db.UsageFilter{Timezone: "UTC"},
|
|
NoDefaultRange: true,
|
|
Breakdowns: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, withBreakdowns.Daily)
|
|
assert.NotEmpty(t, withBreakdowns.Daily[0].ProjectBreakdowns)
|
|
assert.NotEmpty(t, withBreakdowns.Daily[0].AgentBreakdowns)
|
|
}
|
|
|
|
func TestUsageDailyJSONIncludesExportMetadata(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
database := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db"))
|
|
fallbackModel := fallbackPricedModel(t)
|
|
require.NoError(t, seedFallbackPricing(database))
|
|
seedUsageDailyExportMetadataFixture(t, database, fallbackModel)
|
|
require.NoError(t, database.Close())
|
|
|
|
out := captureStdout(t, func() {
|
|
runUsageDaily(UsageDailyConfig{
|
|
JSON: true, Since: "2026-06-01", Until: "2026-06-01",
|
|
Timezone: "UTC", Offline: true, NoSync: true,
|
|
})
|
|
})
|
|
|
|
var got db.DailyUsageResult
|
|
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
|
assert.Equal(t, export.UsageDailySchemaVersion, got.SchemaVersion)
|
|
require.NotNil(t, got.Pricing)
|
|
require.Contains(t, got.Pricing.Models, "gpt-5.1")
|
|
require.Contains(t, got.Pricing.Models, fallbackModel)
|
|
assert.Equal(t, export.CostSourceReported,
|
|
got.Pricing.Models["gpt-5.1"].CostSource)
|
|
assert.Equal(t, export.CostSourceComputed,
|
|
got.Pricing.Models[fallbackModel].CostSource)
|
|
assert.True(t, got.Pricing.Fallback.Used)
|
|
assert.Contains(t, got.Pricing.Fallback.Models, fallbackModel)
|
|
require.Len(t, got.Projects, 1)
|
|
var projectKey string
|
|
for key, project := range got.Projects {
|
|
projectKey = key
|
|
assert.NotContains(t, key, "shared-project")
|
|
assert.Equal(t, "shared-project", project.DisplayLabel)
|
|
assert.Equal(t, export.ProjectResolutionUnknown, project.Resolution)
|
|
}
|
|
|
|
require.Len(t, got.Daily, 1)
|
|
assert.Equal(t, "2026-06-01", got.Daily[0].Date)
|
|
assert.ElementsMatch(t, []string{"gpt-5.1", fallbackModel},
|
|
got.Daily[0].ModelsUsed)
|
|
assert.Equal(t, 300, got.Totals.InputTokens)
|
|
assert.Equal(t, 150, got.Totals.OutputTokens)
|
|
assert.Equal(t, 2, got.SessionCounts.Total)
|
|
assert.Equal(t, map[string]int{projectKey: 2},
|
|
got.SessionCounts.ByProject)
|
|
}
|
|
|
|
func seedUsageDailyExportMetadataFixture(
|
|
t *testing.T, database *db.DB, fallbackModel string,
|
|
) {
|
|
t.Helper()
|
|
started := "2026-06-01T10:00:00Z"
|
|
ended := "2026-06-01T10:05:00Z"
|
|
for _, id := range []string{"reported-cost", "fallback-cost"} {
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "usage-meta-" + id, Project: "shared-project",
|
|
Machine: "test", Agent: "codex", StartedAt: &started,
|
|
EndedAt: &ended, CreatedAt: started, MessageCount: 2,
|
|
UserMessageCount: 1, RelationshipType: "root", DataVersion: 1,
|
|
}))
|
|
}
|
|
require.NoError(t, database.InsertMessages([]db.Message{
|
|
{
|
|
SessionID: "usage-meta-reported-cost", Ordinal: 0,
|
|
Role: "assistant", Timestamp: started, Model: "gpt-5.1",
|
|
},
|
|
{
|
|
SessionID: "usage-meta-fallback-cost", Ordinal: 0,
|
|
Role: "assistant", Timestamp: started, Model: fallbackModel,
|
|
TokenUsage: json.RawMessage(`{"input_tokens":200,"output_tokens":100}`),
|
|
},
|
|
}))
|
|
cost := 0.25
|
|
ordinal := 1
|
|
require.NoError(t, database.ReplaceSessionUsageEvents(
|
|
"usage-meta-reported-cost", []db.UsageEvent{{
|
|
SessionID: "usage-meta-reported-cost", MessageOrdinal: &ordinal,
|
|
Source: "session", Model: "gpt-5.1", InputTokens: 100,
|
|
OutputTokens: 50, CostUSD: &cost,
|
|
OccurredAt: "2026-06-01T10:01:00Z",
|
|
DedupKey: "usage-meta-reported-cost:event",
|
|
}},
|
|
))
|
|
}
|
|
|
|
func TestFormatDailyUsageJSON(t *testing.T) {
|
|
result := db.DailyUsageResult{
|
|
Daily: []db.DailyUsageEntry{
|
|
{
|
|
Date: "2024-06-15",
|
|
InputTokens: 50000,
|
|
OutputTokens: 12000,
|
|
CacheCreationTokens: 8000,
|
|
CacheReadTokens: 30000,
|
|
TotalCost: 0.45,
|
|
ModelsUsed: []string{"claude-sonnet-4-20250514"},
|
|
ModelBreakdowns: []db.ModelBreakdown{
|
|
{
|
|
ModelName: "claude-sonnet-4-20250514",
|
|
InputTokens: 50000,
|
|
OutputTokens: 12000,
|
|
CacheCreationTokens: 8000,
|
|
CacheReadTokens: 30000,
|
|
Cost: 0.45,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Totals: db.UsageTotals{
|
|
InputTokens: 50000,
|
|
OutputTokens: 12000,
|
|
CacheCreationTokens: 8000,
|
|
CacheReadTokens: 30000,
|
|
TotalCost: 0.45,
|
|
},
|
|
}
|
|
|
|
out, err := json.Marshal(result)
|
|
require.NoError(t, err, "json.Marshal failed")
|
|
|
|
var decoded map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(out, &decoded),
|
|
"json.Unmarshal failed")
|
|
|
|
assert.Contains(t, decoded, "daily", "missing 'daily' key in JSON output")
|
|
assert.Contains(t, decoded, "totals", "missing 'totals' key in JSON output")
|
|
|
|
// Verify daily array has expected entry
|
|
var daily []map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(decoded["daily"], &daily),
|
|
"parsing daily array")
|
|
require.Len(t, daily, 1, "daily length")
|
|
|
|
// Check expected fields exist in daily entry
|
|
wantFields := []string{
|
|
"date", "inputTokens", "outputTokens",
|
|
"cacheCreationTokens", "cacheReadTokens",
|
|
"totalCost", "modelsUsed", "modelBreakdowns",
|
|
}
|
|
for _, f := range wantFields {
|
|
assert.Contains(t, daily[0], f,
|
|
"missing field %q in daily entry", f)
|
|
}
|
|
|
|
// Verify totals fields
|
|
var totals map[string]json.RawMessage
|
|
require.NoError(t, json.Unmarshal(decoded["totals"], &totals),
|
|
"parsing totals")
|
|
totalFields := []string{
|
|
"inputTokens", "outputTokens",
|
|
"cacheCreationTokens", "cacheReadTokens",
|
|
"totalCost",
|
|
}
|
|
for _, f := range totalFields {
|
|
assert.Contains(t, totals, f,
|
|
"missing field %q in totals", f)
|
|
}
|
|
}
|
|
|
|
func TestNewUsageCursorCommandUsesConfigFallbacksAndSharedPagination(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(dataDir, "config.toml"),
|
|
[]byte(
|
|
"cursor_admin_api_key = 'config-key'\n"+
|
|
"cursor_admin_email = 'config@example.com'\n"+
|
|
"cursor_admin_user_id = '152683922'\n",
|
|
),
|
|
0o600,
|
|
), "write config")
|
|
|
|
var requests []map[string]any
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, "/teams/filtered-usage-events", r.URL.Path)
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
user, pass, ok := r.BasicAuth()
|
|
require.True(t, ok, "basic auth")
|
|
assert.Equal(t, "config-key", user)
|
|
assert.Empty(t, pass)
|
|
|
|
var req map[string]any
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req), "decode request")
|
|
requests = append(requests, req)
|
|
|
|
page, _ := req["page"].(float64)
|
|
switch int(page) {
|
|
case 1:
|
|
_, _ = w.Write([]byte(`{
|
|
"totalUsageEventsCount": 2,
|
|
"usageEvents": [{
|
|
"timestamp": "1778753100000",
|
|
"model": "claude-4.6-opus-high-thinking",
|
|
"kind": "USAGE_EVENT_KIND_USAGE_BASED",
|
|
"tokenUsage": {
|
|
"inputTokens": 1234,
|
|
"outputTokens": 567,
|
|
"cacheWriteTokens": 12,
|
|
"cacheReadTokens": 34
|
|
},
|
|
"chargedCents": 15.66,
|
|
"cursorTokenFee": 3.32,
|
|
"userId": "152683922",
|
|
"userEmail": "config@example.com",
|
|
"isHeadless": false
|
|
}]
|
|
}`))
|
|
case 2:
|
|
_, _ = w.Write([]byte(`{
|
|
"totalUsageEventsCount": 2,
|
|
"usageEvents": [{
|
|
"timestamp": "2026-05-14T11:05:00Z",
|
|
"model": "gpt-5.4",
|
|
"kind": "USAGE_EVENT_KIND_USAGE_BASED",
|
|
"tokenUsage": {
|
|
"inputTokens": 1,
|
|
"outputTokens": 2,
|
|
"cacheWriteTokens": 3,
|
|
"cacheReadTokens": 4
|
|
},
|
|
"chargedCents": 1.5,
|
|
"cursorTokenFee": 0.5,
|
|
"userId": "152683922",
|
|
"userEmail": "config@example.com",
|
|
"isHeadless": true
|
|
}]
|
|
}`))
|
|
default:
|
|
t.Fatalf("unexpected page request: %#v", req)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
origNewCursorUsageClient := newCursorUsageClient
|
|
newCursorUsageClient = func(apiKey string) *cursorusage.Client {
|
|
return cursorusage.NewClientWithBaseURL(server.URL, apiKey)
|
|
}
|
|
t.Cleanup(func() {
|
|
newCursorUsageClient = origNewCursorUsageClient
|
|
})
|
|
|
|
cmd := newUsageCursorCommand()
|
|
cmd.SetArgs([]string{
|
|
"--since", "2026-05-14",
|
|
"--until", "2026-05-14",
|
|
"--page-size", "1",
|
|
})
|
|
out := captureStdout(t, func() {
|
|
require.NoError(t, cmd.Execute(), "Execute")
|
|
})
|
|
assert.Contains(t, out, "Fetched 2 Cursor usage events into the archive")
|
|
|
|
require.Len(t, requests, 2, "request count")
|
|
for _, req := range requests {
|
|
assert.Equal(t, "config@example.com", req["email"])
|
|
assert.Equal(t, float64(152683922), req["userId"])
|
|
assert.Equal(t, float64(1), req["pageSize"])
|
|
assert.IsType(t, float64(0), req["startDate"])
|
|
assert.IsType(t, float64(0), req["endDate"])
|
|
}
|
|
assert.Equal(t, float64(1), requests[0]["page"])
|
|
assert.Equal(t, float64(2), requests[1]["page"])
|
|
|
|
database := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db"))
|
|
|
|
var count int
|
|
require.NoError(t, database.Reader().QueryRow(
|
|
"SELECT count(*) FROM cursor_usage_events",
|
|
).Scan(&count))
|
|
assert.Equal(t, 2, count)
|
|
|
|
rows, err := database.Reader().Query(
|
|
`SELECT occurred_at, model, input_tokens, output_tokens,
|
|
cache_write_tokens, cache_read_tokens, user_email, is_headless
|
|
FROM cursor_usage_events
|
|
ORDER BY occurred_at ASC`,
|
|
)
|
|
require.NoError(t, err, "query cursor events")
|
|
defer rows.Close()
|
|
|
|
type storedEvent struct {
|
|
occurredAt string
|
|
model string
|
|
inputTokens int
|
|
outputTokens int
|
|
cacheWriteTokens int
|
|
cacheReadTokens int
|
|
userEmail string
|
|
isHeadless int
|
|
}
|
|
var got []storedEvent
|
|
for rows.Next() {
|
|
var ev storedEvent
|
|
require.NoError(t, rows.Scan(
|
|
&ev.occurredAt,
|
|
&ev.model,
|
|
&ev.inputTokens,
|
|
&ev.outputTokens,
|
|
&ev.cacheWriteTokens,
|
|
&ev.cacheReadTokens,
|
|
&ev.userEmail,
|
|
&ev.isHeadless,
|
|
))
|
|
got = append(got, ev)
|
|
}
|
|
require.NoError(t, rows.Err(), "iterate cursor events")
|
|
require.Len(t, got, 2)
|
|
assert.Equal(t, "2026-05-14T10:05:00Z", got[0].occurredAt)
|
|
assert.Equal(t, "claude-4.6-opus-high-thinking", got[0].model)
|
|
assert.Equal(t, 1234, got[0].inputTokens)
|
|
assert.Equal(t, 567, got[0].outputTokens)
|
|
assert.Equal(t, 12, got[0].cacheWriteTokens)
|
|
assert.Equal(t, 34, got[0].cacheReadTokens)
|
|
assert.Equal(t, "config@example.com", got[0].userEmail)
|
|
assert.Equal(t, 0, got[0].isHeadless)
|
|
assert.Equal(t, "2026-05-14T11:05:00Z", got[1].occurredAt)
|
|
assert.Equal(t, 1, got[1].inputTokens)
|
|
assert.Equal(t, 2, got[1].outputTokens)
|
|
assert.Equal(t, 3, got[1].cacheWriteTokens)
|
|
assert.Equal(t, 4, got[1].cacheReadTokens)
|
|
assert.Equal(t, 1, got[1].isHeadless)
|
|
}
|
|
|
|
func TestResolveCursorUsageWindowUntilOnlyUsesDefaultLookback(t *testing.T) {
|
|
loc := time.UTC
|
|
|
|
start, end, err := resolveCursorUsageWindow(UsageCursorConfig{
|
|
Until: "2026-05-31",
|
|
}, loc)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), start)
|
|
assert.Equal(t,
|
|
time.Date(2026, 5, 31, 23, 59, 59, int(999*time.Millisecond), time.UTC),
|
|
end,
|
|
)
|
|
}
|
|
|
|
func TestResolveCursorUsageWindowRejectsInvertedRange(t *testing.T) {
|
|
_, _, err := resolveCursorUsageWindow(UsageCursorConfig{
|
|
Since: "2026-06-01",
|
|
Until: "2026-05-31",
|
|
}, time.UTC)
|
|
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "after until")
|
|
}
|
|
|
|
func TestNewUsageCursorCommandExplicitMemberFilterDoesNotReuseConfigSibling(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantEmail any
|
|
wantUser any
|
|
}{
|
|
{
|
|
name: "explicit email replaces both configured filters",
|
|
args: []string{"--email", "other@example.com"},
|
|
wantEmail: "other@example.com",
|
|
wantUser: nil,
|
|
},
|
|
{
|
|
name: "explicit empty email clears both configured filters",
|
|
args: []string{"--email="},
|
|
wantEmail: nil,
|
|
wantUser: nil,
|
|
},
|
|
{
|
|
name: "explicit user id replaces both configured filters",
|
|
args: []string{"--user-id", "987654321"},
|
|
wantEmail: nil,
|
|
wantUser: float64(987654321),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(dataDir, "config.toml"),
|
|
[]byte(
|
|
"cursor_admin_api_key = 'config-key'\n"+
|
|
"cursor_admin_email = 'config@example.com'\n"+
|
|
"cursor_admin_user_id = '152683922'\n",
|
|
),
|
|
0o600,
|
|
), "write config")
|
|
|
|
var request map[string]any
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&request),
|
|
"decode request")
|
|
_, _ = w.Write([]byte(`{
|
|
"totalUsageEventsCount": 0,
|
|
"usageEvents": []
|
|
}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
origNewCursorUsageClient := newCursorUsageClient
|
|
newCursorUsageClient = func(apiKey string) *cursorusage.Client {
|
|
return cursorusage.NewClientWithBaseURL(server.URL, apiKey)
|
|
}
|
|
t.Cleanup(func() {
|
|
newCursorUsageClient = origNewCursorUsageClient
|
|
})
|
|
|
|
args := []string{
|
|
"--since", "2026-05-14",
|
|
"--until", "2026-05-14",
|
|
}
|
|
args = append(args, tc.args...)
|
|
|
|
cmd := newUsageCursorCommand()
|
|
cmd.SetArgs(args)
|
|
require.NoError(t, cmd.Execute(), "Execute")
|
|
|
|
require.NotNil(t, request, "request")
|
|
assert.Equal(t, tc.wantEmail, request["email"])
|
|
assert.Equal(t, tc.wantUser, request["userId"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefreshPricingIfStale_FreshAttemptSkipsFetch(t *testing.T) {
|
|
d := newTestDB(t)
|
|
now := pricingTestNow()
|
|
|
|
// Last attempt 10 minutes ago, cooldown 1 hour: skip.
|
|
prev := seedPricingAttempt(t, d, now, 10*time.Minute)
|
|
|
|
fetcher := &pricingFetchRecorder{}
|
|
refreshed, err := refreshPricingIfStale(
|
|
d, fetcher.fetch, pricingTestCooldown, now,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.False(t, refreshed, "refreshed = true, want false within cooldown")
|
|
assert.Zero(t, fetcher.calls, "fetch should not run within cooldown")
|
|
|
|
// Meta value preserved (we did not overwrite it).
|
|
assertPricingAttemptMeta(t, d, prev)
|
|
}
|
|
|
|
func TestRefreshPricingIfStale_StaleTriggersFetch(t *testing.T) {
|
|
d := newTestDB(t)
|
|
now := pricingTestNow()
|
|
|
|
// Last attempt 2 hours ago, cooldown 1 hour: refresh.
|
|
seedPricingAttempt(t, d, now, 2*time.Hour)
|
|
|
|
fetcher := &pricingFetchRecorder{rows: []pricing.ModelPricing{{
|
|
ModelPattern: "gpt-5.5",
|
|
InputPerMTok: 1.25,
|
|
OutputPerMTok: 10.0,
|
|
}}}
|
|
refreshed, err := refreshPricingIfStale(
|
|
d, fetcher.fetch, pricingTestCooldown, now,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, refreshed, "refreshed = false, want true after cooldown")
|
|
|
|
// Pricing row written.
|
|
p, err := d.GetModelPricing("gpt-5.5")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, p, "gpt-5.5 row missing")
|
|
assert.Equal(t, 10.0, p.OutputPerMTok)
|
|
|
|
// Meta updated to now.
|
|
assertPricingAttemptMeta(t, d, now.Format(time.RFC3339))
|
|
}
|
|
|
|
func TestRefreshPricingIfStale_NeverAttemptedTriggersFetch(t *testing.T) {
|
|
d := newTestDB(t)
|
|
now := pricingTestNow()
|
|
|
|
fetcher := &pricingFetchRecorder{}
|
|
refreshed, err := refreshPricingIfStale(
|
|
d, fetcher.fetch, pricingTestCooldown, now,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, fetcher.calls, "fetch should run when meta empty")
|
|
assert.True(t, refreshed, "refreshed = false, want true on first attempt")
|
|
}
|
|
|
|
func TestRefreshPricingIfStale_FetchFailureRecordsAttempt(t *testing.T) {
|
|
d := newTestDB(t)
|
|
now := pricingTestNow()
|
|
|
|
wantErr := errors.New("network down")
|
|
fetcher := &pricingFetchRecorder{err: wantErr}
|
|
refreshed, err := refreshPricingIfStale(
|
|
d, fetcher.fetch, pricingTestCooldown, now,
|
|
)
|
|
assert.ErrorIs(t, err, wantErr)
|
|
assert.False(t, refreshed, "refreshed = true, want false on fetch failure")
|
|
|
|
// Cooldown still recorded so a persistent failure doesn't
|
|
// retry on every CLI call.
|
|
assertPricingAttemptMeta(t, d, now.Format(time.RFC3339))
|
|
|
|
// A second call within cooldown skips the fetch entirely.
|
|
second := &pricingFetchRecorder{}
|
|
_, err = refreshPricingIfStale(
|
|
d, second.fetch, pricingTestCooldown, now.Add(time.Minute),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Zero(t, second.calls, "second call should be suppressed by cooldown")
|
|
}
|
|
|
|
func TestEnsurePricingWithFetcherSkipsFetchWithinCooldown(t *testing.T) {
|
|
d := newTestDB(t)
|
|
now := pricingTestNow()
|
|
|
|
seedPricingAttempt(t, d, now, 10*time.Minute)
|
|
|
|
fetcher := &pricingFetchRecorder{rows: []pricing.ModelPricing{{
|
|
ModelPattern: "network-only-model",
|
|
InputPerMTok: 1,
|
|
OutputPerMTok: 1,
|
|
}}}
|
|
refreshed, err := ensurePricingWithFetcher(d, false, fetcher.fetch, now)
|
|
require.NoError(t, err)
|
|
assert.False(t, refreshed)
|
|
assert.Zero(t, fetcher.calls, "fetch should not run within cooldown")
|
|
|
|
fallback, err := d.GetModelPricing("gpt-5.5")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, fallback, "fallback pricing should be seeded")
|
|
|
|
networkOnly, err := d.GetModelPricing("network-only-model")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, networkOnly, "cooldown should prevent network upsert")
|
|
}
|
|
|
|
// sampleDailyUsageJSON is a full usage summary body with a single day and
|
|
// non-zero totals, shared by the HTTP and daemon usage tests.
|
|
const sampleDailyUsageJSON = `{
|
|
"schema_version": 1,
|
|
"from": "2026-06-01",
|
|
"to": "2026-06-02",
|
|
"pricing": {
|
|
"source": "embedded",
|
|
"table_version": "test",
|
|
"latest_row_updated_at": null,
|
|
"custom_override_count": 0,
|
|
"effective_row_count": 1,
|
|
"digest": "test-digest",
|
|
"cost_source": "reported",
|
|
"fallback": {"used": false, "models": []},
|
|
"models": {
|
|
"gpt-5.1": {
|
|
"matched_pattern": "gpt-5.1",
|
|
"input_cost_per_mtok": 1,
|
|
"output_cost_per_mtok": 2,
|
|
"cache_write_cost_per_mtok": 3,
|
|
"cache_read_cost_per_mtok": 4,
|
|
"cost_source": "reported"
|
|
}
|
|
}
|
|
},
|
|
"projects": {
|
|
"pl1:fixture": {
|
|
"display_label": "proj",
|
|
"resolution": "unknown"
|
|
}
|
|
},
|
|
"totals": {
|
|
"inputTokens": 10,
|
|
"outputTokens": 20,
|
|
"totalCost": 0.42
|
|
},
|
|
"daily": [{
|
|
"date": "2026-06-01",
|
|
"inputTokens": 10,
|
|
"outputTokens": 20,
|
|
"totalCost": 0.42,
|
|
"modelsUsed": ["gpt-5.1"]
|
|
}],
|
|
"sessionCounts": {
|
|
"total": 1,
|
|
"byProject": {"proj": 1},
|
|
"byAgent": {"codex": 1}
|
|
}
|
|
}`
|
|
|
|
// emptyDailyUsageJSON is an empty usage summary used when the test only
|
|
// inspects the outbound request.
|
|
const emptyDailyUsageJSON = `{"totals":{},"daily":[]}`
|
|
|
|
// totalCostOnlyUsageJSON carries a non-zero total cost but no daily rows.
|
|
const totalCostOnlyUsageJSON = `{"totals":{"totalCost":0.42},"daily":[]}`
|
|
|
|
// pricingTestCooldown is the cooldown used by the pricing refresh tests.
|
|
const pricingTestCooldown = time.Hour
|
|
|
|
// newAgentDataDir creates a temp data dir and points AGENTSVIEW_DATA_DIR at it.
|
|
func newAgentDataDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := testDataDir(t)
|
|
return dir
|
|
}
|
|
|
|
// sessionsDBPath returns the canonical sessions.db path under dataDir.
|
|
func sessionsDBPath(dataDir string) string {
|
|
return filepath.Join(dataDir, "sessions.db")
|
|
}
|
|
|
|
// assertNoLocalSessionsDB fails if a local sessions.db was created, which would
|
|
// mean a remote/daemon path unexpectedly opened a local database.
|
|
func assertNoLocalSessionsDB(t *testing.T, dataDir string) {
|
|
t.Helper()
|
|
assert.NoFileExists(t, sessionsDBPath(dataDir))
|
|
}
|
|
|
|
// writeJSONResponse writes body as a JSON HTTP response.
|
|
func writeJSONResponse(w http.ResponseWriter, body string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
}
|
|
|
|
// pricingTestNow is the fixed clock used by the pricing refresh tests.
|
|
func pricingTestNow() time.Time {
|
|
return time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// seedPricingAttempt records a pricing refresh attempt aged `age` before now
|
|
// and returns the RFC3339 timestamp written.
|
|
func seedPricingAttempt(
|
|
t *testing.T, d *db.DB, now time.Time, age time.Duration,
|
|
) string {
|
|
t.Helper()
|
|
ts := now.Add(-age).Format(time.RFC3339)
|
|
require.NoError(t, d.SetPricingMeta(pricingRefreshMetaKey, ts))
|
|
return ts
|
|
}
|
|
|
|
// assertPricingAttemptMeta asserts the stored refresh attempt timestamp.
|
|
func assertPricingAttemptMeta(t *testing.T, d *db.DB, want string) {
|
|
t.Helper()
|
|
got, err := d.GetPricingMeta(pricingRefreshMetaKey)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
// pricingFetchRecorder is a fake pricing fetcher that records call counts and
|
|
// returns canned rows or an error.
|
|
type pricingFetchRecorder struct {
|
|
calls int
|
|
rows []pricing.ModelPricing
|
|
err error
|
|
}
|
|
|
|
func (f *pricingFetchRecorder) fetch() ([]pricing.ModelPricing, error) {
|
|
f.calls++
|
|
return f.rows, f.err
|
|
}
|
|
|
|
// zeroTotalsCopilotUsageJSON is a daily-usage summary with sessions present
|
|
// but zero token/cost totals — the "no token data" case.
|
|
const zeroTotalsCopilotUsageJSON = `{
|
|
"daily": [],
|
|
"totals": {"inputTokens":0,"outputTokens":0,"cacheCreationTokens":0,"cacheReadTokens":0,"totalCost":0},
|
|
"sessionCounts": {"total":2,"byProject":{},"byAgent":{"copilot":2}}
|
|
}`
|
|
|
|
func TestRunUsageDailyHintsNoTokenDataForCopilot(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSONResponse(w, zeroTotalsCopilotUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
stderr := captureStderr(t, func() {
|
|
runUsageDaily(UsageDailyConfig{Agent: "copilot", Timezone: "UTC"})
|
|
})
|
|
|
|
assert.Contains(t, stderr, "Copilot")
|
|
assert.Contains(t, stderr, "do not include token or cost data")
|
|
}
|
|
|
|
func TestRunUsageDailyNoHintWithoutAgentFilter(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSONResponse(w, zeroTotalsCopilotUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
stderr := captureStderr(t, func() {
|
|
runUsageDaily(UsageDailyConfig{Timezone: "UTC"})
|
|
})
|
|
|
|
assert.NotContains(t, stderr, "token")
|
|
}
|
|
|
|
func TestRunUsageDailyNoHintWhenDataPresent(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
ts := sessionUsageRuntimeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSONResponse(w, sampleDailyUsageJSON)
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
stderr := captureStderr(t, func() {
|
|
runUsageDaily(UsageDailyConfig{Agent: "codex", Timezone: "UTC"})
|
|
})
|
|
|
|
assert.NotContains(t, stderr, "token-usage")
|
|
}
|
|
|
|
func TestNoTokenDataNote(t *testing.T) {
|
|
parsertest.StubAgentDefs(t,
|
|
parser.AgentDef{
|
|
Type: parser.AgentType("no-token-agent"),
|
|
DisplayName: "No Token Agent",
|
|
Usage: parser.UsageCapabilities{
|
|
NoPerMessageTokenData: true,
|
|
},
|
|
},
|
|
parser.AgentDef{
|
|
Type: parser.AgentType("credit-note-agent"),
|
|
DisplayName: "Credit Note Agent",
|
|
Usage: parser.UsageCapabilities{
|
|
NoPerMessageTokenData: true,
|
|
AICreditsDenominated: true,
|
|
},
|
|
},
|
|
)
|
|
|
|
zero := db.UsageTotals{}
|
|
withData := db.UsageTotals{OutputTokens: 5}
|
|
copilotNote := "note: these GitHub Copilot records do not include token " +
|
|
"or cost data that agentsview can total."
|
|
genericNote := "note: matching sessions do not record per-message token usage."
|
|
cases := []struct {
|
|
name string
|
|
agent string
|
|
totals db.UsageTotals
|
|
want string
|
|
}{
|
|
{"no agent filter", "", zero, ""},
|
|
{"non-copilot agent with zero totals", "codex", zero, ""},
|
|
{"copilot with token data", "copilot", withData, ""},
|
|
{"copilot with zero totals", "copilot", zero, copilotNote},
|
|
{"vscode-copilot with zero totals", "vscode-copilot", zero, copilotNote},
|
|
{"all-copilot CSV filter", "copilot,vscode-copilot", zero, copilotNote},
|
|
{"non-copilot no-token agent", "no-token-agent", zero, genericNote},
|
|
{"non-copilot ai-credit agent", "credit-note-agent", zero, genericNote},
|
|
{"mixed CSV filter", "copilot,claude", zero, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.want,
|
|
noTokenDataNote(tc.agent, tc.totals))
|
|
})
|
|
}
|
|
}
|