codeburn/tests/parser-proxy-pricing.test.ts
Tiago Santos 75c32e6d65
fix: fix and improve test isolation and collision with environment (#530)
* fix: fix and improve test isolation and collision with environment

* docs: remove unnecessary comment

* test(env-isolation): clear CODEBURN_FORCE_MACOS_MAJOR and pin TZ

Two env vars read in src/ were not isolated: CODEBURN_FORCE_MACOS_MAJOR
(now cleared so it cannot leak between tests) and TZ (now pinned to UTC,
since clearing it falls back to the OS zone and would shift date buckets
versus a clean CI runner).

---------

Co-authored-by: AgentSeal <hello@agentseal.org>
2026-06-20 13:42:10 +02:00

264 lines
10 KiB
TypeScript

import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { setProxyPaths, isProxiedPath, getProxyPathsConfigHash, setLocalModelSavings, setModelAliases, loadPricing } from '../src/models.js'
import { parseAllSessions, clearSessionCache, filterProjectsByDateRange } from '../src/parser.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
// ── Part A: isProxiedPath matching rule (pure) ─────────────────────────────
describe('isProxiedPath: path matching rule', () => {
beforeEach(() => setProxyPaths([]))
afterEach(() => setProxyPaths([]))
it('never matches when no proxy paths are configured', () => {
expect(isProxiedPath('/Users/me/work/acme')).toBe(false)
})
it('matches an exact path', () => {
setProxyPaths(['/Users/me/work/acme'])
expect(isProxiedPath('/Users/me/work/acme')).toBe(true)
})
it('matches a child directory under the prefix', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('/Users/me/work/acme/sub')).toBe(true)
})
it('does NOT match across a partial path segment (boundary guard)', () => {
// The single most important negative: a string prefix that is not a
// directory-segment boundary must not silently zero unrelated spend.
setProxyPaths(['/Users/me/proj'])
expect(isProxiedPath('/Users/me/project-unrelated')).toBe(false)
})
it('is tolerant of trailing slashes on both config and cwd', () => {
setProxyPaths(['/Users/me/work/'])
expect(isProxiedPath('/Users/me/work')).toBe(true)
expect(isProxiedPath('/Users/me/work/')).toBe(true)
})
it('is case-insensitive (macOS/Windows default filesystems)', () => {
setProxyPaths(['/Users/Me/Work'])
expect(isProxiedPath('/users/me/work/acme')).toBe(true)
})
it('matches a Windows-style config against a forward-slash cwd', () => {
setProxyPaths(['C:\\Users\\me\\work'])
expect(isProxiedPath('C:/Users/me/work/acme')).toBe(true)
})
it('never matches an empty/undefined/null cwd', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('')).toBe(false)
expect(isProxiedPath(undefined)).toBe(false)
expect(isProxiedPath(null)).toBe(false)
})
it('drops a root "/" entry so it can never match everything', () => {
setProxyPaths(['/'])
expect(isProxiedPath('/Users/me/anything')).toBe(false)
})
it('drops blank / non-string entries', () => {
setProxyPaths(['', ' ', undefined as unknown as string, '/Users/me/work'])
expect(isProxiedPath('/Users/me/work/x')).toBe(true)
expect(isProxiedPath('/somewhere/else')).toBe(false)
})
it('matches when any one of several configured paths matches', () => {
setProxyPaths(['/Users/me/a', '/Users/me/b'])
expect(isProxiedPath('/Users/me/b/deep')).toBe(true)
expect(isProxiedPath('/Users/me/c')).toBe(false)
})
it('is reset by setProxyPaths([])', () => {
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('/Users/me/work')).toBe(true)
setProxyPaths([])
expect(isProxiedPath('/Users/me/work')).toBe(false)
})
it('matches a leading-slash-stripped cwd (non-Claude provider path form)', () => {
// Codex/unsanitizePath project paths drop the leading slash; the configured
// path keeps it. Matching must be agnostic to that difference.
setProxyPaths(['/Users/me/work'])
expect(isProxiedPath('Users/me/work/acme')).toBe(true)
expect(isProxiedPath('Users/me/work')).toBe(true)
})
})
describe('getProxyPathsConfigHash: cache-key stability', () => {
beforeEach(() => setProxyPaths([]))
afterEach(() => setProxyPaths([]))
it('is empty when unconfigured', () => {
expect(getProxyPathsConfigHash()).toBe('')
})
it('is order-independent', () => {
setProxyPaths(['/a', '/b'])
const h1 = getProxyPathsConfigHash()
setProxyPaths(['/b', '/a'])
expect(getProxyPathsConfigHash()).toBe(h1)
})
it('does NOT collide two materially different sets (delimited join)', () => {
// Regression guard: a separator-less join would make {'/a','/b'} and
// {'/a/b'} hash identically and let the session cache serve stale numbers.
setProxyPaths(['/a', '/b'])
const h1 = getProxyPathsConfigHash()
setProxyPaths(['/a/b'])
expect(getProxyPathsConfigHash()).not.toBe(h1)
})
})
// ── Part B: end-to-end attribution through parseAllSessions ────────────────
const FIXTURE_DAY = Date.UTC(2026, 3, 16)
const RANGE_START = new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000)
const RANGE_END = new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000)
const makeRange = (): DateRange => ({ start: RANGE_START, end: RANGE_END })
// A stable, non-existent absolute path: resolveCanonicalProjectPath finds no
// .git ancestor and returns it unchanged, so projectPath is predictable.
const FIXTURE_CWD = '/private/var/eywa-proxy-fixture/acme'
let tmpDirs: string[] = []
beforeAll(async () => {
await loadPricing()
})
beforeEach(() => {
setProxyPaths([])
setLocalModelSavings({})
setModelAliases({})
clearSessionCache()
})
afterEach(async () => {
setProxyPaths([])
clearSessionCache()
while (tmpDirs.length > 0) {
const d = tmpDirs.pop()
if (d) await rm(d, { recursive: true, force: true })
}
})
async function setupProxiedSession(cwd: string = FIXTURE_CWD): Promise<void> {
const base = await mkdtemp(join(tmpdir(), 'codeburn-proxy-'))
tmpDirs.push(base)
const projectDir = join(base, 'projects', 'p')
await mkdir(projectDir, { recursive: true })
const line = JSON.stringify({
type: 'assistant',
timestamp: '2026-04-16T10:00:00.000Z',
sessionId: 's1',
cwd,
message: {
type: 'message',
role: 'assistant',
model: 'claude-sonnet-4-6',
id: 'msg-1',
content: [],
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
},
})
await writeFile(join(projectDir, 's1.jsonl'), line + '\n', 'utf-8')
process.env['CLAUDE_CONFIG_DIR'] = base
}
const allCalls = (projects: ProjectSummary[]) =>
projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
describe('proxy pricing: end-to-end through parseAllSessions', () => {
it('attributes nothing as proxied when no proxy paths are configured', async () => {
await setupProxiedSession()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBe(0)
})
it('flags the full cost as proxied when the project is under a proxy path, WITHOUT altering costUSD', async () => {
await setupProxiedSession()
setProxyPaths([FIXTURE_CWD])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
// "Full cost, flagged": the billable figure is preserved, the same amount
// is reported as subscription-covered, so net out-of-pocket is 0.
expect(proxied).toBeCloseTo(total, 10)
expect(total - proxied).toBeCloseTo(0, 10)
// The raw per-call cost is never destroyed — it stays at the full API rate.
const calls = allCalls(projects)
expect(calls.length).toBeGreaterThan(0)
for (const c of calls) expect(c.costUSD).toBeGreaterThan(0)
})
it('matches a parent prefix on a segment boundary', async () => {
await setupProxiedSession()
setProxyPaths(['/private/var/eywa-proxy-fixture'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBeGreaterThan(0)
})
it('does NOT flag a sibling path that is only a string prefix (no spend silently zeroed)', async () => {
await setupProxiedSession()
// '/private/var/eywa-proxy-fixture/ac' is a string prefix of '.../acme'
// but not a directory-segment boundary — must not match.
setProxyPaths(['/private/var/eywa-proxy-fixture/ac'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBe(0)
})
it('attributes nothing when a different, unrelated path is configured', async () => {
await setupProxiedSession()
setProxyPaths(['/Users/someone/else'])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const proxied = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBe(0)
})
it('preserves proxy attribution after date-range filtering (filterProjectsByDateRange)', async () => {
await setupProxiedSession()
setProxyPaths([FIXTURE_CWD])
clearSessionCache()
const projects = await parseAllSessions(makeRange(), 'all')
const filtered = filterProjectsByDateRange(projects, makeRange())
expect(filtered.length).toBeGreaterThan(0)
const total = filtered.reduce((s, p) => s + p.totalCostUSD, 0)
const proxied = filtered.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(total).toBeGreaterThan(0)
expect(proxied).toBeCloseTo(total, 10)
})
it('does not serve stale proxy attribution from the in-memory cache after proxyPaths changes', async () => {
// parseAllSessions caches ProjectSummary[] for 180s keyed partly on the
// proxy-config hash. Toggling proxyPaths must change the key so the second
// call recomputes rather than returning the pre-change (proxied=0) result.
await setupProxiedSession()
const before = await parseAllSessions(makeRange(), 'all')
expect(before.reduce((s, p) => s + p.totalProxiedCostUSD, 0)).toBe(0)
setProxyPaths([FIXTURE_CWD]) // deliberately NO clearSessionCache()
const after = await parseAllSessions(makeRange(), 'all')
const proxied = after.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
expect(proxied).toBeGreaterThan(0)
})
})