Files
ccs/tests/unit/web-server/cliproxy-usage-syncer.test.ts
T
Tam Nhu Tran 2c817fae48 fix(cliproxy): update remaining stale imports in non-colocated tests
Fix auth-token-manager, quota-response-cache, stats-fetcher,
and proxy-detector import paths in tests/ outside cliproxy.
2026-04-29 17:26:51 -04:00

316 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/services/stats-fetcher';
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
import {
loadCachedCliproxyData,
startCliproxySync,
stopCliproxySync,
syncCliproxyUsage,
} from '../../../src/web-server/usage/cliproxy-usage-syncer';
let ccsDir = '';
let rawResponse: CliproxyUsageApiResponse | null = null;
let fetchCalls = 0;
function fetchRawResponse(): Promise<CliproxyUsageApiResponse | null> {
fetchCalls++;
return Promise.resolve(rawResponse);
}
function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse {
return {
usage: {
apis: {
gemini: {
models: {
'gemini-2.5-pro': {
details: [
{
timestamp,
source: 'account-a',
auth_index: 0,
tokens: {
input_tokens: inputTokens,
output_tokens: 20,
reasoning_tokens: 0,
cached_tokens: 10,
total_tokens: inputTokens + 30,
},
failed: false,
},
],
},
},
},
},
},
};
}
function createDeferredFetch(response: CliproxyUsageApiResponse | null) {
let resolveFetch: ((value: CliproxyUsageApiResponse | null) => void) | undefined;
return {
fetch: () =>
new Promise<CliproxyUsageApiResponse | null>((resolve) => {
fetchCalls++;
resolveFetch = resolve;
}),
resolve: () => resolveFetch?.(response),
};
}
beforeEach(() => {
ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-'));
fetchCalls = 0;
rawResponse = {
usage: {
apis: {
gemini: {
models: {
'gemini-2.5-pro': {
details: [
{
timestamp: '2026-03-02T12:00:00.000Z',
source: 'account-a',
auth_index: 0,
tokens: {
input_tokens: 100,
output_tokens: 20,
reasoning_tokens: 0,
cached_tokens: 10,
total_tokens: 130,
},
failed: false,
},
],
},
},
},
},
},
};
stopCliproxySync();
});
afterEach(() => {
stopCliproxySync();
fs.rmSync(ccsDir, { recursive: true, force: true });
});
describe('cliproxy usage syncer', () => {
it('writes and loads snapshot data', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
await syncCliproxyUsage(fetchRawResponse);
});
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
expect(fs.existsSync(snapshotPath)).toBe(true);
const cached = await runWithScopedConfigDir(ccsDir, async () => {
return await loadCachedCliproxyData();
});
expect(cached.daily).toHaveLength(1);
expect(cached.daily[0].source).toBe('cliproxy');
expect(cached.daily[0].inputTokens).toBe(100);
expect(cached.hourly).toHaveLength(1);
expect(cached.monthly).toHaveLength(1);
});
it('startCliproxySync is idempotent and starts only one interval', async () => {
const intervalSpy = spyOn(globalThis, 'setInterval');
await runWithScopedConfigDir(ccsDir, async () => {
const syncNow = () => syncCliproxyUsage(fetchRawResponse);
startCliproxySync(syncNow);
startCliproxySync(syncNow);
});
expect(intervalSpy).toHaveBeenCalledTimes(1);
expect(fetchCalls).toBeGreaterThan(0);
stopCliproxySync();
intervalSpy.mockRestore();
});
it('keeps stale cached snapshots available for historical analytics reads', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
await syncCliproxyUsage(fetchRawResponse);
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as {
timestamp: number;
};
snapshot.timestamp = Date.now() - 8 * 24 * 60 * 60 * 1000;
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf-8');
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(1);
expect(cached.hourly).toHaveLength(1);
expect(cached.monthly).toHaveLength(1);
});
});
it('merges unique history from overlapping syncs even when the older sync finishes last', async () => {
const olderSync = createDeferredFetch(buildResponse(100, '2026-03-01T10:00:00.000Z'));
const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z'));
await runWithScopedConfigDir(ccsDir, async () => {
const olderWrite = syncCliproxyUsage(olderSync.fetch);
const newerWrite = syncCliproxyUsage(newerSync.fetch);
newerSync.resolve();
await Promise.resolve();
olderSync.resolve();
await Promise.all([olderWrite, newerWrite]);
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(2);
expect(cached.daily.find((entry) => entry.date === '2026-03-01')?.inputTokens).toBe(100);
expect(cached.daily[0].inputTokens).toBe(200);
});
});
it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => {
for (const version of [1, 2]) {
await runWithScopedConfigDir(ccsDir, async () => {
const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json');
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(
snapshotPath,
JSON.stringify({
version,
timestamp: Date.now() - 60_000,
daily: [
{
date: '2026-03-01',
source: 'cliproxy',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
totalCost: 0.2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
},
],
},
],
hourly: [
{
hour: '2026-03-01 12:00',
source: 'cliproxy',
requestCount: 7,
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
totalCost: 0.2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
},
],
},
],
monthly: [
{
month: '2026-03',
source: 'cliproxy',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
totalCost: 0.2,
modelsUsed: ['gemini-2.5-pro'],
modelBreakdowns: [
{
modelName: 'gemini-2.5-pro',
inputTokens: 100,
outputTokens: 20,
cacheCreationTokens: 0,
cacheReadTokens: 10,
cost: 0.2,
},
],
},
],
}),
'utf-8'
);
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z'))
);
const cached = await loadCachedCliproxyData();
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']);
expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7);
});
}
});
it('prunes history details older than the configured retention window', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(100, '2024-01-01T12:00:00.000Z'))
);
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(200, new Date().toISOString()))
);
const cached = await loadCachedCliproxyData();
expect(cached.daily.some((entry) => entry.date === '2024-01-01')).toBe(false);
});
});
it('preserves prior-day history when a later sync only returns the current window', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(100, '2026-03-01T12:00:00.000Z'))
);
await syncCliproxyUsage(() =>
Promise.resolve(buildResponse(200, '2026-03-02T12:00:00.000Z'))
);
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(2);
expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']);
expect(cached.daily.find((entry) => entry.date === '2026-03-01')?.inputTokens).toBe(100);
expect(cached.daily.find((entry) => entry.date === '2026-03-02')?.inputTokens).toBe(200);
});
});
it('does not double count when the same snapshot window is synced twice', async () => {
await runWithScopedConfigDir(ccsDir, async () => {
const repeatedResponse = buildResponse(250, '2026-03-02T12:00:00.000Z');
await syncCliproxyUsage(() => Promise.resolve(repeatedResponse));
await syncCliproxyUsage(() => Promise.resolve(repeatedResponse));
const cached = await loadCachedCliproxyData();
expect(cached.daily).toHaveLength(1);
expect(cached.daily[0].inputTokens).toBe(250);
expect(cached.hourly[0].requestCount).toBe(1);
});
});
});