Files
ccs/tests/unit/data-aggregator.test.ts
T

313 lines
10 KiB
TypeScript

/**
* Unit tests for Data Aggregator
*/
import { describe, expect, test } from 'bun:test';
import {
aggregateDailyUsage,
aggregateMonthlyUsage,
aggregateSessionUsage,
} from '../../src/web-server/data-aggregator';
import { type RawUsageEntry } from '../../src/web-server/jsonl-parser';
// ============================================================================
// TEST FIXTURES
// ============================================================================
const createEntry = (overrides: Partial<RawUsageEntry> = {}): RawUsageEntry => ({
inputTokens: 1000,
outputTokens: 500,
cacheCreationTokens: 100,
cacheReadTokens: 50,
model: 'claude-sonnet-4-5',
sessionId: 'session-123',
timestamp: '2025-12-09T10:00:00.000Z',
projectPath: '/home/user/project',
version: '2.0.60',
...overrides,
});
// ============================================================================
// aggregateDailyUsage Tests
// ============================================================================
describe('aggregateDailyUsage', () => {
test('aggregates entries by date', () => {
const entries: RawUsageEntry[] = [
createEntry({ timestamp: '2025-12-09T10:00:00.000Z', inputTokens: 1000 }),
createEntry({ timestamp: '2025-12-09T14:00:00.000Z', inputTokens: 2000 }),
createEntry({ timestamp: '2025-12-08T10:00:00.000Z', inputTokens: 500 }),
];
const result = aggregateDailyUsage(entries);
expect(result.length).toBe(2);
// Most recent first
expect(result[0].date).toBe('2025-12-09');
expect(result[0].inputTokens).toBe(3000); // 1000 + 2000
expect(result[1].date).toBe('2025-12-08');
expect(result[1].inputTokens).toBe(500);
});
test('groups by model within each day', () => {
const entries: RawUsageEntry[] = [
createEntry({ model: 'claude-sonnet-4-5', inputTokens: 1000 }),
createEntry({ model: 'claude-opus-4-5-20251101', inputTokens: 2000 }),
createEntry({ model: 'claude-sonnet-4-5', inputTokens: 500 }),
];
const result = aggregateDailyUsage(entries);
expect(result.length).toBe(1);
expect(result[0].modelBreakdowns.length).toBe(2);
expect(result[0].modelsUsed).toContain('claude-sonnet-4-5');
expect(result[0].modelsUsed).toContain('claude-opus-4-5-20251101');
// Find sonnet breakdown
const sonnet = result[0].modelBreakdowns.find((b) => b.modelName === 'claude-sonnet-4-5');
expect(sonnet!.inputTokens).toBe(1500); // 1000 + 500
});
test('calculates costs correctly', () => {
const entries: RawUsageEntry[] = [
createEntry({
model: 'claude-sonnet-4-5',
inputTokens: 1_000_000, // $3.00
outputTokens: 1_000_000, // $15.00
cacheCreationTokens: 0,
cacheReadTokens: 0,
}),
];
const result = aggregateDailyUsage(entries);
expect(result[0].totalCost).toBeCloseTo(18.0, 2);
expect(result[0].modelBreakdowns[0].cost).toBeCloseTo(18.0, 2);
});
test('returns empty array for no entries', () => {
const result = aggregateDailyUsage([]);
expect(result.length).toBe(0);
});
test('sorts model breakdowns by cost descending', () => {
const entries: RawUsageEntry[] = [
createEntry({ model: 'claude-haiku-4-5-20251001', inputTokens: 1000 }), // cheap
createEntry({ model: 'claude-opus-4-5-20251101', inputTokens: 1000 }), // expensive
];
const result = aggregateDailyUsage(entries);
// Opus should be first (higher cost)
expect(result[0].modelBreakdowns[0].modelName).toBe('claude-opus-4-5-20251101');
});
test('sets source field', () => {
const entries: RawUsageEntry[] = [createEntry()];
const result = aggregateDailyUsage(entries, 'test-source');
expect(result[0].source).toBe('test-source');
});
});
// ============================================================================
// aggregateMonthlyUsage Tests
// ============================================================================
describe('aggregateMonthlyUsage', () => {
test('aggregates entries by month', () => {
const entries: RawUsageEntry[] = [
createEntry({ timestamp: '2025-12-09T10:00:00.000Z', inputTokens: 1000 }),
createEntry({ timestamp: '2025-12-15T10:00:00.000Z', inputTokens: 2000 }),
createEntry({ timestamp: '2025-11-20T10:00:00.000Z', inputTokens: 500 }),
];
const result = aggregateMonthlyUsage(entries);
expect(result.length).toBe(2);
// Most recent first
expect(result[0].month).toBe('2025-12');
expect(result[0].inputTokens).toBe(3000); // 1000 + 2000
expect(result[1].month).toBe('2025-11');
expect(result[1].inputTokens).toBe(500);
});
test('groups by model within each month', () => {
const entries: RawUsageEntry[] = [
createEntry({ model: 'claude-sonnet-4-5', inputTokens: 1000 }),
createEntry({ model: 'gemini-2.5-pro', inputTokens: 2000 }),
];
const result = aggregateMonthlyUsage(entries);
expect(result[0].modelBreakdowns.length).toBe(2);
expect(result[0].modelsUsed).toContain('claude-sonnet-4-5');
expect(result[0].modelsUsed).toContain('gemini-2.5-pro');
});
test('returns empty array for no entries', () => {
const result = aggregateMonthlyUsage([]);
expect(result.length).toBe(0);
});
});
// ============================================================================
// aggregateSessionUsage Tests
// ============================================================================
describe('aggregateSessionUsage', () => {
test('aggregates entries by sessionId', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', inputTokens: 1000 }),
createEntry({ sessionId: 'session-A', inputTokens: 2000 }),
createEntry({ sessionId: 'session-B', inputTokens: 500 }),
];
const result = aggregateSessionUsage(entries);
expect(result.length).toBe(2);
const sessionA = result.find((s) => s.sessionId === 'session-A');
expect(sessionA!.inputTokens).toBe(3000);
const sessionB = result.find((s) => s.sessionId === 'session-B');
expect(sessionB!.inputTokens).toBe(500);
});
test('tracks last activity timestamp', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', timestamp: '2025-12-09T10:00:00.000Z' }),
createEntry({ sessionId: 'session-A', timestamp: '2025-12-09T14:00:00.000Z' }),
createEntry({ sessionId: 'session-A', timestamp: '2025-12-09T12:00:00.000Z' }),
];
const result = aggregateSessionUsage(entries);
expect(result[0].lastActivity).toBe('2025-12-09T14:00:00.000Z');
});
test('collects unique versions', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', version: '2.0.59' }),
createEntry({ sessionId: 'session-A', version: '2.0.60' }),
createEntry({ sessionId: 'session-A', version: '2.0.60' }), // duplicate
];
const result = aggregateSessionUsage(entries);
expect(result[0].versions.length).toBe(2);
expect(result[0].versions).toContain('2.0.59');
expect(result[0].versions).toContain('2.0.60');
});
test('includes project path', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', projectPath: '/home/user/my-project' }),
];
const result = aggregateSessionUsage(entries);
expect(result[0].projectPath).toBe('/home/user/my-project');
});
test('skips entries without sessionId', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: '', inputTokens: 1000 }),
createEntry({ sessionId: 'valid-session', inputTokens: 500 }),
];
const result = aggregateSessionUsage(entries);
expect(result.length).toBe(1);
expect(result[0].sessionId).toBe('valid-session');
});
test('sorts by last activity descending', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'old-session', timestamp: '2025-12-01T10:00:00.000Z' }),
createEntry({ sessionId: 'new-session', timestamp: '2025-12-09T10:00:00.000Z' }),
];
const result = aggregateSessionUsage(entries);
expect(result[0].sessionId).toBe('new-session');
expect(result[1].sessionId).toBe('old-session');
});
test('propagates target metadata into session usage', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', target: 'droid' }),
createEntry({ sessionId: 'session-B' }),
];
const result = aggregateSessionUsage(entries);
const sessionA = result.find((s) => s.sessionId === 'session-A');
const sessionB = result.find((s) => s.sessionId === 'session-B');
expect(sessionA?.target).toBe('droid');
expect(sessionB?.target).toBeUndefined();
});
test('uses latest timestamp target when a session has mixed targets', () => {
const entries: RawUsageEntry[] = [
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T10:00:00.000Z',
target: 'claude',
}),
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T12:00:00.000Z',
target: 'droid',
}),
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T11:00:00.000Z',
}),
];
const result = aggregateSessionUsage(entries);
expect(result[0].target).toBe('droid');
});
test('returns empty array for no entries', () => {
const result = aggregateSessionUsage([]);
expect(result.length).toBe(0);
});
});
// ============================================================================
// Integration: All token types
// ============================================================================
describe('token aggregation completeness', () => {
test('aggregates all token types correctly', () => {
const entries: RawUsageEntry[] = [
createEntry({
inputTokens: 100,
outputTokens: 200,
cacheCreationTokens: 50,
cacheReadTokens: 25,
}),
createEntry({
inputTokens: 150,
outputTokens: 100,
cacheCreationTokens: 30,
cacheReadTokens: 10,
}),
];
const daily = aggregateDailyUsage(entries);
expect(daily[0].inputTokens).toBe(250);
expect(daily[0].outputTokens).toBe(300);
expect(daily[0].cacheCreationTokens).toBe(80);
expect(daily[0].cacheReadTokens).toBe(35);
// Model breakdown should also have correct totals
expect(daily[0].modelBreakdowns[0].inputTokens).toBe(250);
expect(daily[0].modelBreakdowns[0].outputTokens).toBe(300);
expect(daily[0].modelBreakdowns[0].cacheCreationTokens).toBe(80);
expect(daily[0].modelBreakdowns[0].cacheReadTokens).toBe(35);
});
});