From 383ac94623f69fc024e12008c6d40f059ae12491 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 23:03:37 +0700 Subject: [PATCH] test(usage): add cliproxy sync and integration coverage - add transformer unit tests for flattening and daily/hourly/monthly rollups - add syncer tests for snapshot persistence and idempotent interval start - add aggregator integration test for JSONL + CLIProxy merge behavior --- .../web-server/cliproxy-usage-syncer.test.ts | 97 +++++++++ .../cliproxy-usage-transformer.test.ts | 128 ++++++++++++ ...ge-aggregator-cliproxy-integration.test.ts | 196 ++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 tests/unit/web-server/cliproxy-usage-syncer.test.ts create mode 100644 tests/unit/web-server/cliproxy-usage-transformer.test.ts create mode 100644 tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts new file mode 100644 index 00000000..1d3f165e --- /dev/null +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -0,0 +1,97 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, 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/stats-fetcher'; + +let ccsDir = ''; +let rawResponse: CliproxyUsageApiResponse | null = null; +let fetchCalls = 0; + +mock.module('../../../src/utils/config-manager', () => ({ + getCcsDir: () => ccsDir, +})); + +mock.module('../../../src/cliproxy/stats-fetcher', () => ({ + fetchCliproxyUsageRaw: async () => { + fetchCalls++; + return rawResponse; + }, +})); + +let syncer: typeof import('../../../src/web-server/usage/cliproxy-usage-syncer'); + +beforeAll(async () => { + syncer = await import('../../../src/web-server/usage/cliproxy-usage-syncer'); +}); + +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, + }, + ], + }, + }, + }, + }, + }, + }; + syncer.stopCliproxySync(); +}); + +afterEach(() => { + syncer.stopCliproxySync(); + fs.rmSync(ccsDir, { recursive: true, force: true }); +}); + +afterAll(() => { + mock.restore(); +}); + +describe('cliproxy usage syncer', () => { + it('writes and loads snapshot data', async () => { + await syncer.syncCliproxyUsage(); + + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + expect(fs.existsSync(snapshotPath)).toBe(true); + + const cached = await syncer.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', () => { + const intervalSpy = spyOn(globalThis, 'setInterval'); + + syncer.startCliproxySync(); + syncer.startCliproxySync(); + + expect(intervalSpy).toHaveBeenCalledTimes(1); + expect(fetchCalls).toBeGreaterThan(0); + + syncer.stopCliproxySync(); + intervalSpy.mockRestore(); + }); +}); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts new file mode 100644 index 00000000..ac2e6eec --- /dev/null +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'bun:test'; +import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import { + flattenCliproxyDetails, + transformCliproxyToDailyUsage, + transformCliproxyToHourlyUsage, + transformCliproxyToMonthlyUsage, +} from '../../../src/web-server/usage/cliproxy-usage-transformer'; + +const sampleResponse: CliproxyUsageApiResponse = { + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp: '2026-03-01T10:15:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 100, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 20, + total_tokens: 170, + }, + failed: false, + }, + { + timestamp: '2026-03-01T11:30:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 40, + output_tokens: 10, + reasoning_tokens: 0, + cached_tokens: 5, + total_tokens: 55, + }, + failed: true, + }, + { + timestamp: '2026-03-01T10:45:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 30, + output_tokens: 20, + reasoning_tokens: 0, + cached_tokens: 10, + total_tokens: 60, + }, + failed: false, + }, + ], + }, + }, + }, + codex: { + models: { + 'gpt-4.1': { + details: [ + { + timestamp: '2026-03-02T01:00:00.000Z', + source: 'account-b', + auth_index: 1, + tokens: { + input_tokens: 70, + output_tokens: 30, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 100, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, +}; + +describe('cliproxy usage transformer', () => { + it('flattens nested API details and skips failed requests', () => { + const flat = flattenCliproxyDetails(sampleResponse); + expect(flat).toHaveLength(3); + expect(flat.every((entry) => entry.detail.failed === false)).toBe(true); + }); + + it('transforms daily usage with aggregated model totals', () => { + const daily = transformCliproxyToDailyUsage(sampleResponse); + + expect(daily).toHaveLength(2); + expect(daily[0].date).toBe('2026-03-02'); + expect(daily[0].source).toBe('cliproxy'); + expect(daily[1].date).toBe('2026-03-01'); + + const marchFirst = daily.find((d) => d.date === '2026-03-01'); + expect(marchFirst?.inputTokens).toBe(130); + expect(marchFirst?.outputTokens).toBe(70); + expect(marchFirst?.cacheReadTokens).toBe(30); + expect(marchFirst?.modelsUsed).toContain('gemini-2.5-pro'); + }); + + it('transforms hourly usage with hour buckets', () => { + const hourly = transformCliproxyToHourlyUsage(sampleResponse); + + expect(hourly).toHaveLength(2); + expect(hourly[0].hour).toBe('2026-03-02 01:00'); + + const tenAm = hourly.find((h) => h.hour === '2026-03-01 10:00'); + expect(tenAm?.inputTokens).toBe(130); + expect(tenAm?.outputTokens).toBe(70); + }); + + it('transforms monthly usage with cliproxy source', () => { + const monthly = transformCliproxyToMonthlyUsage(sampleResponse); + + expect(monthly).toHaveLength(1); + expect(monthly[0].month).toBe('2026-03'); + expect(monthly[0].source).toBe('cliproxy'); + expect(monthly[0].inputTokens).toBe(200); + expect(monthly[0].outputTokens).toBe(100); + expect(monthly[0].cacheReadTokens).toBe(30); + }); +}); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts new file mode 100644 index 00000000..3b5cd6cf --- /dev/null +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempRoot = ''; +let ccsDir = ''; +let claudeDir = ''; +let aggregator: typeof import('../../../src/web-server/usage/aggregator'); +let originalCcsDir: string | undefined; +let originalClaudeConfigDir: string | undefined; + +function writeClaudeJsonlFixture(): void { + const projectDir = path.join(claudeDir, 'projects', 'project-one'); + fs.mkdirSync(projectDir, { recursive: true }); + + const line = JSON.stringify({ + type: 'assistant', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + version: '1.0.0', + cwd: '/tmp/project', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 100, + output_tokens: 40, + }, + }, + }); + + fs.writeFileSync(path.join(projectDir, 'usage.jsonl'), `${line}\n`, 'utf-8'); +} + +function writeCliproxySnapshotFixture(): void { + const snapshotDir = path.join(ccsDir, 'cache', 'cliproxy-usage'); + fs.mkdirSync(snapshotDir, { recursive: true }); + + const snapshot = { + version: 1, + timestamp: Date.now(), + daily: [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + hourly: [ + { + hour: '2026-03-02 10:00', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + monthly: [ + { + month: '2026-03', + source: 'cliproxy', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + totalCost: 0.2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 50, + outputTokens: 10, + cacheCreationTokens: 0, + cacheReadTokens: 5, + cost: 0.2, + }, + ], + }, + ], + }; + + fs.writeFileSync(path.join(snapshotDir, 'latest.json'), JSON.stringify(snapshot), 'utf-8'); +} + +function writeUnifiedConfigFixture(): void { + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: 65534 +`; + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), yaml, 'utf-8'); +} + +beforeEach(async () => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-agg-')); + ccsDir = path.join(tempRoot, '.ccs'); + claudeDir = path.join(tempRoot, '.claude'); + + originalCcsDir = process.env.CCS_DIR; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CCS_DIR = ccsDir; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + + writeUnifiedConfigFixture(); + writeClaudeJsonlFixture(); + writeCliproxySnapshotFixture(); + + aggregator = await import('../../../src/web-server/usage/aggregator'); + aggregator.clearUsageCache(); +}); + +afterEach(() => { + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); + + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('usage aggregator cliproxy integration', () => { + it('merges cliproxy snapshot data into getCachedDailyData', async () => { + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0].date).toBe('2026-03-02'); + expect(daily[0].inputTokens).toBe(150); + expect(daily[0].outputTokens).toBe(50); + expect(daily[0].cacheReadTokens).toBe(5); + expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(daily[0].modelsUsed).toContain('gemini-2.5-pro'); + }); + + it('clearUsageCache resets last fetch timestamp', async () => { + await aggregator.getCachedDailyData(); + expect(aggregator.getLastFetchTimestamp()).not.toBeNull(); + + aggregator.clearUsageCache(); + expect(aggregator.getLastFetchTimestamp()).toBeNull(); + }); +});