f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
797 lines
22 KiB
Go
797 lines
22 KiB
Go
package service_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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/dbtest"
|
|
"go.kenn.io/agentsview/internal/service"
|
|
)
|
|
|
|
func seedPairwiseUsageFixture(t *testing.T, d *db.DB) {
|
|
t.Helper()
|
|
|
|
type usageSeed struct {
|
|
id string
|
|
project string
|
|
started string
|
|
model string
|
|
input int
|
|
output int
|
|
create int
|
|
read int
|
|
}
|
|
|
|
seeds := []usageSeed{
|
|
{
|
|
id: "usage-alpha-sonnet",
|
|
project: "alpha",
|
|
started: "2024-06-01T09:00:00Z",
|
|
model: "claude-sonnet-4-20250514",
|
|
input: 100,
|
|
output: 50,
|
|
create: 10,
|
|
read: 20,
|
|
},
|
|
{
|
|
id: "usage-beta-gpt",
|
|
project: "beta",
|
|
started: "2024-06-01T10:00:00Z",
|
|
model: "gpt-4o",
|
|
input: 30,
|
|
output: 15,
|
|
create: 0,
|
|
read: 5,
|
|
},
|
|
{
|
|
id: "usage-beta-sonnet",
|
|
project: "beta",
|
|
started: "2024-06-01T11:00:00Z",
|
|
model: "claude-sonnet-4-20250514",
|
|
input: 70,
|
|
output: 35,
|
|
create: 0,
|
|
read: 0,
|
|
},
|
|
}
|
|
|
|
for _, seed := range seeds {
|
|
started := seed.started
|
|
dbtest.SeedSessionWithMessages(
|
|
t,
|
|
d,
|
|
seed.id,
|
|
seed.project,
|
|
[]db.Message{
|
|
dbtest.UserMsg(seed.id, 0, "compare usage"),
|
|
assistantUsageMsg(seed.id, 1, seed),
|
|
},
|
|
dbtest.WithMessageCounts(2, 1),
|
|
func(s *db.Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = &started
|
|
s.EndedAt = &started
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func seedCommaProjectUsageFixture(t *testing.T, d *db.DB) {
|
|
t.Helper()
|
|
started := "2024-06-01T09:00:00Z"
|
|
for _, seed := range []struct {
|
|
id string
|
|
project string
|
|
input int
|
|
}{
|
|
{id: "usage-comma", project: "team,core", input: 100},
|
|
{id: "usage-other", project: "other", input: 25},
|
|
} {
|
|
msg := dbtest.AsstMsg(seed.id, 1, "done")
|
|
msg.Timestamp = started
|
|
msg.Model = "test-model"
|
|
msg.TokenUsage = fmt.Appendf(nil,
|
|
`{"input_tokens":%d}`, seed.input)
|
|
dbtest.SeedSessionWithMessages(
|
|
t,
|
|
d,
|
|
seed.id,
|
|
seed.project,
|
|
[]db.Message{
|
|
dbtest.UserMsg(seed.id, 0, "compare usage"),
|
|
msg,
|
|
},
|
|
dbtest.WithMessageCounts(2, 1),
|
|
func(s *db.Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = &started
|
|
s.EndedAt = &started
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func assistantUsageMsg(
|
|
sessionID string, ordinal int, seed struct {
|
|
id string
|
|
project string
|
|
started string
|
|
model string
|
|
input int
|
|
output int
|
|
create int
|
|
read int
|
|
},
|
|
) db.Message {
|
|
msg := dbtest.AsstMsg(sessionID, ordinal, "done")
|
|
msg.Timestamp = seed.started
|
|
msg.Model = seed.model
|
|
msg.TokenUsage = fmt.Appendf(nil,
|
|
`{"input_tokens":%d,"output_tokens":%d,"cache_creation_input_tokens":%d,"cache_read_input_tokens":%d}`,
|
|
seed.input,
|
|
seed.output,
|
|
seed.create,
|
|
seed.read,
|
|
)
|
|
return msg
|
|
}
|
|
|
|
func TestBuildUsageFilter_ValidMapping(t *testing.T) {
|
|
t.Parallel()
|
|
f, err := service.BuildUsageFilter(service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-15",
|
|
Project: "proj",
|
|
Agent: "claude",
|
|
// IncludeOneShot/IncludeAutomated default false -> exclude true.
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "2024-06-01", f.From)
|
|
assert.Equal(t, "2024-06-15", f.To)
|
|
assert.Equal(t, "proj", f.Project)
|
|
assert.Equal(t, "UTC", f.Timezone, "empty timezone defaults to UTC")
|
|
assert.True(t, f.ExcludeOneShot, "IncludeOneShot=false -> ExcludeOneShot=true")
|
|
assert.True(t, f.ExcludeAutomated, "IncludeAutomated=false -> ExcludeAutomated=true")
|
|
assert.True(t, f.Breakdowns, "summary needs per-day breakdowns")
|
|
}
|
|
|
|
func TestBuildUsageFilter_IncludeFlagsInvert(t *testing.T) {
|
|
t.Parallel()
|
|
f, err := service.BuildUsageFilter(service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-02",
|
|
IncludeOneShot: true,
|
|
IncludeAutomated: true,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, f.ExcludeOneShot)
|
|
assert.False(t, f.ExcludeAutomated)
|
|
}
|
|
|
|
func TestBuildUsageFilter_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
req service.UsageRequest
|
|
}{
|
|
{"bad timezone", service.UsageRequest{Timezone: "Fake/Zone"}},
|
|
{"bad from date", service.UsageRequest{From: "yesterday", To: "2024-06-02"}},
|
|
{"from after to", service.UsageRequest{From: "2024-07-01", To: "2024-06-01"}},
|
|
{"bad active_since", service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-02", ActiveSince: "not-a-ts",
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := service.BuildUsageFilter(tc.req)
|
|
require.Error(t, err)
|
|
var ue *service.UsageInputError
|
|
assert.True(t, errors.As(err, &ue),
|
|
"want UsageInputError, got %T", err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDirectBackend_UsageSummary_InvalidInput(t *testing.T) {
|
|
t.Parallel()
|
|
d := dbtest.OpenTestDB(t)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
_, err := be.UsageSummary(context.Background(), service.UsageRequest{
|
|
Timezone: "Fake/Zone",
|
|
})
|
|
require.Error(t, err)
|
|
var ue *service.UsageInputError
|
|
assert.True(t, errors.As(err, &ue), "want UsageInputError, got %T", err)
|
|
}
|
|
|
|
func TestDirectBackend_UsageSummary_UnknownProjectKeyHasStableCode(t *testing.T) {
|
|
t.Parallel()
|
|
d := dbtest.OpenTestDB(t)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
_, err := be.UsageSummary(context.Background(), service.UsageRequest{
|
|
ExcludeProjectKey: "pl1:sha256:stale",
|
|
})
|
|
require.Error(t, err)
|
|
var inputErr *service.UsageInputError
|
|
require.ErrorAs(t, err, &inputErr)
|
|
assert.Equal(t, service.UsageErrorCodeUnknownProjectKey, inputErr.Code)
|
|
}
|
|
|
|
func TestDirectBackend_UsageSummary_EmptyRange(t *testing.T) {
|
|
t.Parallel()
|
|
d := dbtest.OpenTestDB(t)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
res, err := be.UsageSummary(context.Background(), service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-03",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, "2024-06-01", res.From)
|
|
assert.Equal(t, "2024-06-03", res.To)
|
|
assert.NotNil(t, res.ProjectTotals, "folds should be non-nil slices")
|
|
}
|
|
|
|
func TestHTTPBackend_UsageSummary_Roundtrip(t *testing.T) {
|
|
t.Parallel()
|
|
env := newHTTPBackendEnv(t)
|
|
svc := env.Backend("", false)
|
|
|
|
res, err := svc.UsageSummary(context.Background(), service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-03",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, "2024-06-01", res.From)
|
|
assert.Equal(t, "2024-06-03", res.To)
|
|
}
|
|
|
|
// The server defaults include_one_shot to true, so the HTTP backend must
|
|
// always send it explicitly to faithfully transmit a false value.
|
|
func TestHTTPBackend_UsageSummary_SendsExplicitIncludeOneShot(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
includeOneShot bool
|
|
want string
|
|
}{
|
|
{"false", false, "false"},
|
|
{"true", true, "true"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var got string
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.URL.Query().Get("include_one_shot")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"from":"x","to":"y"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
svc := service.NewHTTPBackend(srv.URL, "", false)
|
|
|
|
_, err := svc.UsageSummary(context.Background(), service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-02",
|
|
IncludeOneShot: tc.includeOneShot,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A read-only daemon (pg serve) returns 501 for usage; the HTTP backend
|
|
// maps that to the shared db.ErrReadOnly sentinel.
|
|
func TestHTTPBackend_UsageSummary_ReadOnly(t *testing.T) {
|
|
t.Parallel()
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotImplemented)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
svc := service.NewHTTPBackend(srv.URL, "", true)
|
|
|
|
_, err := svc.UsageSummary(context.Background(), service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-02",
|
|
})
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, db.ErrReadOnly),
|
|
"501 should map to db.ErrReadOnly, got %v", err)
|
|
}
|
|
|
|
func TestBuildUsagePairwiseFilters_Validation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, _, _, _, err := service.BuildUsagePairwiseFilters(
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{From: "2024-06-01", To: "2024-06-02"},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "machine",
|
|
RightValue: "host-a",
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
var ue *service.UsageInputError
|
|
assert.True(t, errors.As(err, &ue))
|
|
assert.Equal(t, "right_dimension must be model or project", ue.Msg)
|
|
}
|
|
|
|
func TestBuildUsagePairwiseFilters_PreservesNonComparedBaseFilters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
left, leftEmpty, right, rightEmpty, err := service.BuildUsagePairwiseFilters(
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-03",
|
|
Timezone: "UTC",
|
|
Model: "model-a,model-b",
|
|
Project: "alpha,beta",
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "model-b",
|
|
RightDimension: "project",
|
|
RightValue: "beta",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.False(t, leftEmpty)
|
|
assert.False(t, rightEmpty)
|
|
assert.Equal(t, "model-b", left.Model)
|
|
assert.Equal(t, "alpha,beta", left.Project)
|
|
assert.Equal(t, "model-a,model-b", right.Model)
|
|
assert.Equal(t, "beta", right.Project)
|
|
}
|
|
|
|
func TestBuildUsagePairwiseFilters_ConflictingBaseFilterMarksEmptySide(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
left, leftEmpty, right, rightEmpty, err := service.BuildUsagePairwiseFilters(
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-03",
|
|
Model: "gpt-4o",
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.True(t, leftEmpty)
|
|
assert.False(t, rightEmpty)
|
|
assert.Empty(t, left.Model)
|
|
assert.Equal(t, "gpt-4o", right.Model)
|
|
}
|
|
|
|
func TestDirectBackend_UsagePairwiseComparison_ModelVsModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedPairwiseUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
res, err := be.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-01",
|
|
Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, 2, res.Left.SessionCount)
|
|
assert.Equal(t, 285, res.Left.TotalTokens)
|
|
assert.Equal(t, 1, res.Right.SessionCount)
|
|
assert.Equal(t, 50, res.Right.TotalTokens)
|
|
assert.Equal(t, -235, res.Deltas.TotalTokensDelta)
|
|
require.NotNil(t, res.Left.CostPerSession)
|
|
require.NotNil(t, res.Right.CostPerSession)
|
|
}
|
|
|
|
func TestDirectBackend_UsagePairwiseComparison_ProjectVsModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedPairwiseUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
summary, err := be.UsageSummary(context.Background(), service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
})
|
|
require.NoError(t, err)
|
|
var betaKey string
|
|
for _, project := range summary.ProjectTotals {
|
|
if project.Project == "beta" {
|
|
betaKey = project.ProjectKey
|
|
}
|
|
}
|
|
require.NotEmpty(t, betaKey)
|
|
|
|
res, err := be.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-01",
|
|
Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
},
|
|
LeftDimension: "project",
|
|
LeftValue: betaKey,
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, 2, res.Left.SessionCount)
|
|
assert.Equal(t, 155, res.Left.TotalTokens)
|
|
assert.Equal(t, 1, res.Right.SessionCount)
|
|
assert.Equal(t, 50, res.Right.TotalTokens)
|
|
}
|
|
|
|
func TestDirectBackend_UsagePairwiseComparison_PreservesCommaProjectLabel(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedCommaProjectUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
base := service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
}
|
|
summary, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
keys := make(map[string]string)
|
|
for _, project := range summary.ProjectTotals {
|
|
keys[project.Project] = project.ProjectKey
|
|
}
|
|
require.NotEmpty(t, keys["team,core"])
|
|
require.NotEmpty(t, keys["other"])
|
|
|
|
comparison, err := be.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: base,
|
|
LeftDimension: "project",
|
|
LeftValue: keys["team,core"],
|
|
RightDimension: "project",
|
|
RightValue: keys["other"],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, comparison)
|
|
assert.Equal(t, 1, comparison.Left.SessionCount)
|
|
assert.Equal(t, 100, comparison.Left.TotalTokens)
|
|
assert.Equal(t, 1, comparison.Right.SessionCount)
|
|
assert.Equal(t, 25, comparison.Right.TotalTokens)
|
|
}
|
|
|
|
func TestDirectBackend_UsageSummary_ExcludesOpaqueProjectKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedPairwiseUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
base := service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
}
|
|
summary, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
var betaKey string
|
|
for _, project := range summary.ProjectTotals {
|
|
if project.Project == "beta" {
|
|
betaKey = project.ProjectKey
|
|
}
|
|
}
|
|
require.NotEmpty(t, betaKey)
|
|
|
|
base.ExcludeProjectKey = betaKey
|
|
filtered, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
require.Len(t, filtered.ProjectTotals, 1)
|
|
assert.Equal(t, "alpha", filtered.ProjectTotals[0].Project)
|
|
}
|
|
|
|
func TestDirectBackend_UsageSummary_ExcludesSubagentOnlyProjectKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
started := "2024-06-01T09:00:00Z"
|
|
msg := dbtest.AsstMsg("usage-subagent", 1, "done")
|
|
msg.Timestamp = started
|
|
msg.Model = "test-model"
|
|
msg.TokenUsage = []byte(`{"input_tokens":100}`)
|
|
dbtest.SeedSessionWithMessages(
|
|
t,
|
|
d,
|
|
"usage-subagent",
|
|
"subagent-only",
|
|
[]db.Message{
|
|
dbtest.UserMsg("usage-subagent", 0, "compare usage"),
|
|
msg,
|
|
},
|
|
dbtest.WithMessageCounts(2, 1),
|
|
func(s *db.Session) {
|
|
s.Agent = "claude"
|
|
s.RelationshipType = "subagent"
|
|
s.StartedAt = &started
|
|
s.EndedAt = &started
|
|
},
|
|
)
|
|
be := service.NewDirectBackend(d, nil)
|
|
base := service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
}
|
|
|
|
summary, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
require.Len(t, summary.ProjectTotals, 1)
|
|
require.NotEmpty(t, summary.ProjectTotals[0].ProjectKey)
|
|
assert.Equal(t, "subagent-only", summary.ProjectTotals[0].Project)
|
|
|
|
base.ExcludeProjectKey = summary.ProjectTotals[0].ProjectKey
|
|
filtered, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, filtered.ProjectTotals)
|
|
assert.Zero(t, filtered.Totals.InputTokens)
|
|
}
|
|
|
|
func TestDirectBackend_UsageSummary_ExcludesCommaProjectByOpaqueKey(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedCommaProjectUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
base := service.UsageRequest{
|
|
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
}
|
|
summary, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
var commaKey string
|
|
for _, project := range summary.ProjectTotals {
|
|
if project.Project == "team,core" {
|
|
commaKey = project.ProjectKey
|
|
}
|
|
}
|
|
require.NotEmpty(t, commaKey)
|
|
|
|
base.ExcludeProjectKey = commaKey
|
|
filtered, err := be.UsageSummary(context.Background(), base)
|
|
require.NoError(t, err)
|
|
require.Len(t, filtered.ProjectTotals, 1)
|
|
assert.Equal(t, "other", filtered.ProjectTotals[0].Project)
|
|
assert.Equal(t, 25, filtered.ProjectTotals[0].InputTokens)
|
|
}
|
|
|
|
func TestDirectBackend_UsagePairwiseComparison_ZeroDataSide(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedPairwiseUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
res, err := be.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-01",
|
|
Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
},
|
|
LeftDimension: "project",
|
|
LeftValue: "missing-project",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Zero(t, res.Left.SessionCount)
|
|
assert.Zero(t, res.Left.TotalTokens)
|
|
assert.Nil(t, res.Left.CostPerSession)
|
|
assert.Nil(t, res.Deltas.TotalCostDeltaRatio)
|
|
}
|
|
|
|
func TestDirectBackend_UsagePairwiseComparison_ConflictingBaseFilterReturnsZeroData(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedPairwiseUsageFixture(t, d)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
res, err := be.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-01",
|
|
Timezone: "UTC",
|
|
Model: "gpt-4o",
|
|
IncludeOneShot: true,
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Zero(t, res.Left.SessionCount)
|
|
assert.Zero(t, res.Left.TotalTokens)
|
|
assert.Equal(t, 1, res.Right.SessionCount)
|
|
assert.Equal(t, 50, res.Right.TotalTokens)
|
|
}
|
|
|
|
func TestDirectBackend_UsagePairwiseComparison_DoesNotUseSentinelFilterValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := dbtest.OpenTestDB(t)
|
|
seedPairwiseUsageFixture(t, d)
|
|
started := "2024-06-01T12:00:00Z"
|
|
dbtest.SeedSessionWithMessages(
|
|
t,
|
|
d,
|
|
"usage-sentinel-model",
|
|
"sentinel-project",
|
|
[]db.Message{
|
|
dbtest.UserMsg("usage-sentinel-model", 0, "compare usage"),
|
|
assistantUsageMsg("usage-sentinel-model", 1, struct {
|
|
id string
|
|
project string
|
|
started string
|
|
model string
|
|
input int
|
|
output int
|
|
create int
|
|
read int
|
|
}{
|
|
id: "usage-sentinel-model",
|
|
project: "sentinel-project",
|
|
started: started,
|
|
model: "__agentsview_no_match__",
|
|
input: 999,
|
|
output: 999,
|
|
create: 0,
|
|
read: 0,
|
|
}),
|
|
},
|
|
dbtest.WithMessageCounts(2, 1),
|
|
func(s *db.Session) {
|
|
s.Agent = "claude"
|
|
s.StartedAt = &started
|
|
s.EndedAt = &started
|
|
},
|
|
)
|
|
be := service.NewDirectBackend(d, nil)
|
|
|
|
res, err := be.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-01",
|
|
Timezone: "UTC",
|
|
Model: "gpt-4o",
|
|
IncludeOneShot: true,
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Zero(t, res.Left.SessionCount)
|
|
assert.Zero(t, res.Left.TotalTokens)
|
|
assert.Equal(t, 1, res.Right.SessionCount)
|
|
assert.Equal(t, 50, res.Right.TotalTokens)
|
|
}
|
|
|
|
func TestHTTPBackend_UsagePairwiseComparison_Roundtrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newHTTPBackendEnv(t)
|
|
seedPairwiseUsageFixture(t, env.DB)
|
|
svc := env.Backend("", false)
|
|
|
|
res, err := svc.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
From: "2024-06-01",
|
|
To: "2024-06-01",
|
|
Timezone: "UTC",
|
|
IncludeOneShot: true,
|
|
},
|
|
LeftDimension: "model",
|
|
LeftValue: "claude-sonnet-4-20250514",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, 2, res.Left.SessionCount)
|
|
assert.Equal(t, 1, res.Right.SessionCount)
|
|
}
|
|
|
|
func TestHTTPBackend_UsagePairwiseComparison_SerializesRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var queryValues map[string]string
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
queryValues = map[string]string{
|
|
"left_dimension": r.URL.Query().Get("left_dimension"),
|
|
"left_value": r.URL.Query().Get("left_value"),
|
|
"right_dimension": r.URL.Query().Get("right_dimension"),
|
|
"right_value": r.URL.Query().Get("right_value"),
|
|
"git_branch": r.URL.Query().Get("git_branch"),
|
|
"exclude_project_key": r.URL.Query().Get("exclude_project_key"),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"left":{"sessionCount":1,"totalTokens":12},"right":{"sessionCount":2,"totalTokens":34},"deltas":{"sessionCountDelta":1,"totalTokensDelta":22}}`))
|
|
},
|
|
))
|
|
t.Cleanup(srv.Close)
|
|
svc := service.NewHTTPBackend(srv.URL, "", false)
|
|
|
|
res, err := svc.UsagePairwiseComparison(
|
|
context.Background(),
|
|
service.UsagePairwiseComparisonRequest{
|
|
UsageRequest: service.UsageRequest{
|
|
GitBranch: "alpha/main",
|
|
ExcludeProjectKey: "pl1:sha256:hidden",
|
|
},
|
|
LeftDimension: "project",
|
|
LeftValue: "alpha",
|
|
RightDimension: "model",
|
|
RightValue: "gpt-4o",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, res)
|
|
assert.Equal(t, "project", queryValues["left_dimension"])
|
|
assert.Equal(t, "alpha", queryValues["left_value"])
|
|
assert.Equal(t, "model", queryValues["right_dimension"])
|
|
assert.Equal(t, "gpt-4o", queryValues["right_value"])
|
|
assert.Equal(t, "alpha/main", queryValues["git_branch"])
|
|
assert.Equal(t, "pl1:sha256:hidden", queryValues["exclude_project_key"])
|
|
assert.Equal(t, 22, res.Deltas.TotalTokensDelta)
|
|
}
|