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

1137 lines
30 KiB
Go

package server_test
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/dbtest"
)
const basePath = "/api/v1/analytics/"
// seedStats holds expected values after seeding the database.
type seedStats struct {
TotalSessions int
TotalMessages int
ActiveProjects int
TotalToolCalls int
TotalSkillCalls int
Agents int
ActiveDays int
TotalOutputTokens int
TokenReportingSessions int
TopSessionOutputTokens int
}
// seedAnalyticsEnv populates the test env with sessions and
// messages suitable for analytics endpoint tests. Some messages
// include tool_calls for tool analytics testing.
func seedAnalyticsEnv(t *testing.T, te *testEnv) seedStats {
t.Helper()
type entry struct {
id, project, agent, started, model string
msgs int
}
entries := []entry{
{"a1", "alpha", "claude", "2024-06-01T09:00:00Z", "claude-3-5-sonnet", 10},
{"a2", "alpha", "codex", "2024-06-01T14:00:00Z", "gpt-4o", 20},
{"b1", "beta", "claude", "2024-06-02T10:00:00Z", "claude-3-5-sonnet", 30},
}
stats := seedStats{
TotalSessions: len(entries),
}
projects := make(map[string]bool)
agents := make(map[string]bool)
days := make(map[string]bool)
writes := make([]db.SessionBatchWrite, 0, len(entries))
for _, s := range entries {
projects[s.project] = true
agents[s.agent] = true
if len(s.started) >= 10 {
days[s.started[:10]] = true
}
stats.TotalMessages += s.msgs
started := s.started
msgs := buildTestMessages(s.id, s.msgs,
func(i int, m *db.Message) {
// Skill analytics now buckets and filters by message
// timestamp, so align messages with the session window.
m.Timestamp = started
m.Model = s.model
// Add tool calls on every other assistant msg
if m.Role == "assistant" && i%4 == 1 {
m.HasToolUse = true
m.ToolCalls = []db.ToolCall{
{
SessionID: s.id,
ToolName: "Read",
Category: "Read",
SkillName: "review-code",
},
}
stats.TotalToolCalls++
stats.TotalSkillCalls++
}
},
)
writes = append(writes, db.SessionBatchWrite{
Session: db.Session{
ID: s.id,
Project: s.project,
Machine: "test",
Agent: s.agent,
MessageCount: s.msgs,
UserMessageCount: max(s.msgs, 2),
StartedAt: &started,
EndedAt: &started,
FirstMessage: new("Hello"),
},
Messages: msgs,
})
}
result, err := te.db.WriteSessionBatchAtomic(writes)
require.NoError(t, err)
require.Equal(t, len(entries), result.WrittenSessions)
require.Equal(t, stats.TotalMessages, result.WrittenMessages)
stats.ActiveProjects = len(projects)
stats.Agents = len(agents)
stats.ActiveDays = len(days)
return stats
}
func seedAnalyticsTokenEnv(t *testing.T, te *testEnv) seedStats {
t.Helper()
type entry struct {
id, project, agent, started string
msgs, outputTokens int
hasTokens bool
}
entries := []entry{
{"tok-01", "alpha", "claude", "2024-06-01T09:00:00Z", 12, 9000, true},
{"tok-02", "alpha", "codex", "2024-06-01T10:00:00Z", 11, 7000, true},
{"tok-03", "beta", "claude", "2024-06-01T11:00:00Z", 10, 6000, true},
{"tok-04", "beta", "codex", "2024-06-01T12:00:00Z", 9, 5000, true},
{"tok-05", "gamma", "claude", "2024-06-02T09:00:00Z", 8, 4000, true},
{"tok-06", "gamma", "codex", "2024-06-02T10:00:00Z", 7, 3000, true},
{"tok-07", "delta", "claude", "2024-06-02T11:00:00Z", 6, 2000, true},
{"tok-08", "delta", "codex", "2024-06-02T12:00:00Z", 5, 1500, true},
{"tok-09", "epsilon", "claude", "2024-06-03T09:00:00Z", 4, 1200, true},
{"tok-10", "epsilon", "codex", "2024-06-03T10:00:00Z", 3, 1100, true},
{"tok-11", "zeta", "claude", "2024-06-03T11:00:00Z", 2, 1000, true},
{"tok-12", "zeta", "codex", "2024-06-03T12:00:00Z", 2, 200, true},
{"tok-missing", "omega", "claude", "2024-06-03T13:00:00Z", 40, 0, false},
}
var stats seedStats
writes := make([]db.SessionBatchWrite, 0, len(entries))
for _, s := range entries {
stats.TotalSessions++
stats.TotalMessages += s.msgs
started := s.started
writes = append(writes, db.SessionBatchWrite{
Session: db.Session{
ID: s.id,
Project: s.project,
Machine: "test",
Agent: s.agent,
MessageCount: s.msgs,
UserMessageCount: max(s.msgs, 2),
StartedAt: &started,
EndedAt: &started,
FirstMessage: new("Token seeded"),
TotalOutputTokens: s.outputTokens,
HasTotalOutputTokens: s.hasTokens,
},
Messages: buildTestMessages(s.id, s.msgs),
})
if s.hasTokens {
stats.TotalOutputTokens += s.outputTokens
stats.TokenReportingSessions++
if s.outputTokens > stats.TopSessionOutputTokens {
stats.TopSessionOutputTokens = s.outputTokens
}
}
}
result, err := te.db.WriteSessionBatchAtomic(writes)
require.NoError(t, err)
require.Equal(t, stats.TotalSessions, result.WrittenSessions)
require.Equal(t, stats.TotalMessages, result.WrittenMessages)
return stats
}
type analyticsDBFixture struct {
files map[string][]byte
stats seedStats
}
var (
analyticsFixtureOnce sync.Once
analyticsFixture analyticsDBFixture
analyticsFixtureErr error
analyticsTokenFixtureOnce sync.Once
analyticsTokenFixture analyticsDBFixture
analyticsTokenFixtureErr error
)
func setupAnalyticsEnv(t *testing.T) (*testEnv, seedStats) {
t.Helper()
fixture := analyticsFixtureFor(t,
&analyticsFixtureOnce,
&analyticsFixture,
&analyticsFixtureErr,
"analytics",
seedAnalyticsEnv,
)
return setupWithDBTemplate(t, fixture.files), fixture.stats
}
func setupAnalyticsTokenEnv(t *testing.T) (*testEnv, seedStats) {
t.Helper()
fixture := analyticsFixtureFor(t,
&analyticsTokenFixtureOnce,
&analyticsTokenFixture,
&analyticsTokenFixtureErr,
"analytics-token",
seedAnalyticsTokenEnv,
)
return setupWithDBTemplate(t, fixture.files), fixture.stats
}
func analyticsFixtureFor(
t *testing.T,
once *sync.Once,
fixture *analyticsDBFixture,
fixtureErr *error,
name string,
seed func(*testing.T, *testEnv) seedStats,
) analyticsDBFixture {
t.Helper()
once.Do(func() {
*fixture, *fixtureErr = buildAnalyticsDBFixture(t, name, seed)
})
require.NoError(t, *fixtureErr)
return *fixture
}
func buildAnalyticsDBFixture(
t *testing.T,
name string,
seed func(*testing.T, *testEnv) seedStats,
) (analyticsDBFixture, error) {
t.Helper()
dir, err := os.MkdirTemp("", "agentsview-server-"+name+"-*")
if err != nil {
return analyticsDBFixture{}, fmt.Errorf(
"creating analytics fixture dir: %w", err,
)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "test.db")
dbtest.EnsureTestDBAt(t, path)
database, err := db.Open(path)
if err != nil {
return analyticsDBFixture{}, fmt.Errorf(
"opening analytics fixture db: %w", err,
)
}
stats := seed(t, &testEnv{db: database})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
checkpointErr := database.CheckpointWALTruncate(ctx)
closeErr := database.Close()
if checkpointErr != nil {
return analyticsDBFixture{}, fmt.Errorf(
"checkpointing analytics fixture db: %w", checkpointErr,
)
}
if closeErr != nil {
return analyticsDBFixture{}, fmt.Errorf(
"closing analytics fixture db: %w", closeErr,
)
}
files, err := readClosedDBFiles(path)
if err != nil {
return analyticsDBFixture{}, err
}
return analyticsDBFixture{files: files, stats: stats}, nil
}
func readClosedDBFiles(path string) (map[string][]byte, error) {
files := make(map[string][]byte, 3)
for _, suffix := range []string{"", "-wal", "-shm"} {
data, err := os.ReadFile(path + suffix)
if err != nil {
if suffix != "" && errors.Is(err, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf(
"reading analytics fixture db %s: %w",
path+suffix, err,
)
}
files[suffix] = data
}
return files, nil
}
// buildPathURL constructs an API URL for a given full path and parameters.
func buildPathURL(fullPath string, params map[string]string) string {
u, _ := url.Parse(fullPath)
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
u.RawQuery = q.Encode()
return u.String()
}
// buildURL constructs an analytics API URL.
func buildURL(path string, params map[string]string) string {
return buildPathURL(basePath+path, params)
}
// buildURLWithRange constructs an analytics API URL with default from/to params.
func buildURLWithRange(path string, params map[string]string) string {
if params == nil {
params = make(map[string]string)
}
if _, ok := params["from"]; !ok {
params["from"] = "2024-06-01"
}
if _, ok := params["to"]; !ok {
params["to"] = "2024-06-03"
}
return buildURL(path, params)
}
func TestAnalyticsSummary(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("summary", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.AnalyticsSummary](t, w)
assert.Equal(t, stats.TotalSessions, resp.TotalSessions)
assert.Equal(t, stats.TotalMessages, resp.TotalMessages)
assert.Equal(t, stats.ActiveProjects, resp.ActiveProjects)
assert.Equal(t, stats.ActiveDays, resp.ActiveDays)
assert.Equal(t,
[]string{"claude-3-5-sonnet", "gpt-4o"},
resp.Models,
)
})
t.Run("ModelFilter", func(t *testing.T) {
w := te.get(t, buildURLWithRange("summary", map[string]string{
"timezone": "UTC",
"model": "gpt-4o",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.AnalyticsSummary](t, w)
assert.Equal(t, 1, resp.TotalSessions)
assert.Equal(t, 20, resp.TotalMessages)
assert.Equal(t, []string{"gpt-4o"}, resp.Models)
})
t.Run("NonUTCTimezone", func(t *testing.T) {
w := te.get(t, buildURLWithRange("summary", map[string]string{"timezone": "America/New_York"}))
assertStatus(t, w, http.StatusOK)
})
t.Run("InvalidTimezone", func(t *testing.T) {
w := te.get(t, buildURL("summary", map[string]string{"timezone": "Fake/Zone"}))
assertStatus(t, w, http.StatusBadRequest)
})
}
func TestAnalyticsSummary_OutputTokenCoverage(t *testing.T) {
te, stats := setupAnalyticsTokenEnv(t)
w := te.get(t, buildURLWithRange("summary", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.AnalyticsSummary](t, w)
assert.Equal(t, stats.TotalOutputTokens, resp.TotalOutputTokens)
assert.Equal(t, stats.TokenReportingSessions, resp.TokenReportingSessions)
}
func TestAnalyticsSummary_DateValidation(t *testing.T) {
te := setup(t)
tests := []struct {
name string
params map[string]string
status int
}{
{
"InvalidFromFormat",
map[string]string{"from": "not-a-date", "to": "2024-06-03"},
http.StatusBadRequest,
},
{
"InvalidToFormat",
map[string]string{"from": "2024-06-01", "to": "06-03-2024"},
http.StatusBadRequest,
},
{
"FromAfterTo",
map[string]string{"from": "2024-07-01", "to": "2024-06-01"},
http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.get(t, buildURL("summary", tt.params))
assertStatus(t, w, tt.status)
})
}
}
func TestAnalyticsErrorRedaction(t *testing.T) {
te, _ := setupAnalyticsEnv(t)
// Valid request should succeed
w := te.get(t, buildURLWithRange("summary", nil))
assertStatus(t, w, http.StatusOK)
// Force a DB error by closing the database
te.db.Close()
endpoints := []string{
"summary",
"activity",
"heatmap",
"projects",
"hour-of-week",
"sessions",
"velocity",
"tools",
"top-sessions",
"signals",
}
for _, ep := range endpoints {
t.Run(ep, func(t *testing.T) {
w := te.get(t, buildURLWithRange(ep, nil))
assertStatus(t, w, http.StatusInternalServerError)
body := w.Body.String()
assert.NotContains(t, body, "sql", "response exposes internal error")
assert.NotContains(t, body, "database", "response exposes internal error")
})
}
}
func TestAnalyticsSignalSessionsRejectsUnsupportedSignal(t *testing.T) {
te, _ := setupAnalyticsEnv(t)
w := te.get(t, buildURLWithRange("signal-sessions",
map[string]string{"signal": "not_a_signal"}))
assertStatus(t, w, http.StatusBadRequest)
}
func TestAnalyticsEndpoints_DefaultParams(t *testing.T) {
te, _ := setupAnalyticsEnv(t)
endpoints := []string{
"summary",
"activity",
"heatmap",
"projects",
"hour-of-week",
"sessions",
"velocity",
"tools",
"top-sessions",
"signals",
}
for _, ep := range endpoints {
t.Run(ep, func(t *testing.T) {
w := te.get(t, buildURL(ep, nil))
assertStatus(t, w, http.StatusOK)
})
}
}
func TestSessionsDateValidation(t *testing.T) {
te := setup(t)
tests := []struct {
name string
params map[string]string
status int
}{
{
"InvalidDateFormat",
map[string]string{"date": "not-a-date"},
http.StatusBadRequest,
},
{
"InvalidDateFromFormat",
map[string]string{"date_from": "2024/06/01"},
http.StatusBadRequest,
},
{
"DateFromAfterDateTo",
map[string]string{"date_from": "2024-07-01", "date_to": "2024-06-01"},
http.StatusBadRequest,
},
{
"ValidDateRange",
map[string]string{"date_from": "2024-06-01", "date_to": "2024-06-03"},
http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.get(t, buildPathURL("/api/v1/sessions", tt.params))
assertStatus(t, w, tt.status)
})
}
}
func TestActiveSinceValidation(t *testing.T) {
te := setup(t)
tests := []struct {
name string
path string
params map[string]string
status int
}{
{
"Sessions_InvalidActiveSince",
"/api/v1/sessions",
map[string]string{"active_since": "garbage"},
http.StatusBadRequest,
},
{
"Sessions_ValidActiveSince",
"/api/v1/sessions",
map[string]string{"active_since": "2024-06-01T10:00:00Z"},
http.StatusOK,
},
{
"Sessions_ValidActiveSinceNano",
"/api/v1/sessions",
map[string]string{"active_since": "2024-06-01T10:00:00.123456789Z"},
http.StatusOK,
},
{
"Analytics_InvalidActiveSince",
basePath + "summary",
map[string]string{"from": "2024-06-01", "to": "2024-06-03", "active_since": "not-a-timestamp"},
http.StatusBadRequest,
},
{
"Analytics_ValidActiveSince",
basePath + "summary",
map[string]string{"from": "2024-06-01", "to": "2024-06-03", "active_since": "2024-06-01T00:00:00Z"},
http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.get(t, buildPathURL(tt.path, tt.params))
assertStatus(t, w, tt.status)
})
}
}
// TestDateFilterValidation pins that the content-search and secrets-list
// endpoints reject malformed date filters with 400 instead of forwarding them
// to the DB, where a date/timestamptz cast failure would surface as a 500.
func TestDateFilterValidation(t *testing.T) {
te := setup(t)
tests := []struct {
name string
path string
params map[string]string
status int
}{
{
"Search_InvalidDate", "/api/v1/search/content",
map[string]string{"pattern": "x", "date": "not-a-date"},
http.StatusBadRequest,
},
{
"Search_InvalidDateFrom", "/api/v1/search/content",
map[string]string{"pattern": "x", "date_from": "2024-13-40"},
http.StatusBadRequest,
},
{
"Search_DateFromAfterDateTo", "/api/v1/search/content",
map[string]string{
"pattern": "x", "date_from": "2024-06-03", "date_to": "2024-06-01",
},
http.StatusBadRequest,
},
{
"Search_InvalidActiveSince", "/api/v1/search/content",
map[string]string{"pattern": "x", "active_since": "garbage"},
http.StatusBadRequest,
},
{
"Search_ValidDates", "/api/v1/search/content",
map[string]string{
"pattern": "x", "date_from": "2024-06-01", "date_to": "2024-06-03",
},
http.StatusOK,
},
{
"Secrets_InvalidDateFrom", "/api/v1/secrets",
map[string]string{"date_from": "bad-date"},
http.StatusBadRequest,
},
{
"Secrets_ValidDates", "/api/v1/secrets",
map[string]string{"date_from": "2024-06-01", "date_to": "2024-06-03"},
http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := te.get(t, buildPathURL(tt.path, tt.params))
assertStatus(t, w, tt.status)
})
}
}
func TestAnalyticsActivity(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
tests := []struct {
name string
granularity string
wantStatus int
}{
{"DayGranularity", "day", http.StatusOK},
{"WeekGranularity", "week", http.StatusOK},
{"DefaultGranularity", "", http.StatusOK},
{"InvalidGranularity", "hour", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := make(map[string]string)
if tt.granularity != "" {
params["granularity"] = tt.granularity
}
w := te.get(t, buildURLWithRange("activity", params))
assertStatus(t, w, tt.wantStatus)
if tt.wantStatus == http.StatusOK {
resp := decode[db.ActivityResponse](t, w)
expectedGran := tt.granularity
if expectedGran == "" {
expectedGran = "day" // default
}
assert.Equal(t, expectedGran, resp.Granularity)
if expectedGran == "day" {
require.Len(t, resp.Series, stats.ActiveDays)
totalUser := 0
totalAsst := 0
for _, e := range resp.Series {
totalUser += e.UserMessages
totalAsst += e.AssistantMessages
}
assert.Equal(t, stats.TotalMessages, totalUser+totalAsst)
}
}
})
}
}
func TestAnalyticsHeatmap(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
tests := []struct {
name string
metric string
wantStatus int
wantEntries int
}{
{"MessageMetric", "messages", http.StatusOK, 3},
{"SessionMetric", "sessions", http.StatusOK, 3},
{"DefaultMetric", "", http.StatusOK, 3},
{"InvalidMetric", "bytes", http.StatusBadRequest, -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := make(map[string]string)
if tt.metric != "" {
params["metric"] = tt.metric
}
w := te.get(t, buildURLWithRange("heatmap", params))
assertStatus(t, w, tt.wantStatus)
if tt.wantStatus == http.StatusOK {
resp := decode[db.HeatmapResponse](t, w)
expectedMetric := tt.metric
if expectedMetric == "" {
expectedMetric = "messages" // default
}
assert.Equal(t, expectedMetric, resp.Metric)
if tt.wantEntries >= 0 {
assert.Len(t, resp.Entries, tt.wantEntries)
}
if tt.wantEntries > 0 {
total := 0
for _, e := range resp.Entries {
total += e.Value
}
switch expectedMetric {
case "messages":
assert.Equal(t, stats.TotalMessages, total)
case "sessions":
assert.Equal(t, stats.TotalSessions, total)
}
}
}
})
}
t.Run("ClampedRange", func(t *testing.T) {
// Request a range >366 days; entries should be clamped
params := map[string]string{
"from": "2022-01-01",
"to": "2024-06-03",
}
w := te.get(t, buildURL("heatmap", params))
assertStatus(t, w, http.StatusOK)
resp := decode[db.HeatmapResponse](t, w)
assert.LessOrEqual(t, len(resp.Entries), db.MaxHeatmapDays)
require.NotEmpty(t, resp.EntriesFrom)
assert.Greater(t, resp.EntriesFrom, "2022-01-01",
"EntriesFrom should be later than 2022-01-01")
})
t.Run("ShortRange_NoClamping", func(t *testing.T) {
// A 3-day range should not be clamped
w := te.get(t, buildURLWithRange("heatmap", nil))
assertStatus(t, w, http.StatusOK)
resp := decode[db.HeatmapResponse](t, w)
assert.Equal(t, "2024-06-01", resp.EntriesFrom)
})
t.Run("Levels_FromClampedWindow", func(t *testing.T) {
// Seed a historical outlier far outside the clamped
// window. Levels should be based only on displayed data.
oldDate := "2020-01-15T10:00:00Z"
te.seedSession(t, "old-outlier", "gamma", 500,
func(sess *db.Session) {
sess.Agent = "claude"
sess.StartedAt = &oldDate
sess.EndedAt = &oldDate
},
)
te.seedMessages(t, "old-outlier", 500)
// Request range covering both old and recent data
params := map[string]string{
"from": "2019-01-01",
"to": "2024-06-03",
}
w := te.get(t, buildURL("heatmap", params))
assertStatus(t, w, http.StatusOK)
resp := decode[db.HeatmapResponse](t, w)
// The outlier at 2020-01-15 should be clamped out.
// Verify no entry has the outlier date.
for _, e := range resp.Entries {
assert.NotEqual(t, "2020-01-15", e.Date,
"outlier date should be outside clamped window")
}
// Levels should reflect the recent data (max ~30 msgs),
// not the 500-message outlier.
assert.Less(t, resp.Levels.L4, 500,
"L4 should be << 500 (outlier leaked into levels)")
})
}
func TestAnalyticsHeatmap_OutputTokens(t *testing.T) {
te, stats := setupAnalyticsTokenEnv(t)
w := te.get(t, buildURLWithRange("heatmap", map[string]string{
"timezone": "UTC",
"metric": "output_tokens",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.HeatmapResponse](t, w)
require.Equal(t, "output_tokens", resp.Metric)
total := 0
for _, e := range resp.Entries {
total += e.Value
}
assert.Equal(t, stats.TotalOutputTokens, total)
}
func TestAnalyticsHeatmap_OutputTokensNoReporting(
t *testing.T,
) {
te, _ := setupAnalyticsEnv(t)
w := te.get(t, buildURLWithRange("heatmap", map[string]string{
"timezone": "UTC",
"metric": "output_tokens",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.HeatmapResponse](t, w)
require.Equal(t, "output_tokens", resp.Metric)
assert.Empty(t, resp.Entries,
"no sessions report token coverage")
}
func TestAnalyticsProjects(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("projects", nil))
assertStatus(t, w, http.StatusOK)
resp := decode[db.ProjectsAnalyticsResponse](t, w)
require.Len(t, resp.Projects, stats.ActiveProjects)
total := 0
for _, p := range resp.Projects {
total += p.Messages
}
assert.Equal(t, stats.TotalMessages, total)
})
t.Run("MachineFilter", func(t *testing.T) {
w := te.get(t, buildURLWithRange("projects", map[string]string{"machine": "nonexistent"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.ProjectsAnalyticsResponse](t, w)
assert.Empty(t, resp.Projects)
})
}
func TestAnalyticsHourOfWeek(t *testing.T) {
te, _ := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("hour-of-week", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.HourOfWeekResponse](t, w)
assert.Len(t, resp.Cells, 168)
})
}
func TestAnalyticsSessionShape(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("sessions", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.SessionShapeResponse](t, w)
assert.Equal(t, stats.TotalSessions, resp.Count)
})
}
func TestAnalyticsVelocity(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("velocity", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.VelocityResponse](t, w)
assert.Len(t, resp.ByAgent, stats.Agents)
})
}
func TestAnalyticsTools(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("tools", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.ToolsAnalyticsResponse](t, w)
assert.Equal(t, stats.TotalToolCalls, resp.TotalCalls)
assert.NotEmpty(t, resp.ByCategory)
require.NotEmpty(t, resp.ByTool)
assert.NotEmpty(t, resp.ByTool[0].ToolName)
assert.NotZero(t, resp.ByTool[0].CallCount)
assert.NotZero(t, resp.ByTool[0].SessionCount)
assert.Len(t, resp.ByAgent, stats.Agents)
})
t.Run("WithProjectFilter", func(t *testing.T) {
w := te.get(t, buildURLWithRange("tools", map[string]string{"project": "alpha", "timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.ToolsAnalyticsResponse](t, w)
assert.NotZero(t, resp.TotalCalls, "TotalCalls for alpha")
})
t.Run("InvalidTimezone", func(t *testing.T) {
w := te.get(t, buildURL("tools", map[string]string{"timezone": "Fake/Zone"}))
assertStatus(t, w, http.StatusBadRequest)
})
}
func TestAnalyticsSkills(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
t.Run("OK", func(t *testing.T) {
w := te.get(t, buildURLWithRange("skills", map[string]string{"timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.SkillsAnalyticsResponse](t, w)
assert.Equal(t, stats.TotalSkillCalls, resp.TotalSkillCalls)
assert.Equal(t, 1, resp.DistinctSkills)
require.NotEmpty(t, resp.BySkill)
assert.Equal(t, "review-code", resp.BySkill[0].SkillName)
})
t.Run("WithProjectFilter", func(t *testing.T) {
w := te.get(t, buildURLWithRange("skills", map[string]string{"project": "alpha", "timezone": "UTC"}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.SkillsAnalyticsResponse](t, w)
assert.NotZero(t, resp.TotalSkillCalls, "TotalSkillCalls for alpha")
})
t.Run("InvalidTimezone", func(t *testing.T) {
w := te.get(t, buildURL("skills", map[string]string{"timezone": "Fake/Zone"}))
assertStatus(t, w, http.StatusBadRequest)
})
}
func TestAnalyticsTopSessions(t *testing.T) {
te, stats := setupAnalyticsEnv(t)
tests := []struct {
name string
metric string
project string
wantStatus int
}{
{"ByMessages", "messages", "", http.StatusOK},
{"ByDuration", "duration", "", http.StatusOK},
{"DefaultMetric", "", "", http.StatusOK},
{"InvalidMetric", "bytes", "", http.StatusBadRequest},
{"WithProjectFilter", "", "alpha", http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := make(map[string]string)
if tt.metric != "" {
params["metric"] = tt.metric
}
if tt.project != "" {
params["project"] = tt.project
}
if tt.wantStatus == http.StatusOK {
params["timezone"] = "UTC"
}
w := te.get(t, buildURLWithRange("top-sessions", params))
assertStatus(t, w, tt.wantStatus)
if tt.wantStatus == http.StatusOK {
resp := decode[db.TopSessionsResponse](t, w)
expectedMetric := tt.metric
if expectedMetric == "" {
expectedMetric = "messages"
}
assert.Equal(t, expectedMetric, resp.Metric)
if tt.project == "" {
expected := min(stats.TotalSessions, 10)
assert.Len(t, resp.Sessions, expected)
}
if tt.project != "" {
assert.NotEmpty(t, resp.Sessions, "project %q", tt.project)
for _, s := range resp.Sessions {
assert.Equal(t, tt.project, s.Project)
}
}
}
})
}
}
func TestAnalyticsTopSessions_OutputTokens(t *testing.T) {
te, stats := setupAnalyticsTokenEnv(t)
w := te.get(t, buildURLWithRange("top-sessions", map[string]string{
"timezone": "UTC",
"metric": "output_tokens",
}))
assertStatus(t, w, http.StatusOK)
resp := decode[db.TopSessionsResponse](t, w)
require.Equal(t, "output_tokens", resp.Metric)
require.NotEmpty(t, resp.Sessions)
assert.Equal(t, stats.TopSessionOutputTokens, resp.Sessions[0].OutputTokens)
}
// TestSessionCountConsistency verifies that session counts from
// /api/v1/sessions, /api/v1/stats, and /api/v1/analytics/summary
// all agree. This catches regressions where one endpoint counts
// sub-agent, fork, or empty sessions that others exclude.
func TestSessionCountConsistency(t *testing.T) {
te := setup(t)
// Seed root sessions with messages (should be counted).
for i := range 5 {
id := fmt.Sprintf("root-%d", i)
te.seedSession(t, id, "proj-a", 10,
func(s *db.Session) {
s.Agent = "claude"
s.StartedAt = new(
"2024-06-01T09:00:00Z")
s.EndedAt = new(
"2024-06-01T10:00:00Z")
},
)
te.seedMessages(t, id, 10)
}
// Seed sub-agent sessions. These are excluded from the session
// list and db-wide stats (navigation surfaces) but counted in the
// analytics summary (a token/session aggregate), so the two counts
// intentionally differ.
for i := range 3 {
id := fmt.Sprintf("subagent-%d", i)
te.seedSession(t, id, "proj-a", 8,
func(s *db.Session) {
s.Agent = "claude"
s.ParentSessionID = new("root-0")
s.RelationshipType = "subagent"
s.StartedAt = new(
"2024-06-01T09:00:00Z")
s.EndedAt = new(
"2024-06-01T10:00:00Z")
},
)
te.seedMessages(t, id, 8)
}
// Seed fork sessions (should NOT be counted).
for i := range 2 {
id := fmt.Sprintf("fork-%d", i)
te.seedSession(t, id, "proj-a", 6,
func(s *db.Session) {
s.Agent = "claude"
s.ParentSessionID = new("root-1")
s.RelationshipType = "fork"
s.StartedAt = new(
"2024-06-01T09:00:00Z")
s.EndedAt = new(
"2024-06-01T10:00:00Z")
},
)
te.seedMessages(t, id, 6)
}
// Seed empty sessions (should NOT be counted).
for i := range 4 {
id := fmt.Sprintf("empty-%d", i)
te.seedSession(t, id, "proj-a", 0,
func(s *db.Session) {
s.Agent = "claude"
s.StartedAt = new(
"2024-06-01T09:00:00Z")
s.EndedAt = new(
"2024-06-01T10:00:00Z")
},
)
}
// Seed continuation sessions (SHOULD be counted).
te.seedSession(t, "cont-0", "proj-a", 5,
func(s *db.Session) {
s.Agent = "claude"
s.ParentSessionID = new("root-2")
s.RelationshipType = "continuation"
s.StartedAt = new(
"2024-06-01T09:00:00Z")
s.EndedAt = new(
"2024-06-01T10:00:00Z")
},
)
te.seedMessages(t, "cont-0", 5)
// Navigation surfaces (list, stats) exclude subagents: 5 root + 1
// continuation. The analytics summary additionally counts the 3
// subagents, since their messages and tokens are real spend.
wantNavCount := 6
wantAnalyticsCount := 9
// 1. Session list
w := te.get(t, "/api/v1/sessions")
assertStatus(t, w, http.StatusOK)
listResp := decode[sessionListResponse](t, w)
// 2. Stats
w = te.get(t, "/api/v1/stats")
assertStatus(t, w, http.StatusOK)
statsResp := decode[db.Stats](t, w)
// 3. Analytics summary
w = te.get(t, buildURLWithRange("summary", map[string]string{
"timezone": "UTC",
}))
assertStatus(t, w, http.StatusOK)
summaryResp := decode[db.AnalyticsSummary](t, w)
assert.Equal(t, wantNavCount, listResp.Total, "session list total")
assert.Equal(t, wantNavCount, statsResp.SessionCount, "stats session_count")
assert.Equal(t, wantAnalyticsCount, summaryResp.TotalSessions,
"analytics total_sessions counts subagents")
// List and stats (navigation) agree; analytics counts subagents on
// top, so it is intentionally higher.
require.Equal(t, listResp.Total, statsResp.SessionCount,
"navigation session counts disagree: list=%d stats=%d",
listResp.Total, statsResp.SessionCount)
require.Greater(t, summaryResp.TotalSessions, listResp.Total,
"analytics should count more than navigation: analytics=%d list=%d",
summaryResp.TotalSessions, listResp.Total)
}