Files
ccs/tests/unit/web-server/usage/account-attribution.test.ts
T
Tam Nhu Tran cc7ac553e3 fix(usage): correct per-account cost attribution
Drop the detail.source fallback and dead numeric-key lookup in the usage
transformer so unmapped auth_index rows go to the 'unknown' bucket instead of
mis-keying cost; null out today_cost when an email cost-key is shared by more
than one account (duplicate-email providers) instead of double-displaying the
combined spend.
2026-06-07 16:37:59 -04:00

406 lines
16 KiB
TypeScript

/**
* Phase 1A: Account Attribution Tests
*
* TDD tests for auth_index → accountId mapping through the usage pipeline.
* Covers:
* - auth_index maps to account email via accountMap in transformer
* - extractCliproxyUsageHistoryDetails carries accountId
* - getTodayCostByAccount returns correct per-account totals
* - Profile-based aggregation unaffected (backward compat)
*/
import { describe, expect, it, beforeEach, afterEach } from 'bun:test';
import type { CliproxyUsageApiResponse, CliproxyManagementAuthFile } from '../../../../src/cliproxy/services/stats-fetcher';
// ============================================================================
// HELPERS & FIXTURES
// ============================================================================
const TODAY = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
function makeResponse(entries: Array<{
provider: string;
model: string;
auth_index: number;
source: string;
timestamp: string;
input: number;
output: number;
failed?: boolean;
}>): CliproxyUsageApiResponse {
const apis: CliproxyUsageApiResponse['usage'] = { apis: {} };
for (const e of entries) {
if (!apis.apis![e.provider]) {
apis.apis![e.provider] = { models: {} };
}
const models = apis.apis![e.provider].models!;
if (!models[e.model]) {
models[e.model] = { details: [] };
}
models[e.model].details!.push({
timestamp: e.timestamp,
source: e.source,
auth_index: e.auth_index,
tokens: {
input_tokens: e.input,
output_tokens: e.output,
reasoning_tokens: 0,
cached_tokens: 0,
total_tokens: e.input + e.output,
},
failed: e.failed ?? false,
});
}
return { usage: apis };
}
const twoAccountResponse = makeResponse([
{
provider: 'anthropic',
model: 'claude-sonnet-4-5',
auth_index: 0,
source: 'old-source-a',
timestamp: `${TODAY}T10:00:00.000Z`,
input: 1000,
output: 500,
},
{
provider: 'anthropic',
model: 'claude-sonnet-4-5',
auth_index: 1,
source: 'old-source-b',
timestamp: `${TODAY}T11:00:00.000Z`,
input: 2000,
output: 800,
},
// auth_index 0 again — same account, second request.
// output=201 (not 200) ensures alice's two-request total ($0.018025) strictly
// exceeds bob's single-request total ($0.018), avoiding a floating-point tie.
{
provider: 'anthropic',
model: 'claude-opus-4-5',
auth_index: 0,
source: 'old-source-a',
timestamp: `${TODAY}T12:00:00.000Z`,
input: 500,
output: 201,
},
]);
// Fix #7/#13/#15: buildAuthIndexToAccountMap stores String(auth_index) keys only.
// The map must use string keys so accountMap.get(String(detail.auth_index)) resolves correctly.
const authFileMap: Map<number | string, string> = new Map([
['0', 'alice@example.com'],
['1', 'bob@example.com'],
]);
// ============================================================================
// TRANSFORMER: extractCliproxyUsageHistoryDetails with accountMap
// ============================================================================
describe('extractCliproxyUsageHistoryDetails with accountMap', () => {
it('populates accountId from accountMap when auth_index is present', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
const aliceDetails = details.filter((d) => d.accountId === 'alice@example.com');
const bobDetails = details.filter((d) => d.accountId === 'bob@example.com');
expect(aliceDetails).toHaveLength(2); // auth_index 0 appears twice
expect(bobDetails).toHaveLength(1); // auth_index 1 appears once
});
it('leaves accountId undefined when auth_index is not in accountMap (no source fallback)', async () => {
// Fix #7/#13/#15: detail.source is a CLIProxy source label, not an email.
// Using it as a cost key caused mis-attribution. When auth_index is absent from the
// map, accountId must be undefined so getTodayCostByAccount buckets under 'unknown'.
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
// Use string key matching buildAuthIndexToAccountMap's String(auth_index) output
const partialMap: Map<number | string, string> = new Map([['0', 'alice@example.com']]);
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, partialMap);
// auth_index 1 (bob) is not in the partial map — must be undefined, not 'old-source-b'
const bobDetail = details.find((d) => d.accountId === undefined && !d.accountId);
// There should be exactly one detail with no accountId (bob's request)
const unmappedDetails = details.filter((d) => d.accountId === undefined);
expect(unmappedDetails).toHaveLength(1);
// Confirm it is NOT keyed under the source string
const sourceFallback = details.find((d) => d.accountId === 'old-source-b');
expect(sourceFallback).toBeUndefined();
void bobDetail; // suppress lint
});
it('does not include accountId when no accountMap is provided (backward compat)', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
for (const detail of details) {
expect(detail.accountId).toBeUndefined();
}
});
it('does not expose source or auth_index on returned history details', async () => {
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
for (const detail of details) {
expect((detail as Record<string, unknown>).source).toBeUndefined();
expect((detail as Record<string, unknown>).auth_index).toBeUndefined();
}
});
});
// ============================================================================
// TRANSFORMER: CliproxyUsageHistoryDetail type has optional accountId
// ============================================================================
describe('CliproxyUsageHistoryDetail type', () => {
it('allows accountId as optional string field', async () => {
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const withAccount = normalizeCliproxyUsageHistoryDetail({
model: 'claude-sonnet-4-5',
timestamp: `${TODAY}T10:00:00.000Z`,
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
requestCount: 1,
cost: 0.01,
failed: false,
accountId: 'alice@example.com',
});
expect(withAccount).not.toBeNull();
expect(withAccount?.accountId).toBe('alice@example.com');
});
it('normalizes detail without accountId (remains undefined)', async () => {
const { normalizeCliproxyUsageHistoryDetail } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const noAccount = normalizeCliproxyUsageHistoryDetail({
model: 'claude-sonnet-4-5',
timestamp: `${TODAY}T10:00:00.000Z`,
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
requestCount: 1,
cost: 0.01,
failed: false,
});
expect(noAccount).not.toBeNull();
expect(noAccount?.accountId).toBeUndefined();
});
});
// ============================================================================
// DATA-AGGREGATOR: getTodayCostByAccount
// ============================================================================
describe('getTodayCostByAccount', () => {
it('returns per-account cost totals for today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const details = [];
// Simulate alice's two requests today
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const todayDetails = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
const result = getTodayCostByAccount(todayDetails, TODAY);
// Alice has auth_index 0: two requests (claude-sonnet + claude-opus)
// Bob has auth_index 1: one request (claude-sonnet)
expect(typeof result['alice@example.com']).toBe('number');
expect(typeof result['bob@example.com']).toBe('number');
expect(result['alice@example.com']).toBeGreaterThan(0);
expect(result['bob@example.com']).toBeGreaterThan(0);
// Alice has two requests across two models; Bob has one request with more tokens.
// Alice's two-request total ($0.018025) exceeds Bob's single-request total ($0.018).
expect(result['alice@example.com']).toBeGreaterThan(result['bob@example.com']);
});
it('returns empty object when no details exist for today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const result = getTodayCostByAccount([], TODAY);
expect(result).toEqual({});
});
it('filters out details from days other than today', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const yesterdayResponse = makeResponse([
{
provider: 'anthropic',
model: 'claude-sonnet-4-5',
auth_index: 0,
source: 'old-source-a',
timestamp: '2020-01-01T10:00:00.000Z', // definitely not today
input: 9999,
output: 9999,
},
]);
const details = extractCliproxyUsageHistoryDetails(yesterdayResponse, authFileMap);
const result = getTodayCostByAccount(details, TODAY);
expect(Object.keys(result)).toHaveLength(0);
});
it('accumulates costs across multiple details for the same account', async () => {
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse, authFileMap);
// alice appears for two models — verify aggregated correctly
const result = getTodayCostByAccount(details, TODAY);
const aliceCostFromDetails = details
.filter((d) => d.accountId === 'alice@example.com')
.reduce((acc, d) => acc + d.cost, 0);
expect(result['alice@example.com']).toBeCloseTo(aliceCostFromDetails, 10);
});
it('details without accountId are grouped under the "unknown" key', async () => {
// Fix #7/#13/#15: when no accountMap is provided, accountId is undefined on all details.
// getTodayCostByAccount buckets these under 'unknown' — not under detail.source.
const { getTodayCostByAccount } = await import('../../../../src/web-server/usage/data-aggregator');
const { extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
// no accountMap — accountId will be undefined on all details
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
const result = getTodayCostByAccount(details, TODAY);
// All costs should be accumulated under the literal key 'unknown'
expect(typeof result['unknown']).toBe('number');
expect(result['unknown']).toBeGreaterThan(0);
// No source-string keys should appear in the result
expect(result['old-source-a']).toBeUndefined();
expect(result['old-source-b']).toBeUndefined();
});
});
// ============================================================================
// BACKWARD COMPATIBILITY: existing profile aggregation still works
// ============================================================================
describe('backward compatibility: profile-based aggregation unaffected', () => {
it('transformCliproxyToDailyUsage works without accountMap', async () => {
const { transformCliproxyToDailyUsage } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const daily = transformCliproxyToDailyUsage(twoAccountResponse);
expect(daily.length).toBeGreaterThan(0);
expect(daily[0].source).toBe('cliproxy');
expect(daily[0].modelBreakdowns.length).toBeGreaterThan(0);
});
it('buildCliproxyUsageHistoryAggregates preserves existing shape', async () => {
const { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails } = await import('../../../../src/web-server/usage/cliproxy-usage-transformer');
const details = extractCliproxyUsageHistoryDetails(twoAccountResponse);
const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(details);
expect(Array.isArray(daily)).toBe(true);
expect(Array.isArray(hourly)).toBe(true);
expect(Array.isArray(monthly)).toBe(true);
if (daily.length > 0) {
expect(typeof daily[0].date).toBe('string');
expect(typeof daily[0].totalCost).toBe('number');
}
});
it('DailyUsage shape includes optional accountId field', async () => {
// Type-level test: verify DailyUsage can carry accountId without breaking shape
type DailyUsageShape = { date: string; source: string; totalCost: number; accountId?: string };
const sample: DailyUsageShape = {
date: TODAY,
source: 'cliproxy',
totalCost: 1.5,
accountId: 'alice@example.com',
};
const noAccount: DailyUsageShape = { date: TODAY, source: 'cliproxy', totalCost: 0.5 };
expect(sample.accountId).toBe('alice@example.com');
expect(noAccount.accountId).toBeUndefined();
});
});
// ============================================================================
// STATS-FETCHER: buildAuthIndexToAccountMap
// ============================================================================
describe('buildAuthIndexToAccountMap', () => {
it('builds map from auth files with auth_index and email', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 0, provider: 'anthropic', email: 'alice@example.com' },
{ auth_index: 1, provider: 'anthropic', email: 'bob@example.com' },
{ auth_index: 2, provider: 'gemini', email: 'carol@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.get('0')).toBe('alice@example.com');
expect(map.get('1')).toBe('bob@example.com');
expect(map.get('2')).toBe('carol@example.com');
});
it('skips entries missing auth_index', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ provider: 'anthropic', email: 'nobody@example.com' }, // no auth_index
{ auth_index: 3, provider: 'anthropic', email: 'alice@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.size).toBe(1);
expect(map.get('3')).toBe('alice@example.com');
});
it('skips entries missing email', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 4, provider: 'anthropic' }, // no email
{ auth_index: 5, provider: 'anthropic', email: 'dave@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.size).toBe(1);
expect(map.get('5')).toBe('dave@example.com');
});
it('returns empty map for empty auth files array', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const map = buildAuthIndexToAccountMap([]);
expect(map.size).toBe(0);
});
it('handles numeric and string auth_index keys consistently', async () => {
const { buildAuthIndexToAccountMap } = await import('../../../../src/cliproxy/services/stats-fetcher');
const authFiles: CliproxyManagementAuthFile[] = [
{ auth_index: 7, provider: 'anthropic', email: 'alice@example.com' },
{ auth_index: '8', provider: 'anthropic', email: 'bob@example.com' },
];
const map = buildAuthIndexToAccountMap(authFiles);
expect(map.get('7')).toBe('alice@example.com');
expect(map.get('8')).toBe('bob@example.com');
});
});