Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 327 additions & 13 deletions packages/frontend/__tests__/api/submit.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
attachDeviceContributions,
LEGACY_DEVICE_ID,
mergeClientBreakdowns,
type ClientBreakdownData,
} from '../../src/lib/db/helpers';

/**
* Test suite for POST /api/submit - Client-Level Merge
*
* These tests verify the client-level merge functionality:
* - First submission creates new records
* - Subsequent submissions merge by client
* - Clients not in submission are preserved
* - Totals are recalculated from dailyBreakdown
* - Concurrent submissions are handled correctly
* Test suite for POST /api/submit merge behavior.
*
* These tests cover both the legacy client-level expectations and the
* device-level merge semantics used to preserve cross-machine submissions.
*/

// Mock data factories
Expand Down Expand Up @@ -89,7 +91,7 @@ function createMockSubmissionData(overrides: Partial<{
};
}

describe('POST /api/submit - Client-Level Merge', () => {
describe('POST /api/submit merge behavior', () => {
describe('First Submission (Create Mode)', () => {
it('should create new submission with all clients', () => {
const data = createMockSubmissionData({ clients: ['claude', 'cursor'] });
Expand Down Expand Up @@ -157,7 +159,6 @@ describe('POST /api/submit - Client-Level Merge', () => {

it('should update submitted client data', () => {
// Same client submitted again should replace, not add
const existingClaude = { tokens: 1000, cost: 10, modelId: 'claude-sonnet-4', input: 600, output: 400, cacheRead: 0, cacheWrite: 0, messages: 5 };
const newClaude = { tokens: 1500, cost: 15, modelId: 'claude-sonnet-4', input: 900, output: 600, cacheRead: 0, cacheWrite: 0, messages: 8 };

// After merge, should be new values, not sum
Expand Down Expand Up @@ -263,8 +264,6 @@ describe('POST /api/submit - Client-Level Merge', () => {
],
};

const submittedClients = new Set(['claude']);

// No claude data to update for this day
const claudeInDay = dayWithOnlyOpencode.clients.find(client => client.client === 'claude');
expect(claudeInDay).toBeUndefined();
Expand Down Expand Up @@ -442,4 +441,319 @@ describe('POST /api/submit - Client-Level Merge', () => {
expect(mockResponse.mode).toBe('merge');
});
});

describe('Device-Level Deduplication', () => {
const makeClientData = (tokens: number, cost: number, modelId: string): ClientBreakdownData => ({
tokens,
cost,
input: Math.floor(tokens * 0.6),
output: Math.floor(tokens * 0.4),
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
messages: 1,
models: {
[modelId]: {
tokens,
cost,
input: Math.floor(tokens * 0.6),
output: Math.floor(tokens * 0.4),
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
messages: 1,
},
},
});

it('replaces a device contribution on resubmit instead of double-counting it', () => {
const incoming1 = { claude: makeClientData(1000, 10, 'claude-sonnet-4') };
const after1 = mergeClientBreakdowns({}, incoming1, new Set(['claude']), 'device-a');

const incoming2 = { claude: makeClientData(1500, 15, 'claude-sonnet-4') };
const after2 = mergeClientBreakdowns(after1, incoming2, new Set(['claude']), 'device-a');

expect(after2.claude.tokens).toBe(1500);
expect(after2.claude.devices!['device-a'].tokens).toBe(1500);
});

it('keeps other device contributions when one device resubmits', () => {
const afterA = mergeClientBreakdowns(
{},
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
new Set(['claude']),
'device-a'
);
const afterB = mergeClientBreakdowns(
afterA,
{ claude: makeClientData(500, 5, 'claude-sonnet-4') },
new Set(['claude']),
'device-b'
);
const afterA2 = mergeClientBreakdowns(
afterB,
{ claude: makeClientData(2000, 20, 'claude-sonnet-4') },
new Set(['claude']),
'device-a'
);

expect(afterA2.claude.devices!['device-a'].tokens).toBe(2000);
expect(afterA2.claude.devices!['device-b'].tokens).toBe(500);
expect(afterA2.claude.tokens).toBe(2500);
});

it('migrates legacy client data into a __legacy__ device bucket on first merge', () => {
const existing: Record<string, ClientBreakdownData> = {
claude: {
tokens: 800,
cost: 8,
input: 480,
output: 320,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
messages: 3,
models: {
'claude-sonnet-4': {
tokens: 800,
cost: 8,
input: 480,
output: 320,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
messages: 3,
},
},
},
};

const merged = mergeClientBreakdowns(
existing,
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
new Set(['claude']),
'device-a'
);

expect(merged.claude.devices![LEGACY_DEVICE_ID]).toBeDefined();
expect(merged.claude.devices![LEGACY_DEVICE_ID].tokens).toBe(800);
expect(merged.claude.devices!['device-a'].tokens).toBe(1000);
expect(merged.claude.tokens).toBe(1800);
});

it('removes only the submitting device when a declared client has no incoming data', () => {
const afterA = mergeClientBreakdowns(
{},
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
new Set(['claude']),
'device-a'
);
const afterB = mergeClientBreakdowns(
afterA,
{ claude: makeClientData(500, 5, 'claude-sonnet-4') },
new Set(['claude']),
'device-b'
);
const afterRemove = mergeClientBreakdowns(
afterB,
{},
new Set(['claude']),
'device-a'
);

expect(afterRemove.claude.devices!['device-a']).toBeUndefined();
expect(afterRemove.claude.devices!['device-b'].tokens).toBe(500);
expect(afterRemove.claude.tokens).toBe(500);
});

it('deletes the client when the last device contribution is removed', () => {
const afterA = mergeClientBreakdowns(
{},
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
new Set(['claude']),
'device-a'
);
const afterRemove = mergeClientBreakdowns(
afterA,
{},
new Set(['claude']),
'device-a'
);

expect(afterRemove.claude).toBeUndefined();
});

it('aggregates models across devices for the same client', () => {
const afterA = mergeClientBreakdowns(
{},
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
new Set(['claude']),
'device-a'
);
const afterB = mergeClientBreakdowns(
afterA,
{ claude: makeClientData(500, 5, 'claude-opus-4') },
new Set(['claude']),
'device-b'
);

expect(afterB.claude.models['claude-sonnet-4'].tokens).toBe(1000);
expect(afterB.claude.models['claude-opus-4'].tokens).toBe(500);
expect(afterB.claude.tokens).toBe(1500);
});

it('handles multiple clients across multiple devices', () => {
const afterA = mergeClientBreakdowns(
{},
{
claude: makeClientData(1000, 10, 'claude-sonnet-4'),
cursor: makeClientData(300, 3, 'gpt-4o'),
},
new Set(['claude', 'cursor']),
'device-a'
);
const afterB = mergeClientBreakdowns(
afterA,
{ claude: makeClientData(500, 5, 'claude-sonnet-4') },
new Set(['claude']),
'device-b'
);

expect(afterB.claude.tokens).toBe(1500);
expect(afterB.cursor.tokens).toBe(300);
expect(afterB.cursor.devices!['device-a'].tokens).toBe(300);
});

it('adds device-scoped breakdown data for newly inserted days', () => {
const incoming = {
claude: makeClientData(1000, 10, 'claude-sonnet-4'),
cursor: makeClientData(300, 3, 'gpt-4o'),
};

const withDevices = attachDeviceContributions(incoming, 'device-a');

expect(withDevices.claude.tokens).toBe(1000);
expect(withDevices.claude.devices!['device-a'].tokens).toBe(1000);
expect(withDevices.claude.devices!['device-a'].models['claude-sonnet-4'].tokens).toBe(1000);
expect(withDevices.cursor.devices!['device-a'].tokens).toBe(300);
expect(Object.keys(withDevices.cursor.devices!)).toEqual(['device-a']);
});

it('drops stale device-only days that disappear inside the submitted date range', () => {
const submittedClients = new Set(['claude']);
const dateRange = { start: '2024-12-01', end: '2024-12-02' };
const incomingDates = new Set(['2024-12-02']);

const existingDays = [
{
id: 'day-1',
date: '2024-12-01',
sourceBreakdown: mergeClientBreakdowns(
{},
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
submittedClients,
'device-a'
),
},
{
id: 'day-2',
date: '2024-12-03',
sourceBreakdown: mergeClientBreakdowns(
{},
{ claude: makeClientData(500, 5, 'claude-sonnet-4') },
submittedClients,
'device-a'
),
},
];

const toDelete: string[] = [];

for (const existingDay of existingDays) {
const isWithinSubmittedRange =
existingDay.date >= dateRange.start &&
existingDay.date <= dateRange.end;

if (!isWithinSubmittedRange || incomingDates.has(existingDay.date)) {
continue;
}

const prunedClientBreakdown = mergeClientBreakdowns(
existingDay.sourceBreakdown,
{},
submittedClients,
'device-a'
);

if (Object.keys(prunedClientBreakdown).length === 0) {
toDelete.push(existingDay.id);
}
}

expect(toDelete).toEqual(['day-1']);
});

it('removes stale omitted clients for disappeared days inside the submitted range', () => {
const dateRange = { start: '2024-12-01', end: '2024-12-02' };
const incomingDates = new Set(['2024-12-02']);

const existingDay = {
id: 'day-1',
date: '2024-12-01',
sourceBreakdown: mergeClientBreakdowns(
{},
{
claude: makeClientData(1000, 10, 'claude-sonnet-4'),
cursor: makeClientData(300, 3, 'gpt-4o'),
},
new Set(['claude', 'cursor']),
'device-a'
),
};

const isWithinSubmittedRange =
existingDay.date >= dateRange.start &&
existingDay.date <= dateRange.end;

expect(isWithinSubmittedRange).toBe(true);
expect(incomingDates.has(existingDay.date)).toBe(false);

const existingClientBreakdown =
existingDay.sourceBreakdown as Record<string, ClientBreakdownData>;
const staleClients = new Set(Object.keys(existingClientBreakdown));
const prunedClientBreakdown = mergeClientBreakdowns(
existingClientBreakdown,
{},
staleClients,
'device-a'
);

expect(prunedClientBreakdown).toEqual({});
});

it('preserves days outside the submitted date range even when they are absent from the payload', () => {
const submittedClients = new Set(['claude']);
const dateRange = { start: '2024-12-02', end: '2024-12-03' };
const incomingDates = new Set(['2024-12-03']);

const existingDay = {
id: 'day-1',
date: '2024-12-01',
sourceBreakdown: mergeClientBreakdowns(
{},
{ claude: makeClientData(1000, 10, 'claude-sonnet-4') },
submittedClients,
'device-a'
),
};

const isWithinSubmittedRange =
existingDay.date >= dateRange.start &&
existingDay.date <= dateRange.end;

expect(isWithinSubmittedRange).toBe(false);
expect(incomingDates.has(existingDay.date)).toBe(false);
expect(existingDay.sourceBreakdown.claude.devices!['device-a'].tokens).toBe(1000);
});
});
});
Loading