codeburn/tests/daily-cache.test.ts
Uttam Deb ce19e28ca6
Count Claude queued prompts as turns (#1582)
* Add regression coverage for Claude queued prompts

* Count queued Claude human prompts in turn grouping

* Cover queued prompts through Claude session cache

* Reparse cached Claude turns for queued prompts

* Refresh daily categories after Claude turn regrouping

* fix(claude): count only human queued prompts, skip peer agent messages

---------

Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
2026-10-02 11:48:12 +02:00

720 lines
28 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readFile, rm } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { ProjectSummary } from '../src/types.js'
import {
addNewDays,
currentTzKey,
dailyCachePath,
DAILY_CACHE_VERSION,
type DailyCache,
type DailyEntry,
getDaysInRange,
ensureCacheHydrated,
loadDailyCache,
saveDailyCache,
withDailyCacheLock,
} from '../src/daily-cache.js'
function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
return {
date,
cost,
savingsUSD: 0,
calls,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
// A day's model rows have to account for its calls and cost, or loading it
// credits the difference to the carried row.
models: calls || cost
? { 'Opus 4.7': { calls, cost, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
: {},
categories: {},
providers: {},
}
}
const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
beforeEach(() => {
process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
})
afterEach(async () => {
vi.useRealTimers()
if (existsSync(TMP_CACHE_ROOT)) {
await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
}
})
describe('loadDailyCache', () => {
it('returns an empty cache when the file does not exist', async () => {
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.lastComputedDate).toBeNull()
expect(cache.days).toEqual([])
})
it('returns an empty cache when the file contains invalid JSON', async () => {
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toEqual([])
})
// With carry-forward (v14), a legacy unversioned file whose version is not
// the current one is ADOPTED as a carried baseline — its days survive into
// the new cache, marked `carried` and pending re-derivation. The legacy file
// itself is never rewritten, backed up, or deleted (old binaries still own it).
it('adopts a legacy file too old to trust as a carried baseline, without rewriting it', async () => {
const saved = {
version: 1,
lastComputedDate: '2026-04-10',
days: [{ date: '2026-04-10', cost: 10, calls: 5 }],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json')
await writeFile(legacy, JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.days).toHaveLength(1)
expect(cache.days[0]).toMatchObject({ date: '2026-04-10', cost: 10, calls: 5, carried: true })
// Adopted days are not yet finalized under current accounting.
expect(cache.complete).not.toBe(true)
// Legacy file untouched (no .bak, contents intact); versioned file persisted.
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(false)
expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved)
expect(existsSync(dailyCachePath())).toBe(true)
})
it('adopts a legacy v2 cache as carried days and leaves the file intact', async () => {
const saved = {
version: 2,
lastComputedDate: '2026-04-10',
days: [{
date: '2026-04-10', cost: 10, calls: 5, sessions: 2,
inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100,
models: { 'claude-opus-4-6': { calls: 5, cost: 10, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } },
}],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json')
await writeFile(legacy, JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.days).toHaveLength(1)
expect(cache.days[0]).toMatchObject({ date: '2026-04-10', cost: 10, calls: 5, sessions: 2, carried: true })
expect(cache.days[0]!.models['claude-opus-4-6']!.cost).toBe(10)
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(false)
expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved)
})
it('adopts a legacy v5 cache including its provider slices', async () => {
const saved = {
version: 5,
lastComputedDate: '2026-05-01',
days: [{
date: '2026-05-01',
cost: 0.37575,
calls: 1,
sessions: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 60_120,
editTurns: 0,
oneShotTurns: 0,
models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } },
categories: {},
providers: { claude: { calls: 1, cost: 0.37575 } },
}],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json')
await writeFile(legacy, JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
expect(cache.version).toBe(DAILY_CACHE_VERSION)
expect(cache.days).toHaveLength(1)
expect(cache.days[0]).toMatchObject({ date: '2026-05-01', cost: 0.37575, calls: 1, carried: true })
expect(cache.days[0]!.providers['claude']).toMatchObject({ calls: 1, cost: 0.37575 })
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(false)
expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved)
})
it('credits a token remainder to the carried row even when cost and calls reconcile', async () => {
const saved = {
version: 5,
lastComputedDate: '2026-05-02',
days: [
{
// cost + calls reconcile with the model rows, but the model tokens
// fall 200 input / 100 output short of the day totals.
date: '2026-05-02',
cost: 10, calls: 5, sessions: 1,
inputTokens: 1000, outputTokens: 500, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0,
models: { 'Opus 4.7': { calls: 5, cost: 10, savingsUSD: 0, inputTokens: 800, outputTokens: 400, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: {}, providers: {},
},
{
// Fully reconciled — must not grow a carried row.
date: '2026-05-03',
cost: 4, calls: 2, sessions: 1,
inputTokens: 600, outputTokens: 300, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0,
models: { 'Opus 4.7': { calls: 2, cost: 4, savingsUSD: 0, inputTokens: 600, outputTokens: 300, cacheReadTokens: 0, cacheWriteTokens: 0 } },
categories: {}, providers: {},
},
],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
const cache = await loadDailyCache()
const short = cache.days.find(d => d.date === '2026-05-02')!
const reconciled = cache.days.find(d => d.date === '2026-05-03')!
// The carried row picks up exactly the missing tokens, with no calls/cost.
const carried = short.models['Unknown (carried)']!
expect(carried).toBeDefined()
expect(carried.inputTokens).toBe(200)
expect(carried.outputTokens).toBe(100)
expect(carried.calls).toBe(0)
expect(carried.cost).toBe(0)
// Cost and calls are untouched; token sums now reconcile.
expect(short.cost).toBe(10)
expect(short.calls).toBe(5)
const sum = (k: 'inputTokens' | 'outputTokens') => Object.values(short.models).reduce((a, m) => a + m[k], 0)
expect(sum('inputTokens')).toBe(short.inputTokens)
expect(sum('outputTokens')).toBe(short.outputTokens)
// A fully reconciled day gets no carried row (idempotent).
expect(reconciled.models['Unknown (carried)']).toBeUndefined()
})
it('adopts a legacy file whose version matches the current one, once, without deleting it', async () => {
const saved = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'legacy-hash',
lastComputedDate: '2026-05-01',
days: [emptyDay('2026-05-01', 3.5, 9)],
}
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json')
await writeFile(legacy, JSON.stringify(saved), 'utf-8')
// First load: versioned file absent → adopt-copy from legacy.
const first = await loadDailyCache()
expect(first.days).toEqual(saved.days)
expect(first.savingsConfigHash).toBe('legacy-hash')
expect(existsSync(dailyCachePath())).toBe(true)
// Legacy file is NOT deleted.
expect(existsSync(legacy)).toBe(true)
// Adoption is one-time: mutate the legacy file, load again — the versioned
// file now wins and the stale legacy edit is never re-adopted.
await writeFile(legacy, JSON.stringify({ ...saved, days: [emptyDay('2000-01-01', 999)] }), 'utf-8')
const second = await loadDailyCache()
expect(second.days).toEqual(saved.days)
})
it('round-trips a valid cache through save and load', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-hash-1',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
complete: true,
watermarkTrusted: true,
}
await saveDailyCache(saved)
const loaded = await loadDailyCache()
expect(loaded).toEqual(saved)
})
})
describe('saveDailyCache', () => {
it('writes atomically so no temp file is left after a successful save', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-hash-1',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
await saveDailyCache(saved)
const { readdir } = await import('fs/promises')
const files = await readdir(TMP_CACHE_ROOT)
const tempLeftovers = files.filter(f => f.endsWith('.tmp'))
expect(tempLeftovers).toEqual([])
const finalFile = await readFile(dailyCachePath(), 'utf-8')
expect(JSON.parse(finalFile)).toEqual(saved)
})
})
describe('addNewDays', () => {
it('returns a new cache with the added days sorted ascending by date', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10')
expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'])
expect(updated.lastComputedDate).toBe('2026-04-10')
})
it('replaces existing days with incoming data (last write wins)', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-08',
days: [emptyDay('2026-04-08', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08')
const aprilEight = updated.days.find(d => d.date === '2026-04-08')!
expect(aprilEight.cost).toBe(99)
})
it('does not regress lastComputedDate if incoming newestDate is older', () => {
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-10', 5)],
}
const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05')
expect(updated.lastComputedDate).toBe('2026-04-10')
})
it('skips prune when newestDate is malformed (does not silently drop all days)', () => {
// Regression guard: a corrupt newestDate string used to produce a NaN
// cutoff, which made `d.date >= "Invalid Date"` always false and
// wiped every cached day on the next merge. The guard now leaves
// the entries untouched so the next valid run can prune normally.
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-08', 1), emptyDay('2026-04-09', 2), emptyDay('2026-04-10', 3)],
}
const updated = addNewDays(base, [], 'not-a-date')
expect(updated.days.map(d => d.date)).toEqual(['2026-04-08', '2026-04-09', '2026-04-10'])
})
it('still prunes when newestDate is valid', () => {
const old = '2010-01-01'
const recent = '2026-04-10'
const base: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: recent,
days: [emptyDay(old, 1), emptyDay(recent, 2)],
}
const updated = addNewDays(base, [], recent)
// 3650-day retention from 2026-04-10 puts the cutoff in 2016; 2010-01-01 must be gone.
expect(updated.days.find(d => d.date === old)).toBeUndefined()
expect(updated.days.find(d => d.date === recent)).toBeDefined()
})
})
describe('getDaysInRange', () => {
const cache: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-04-10',
days: [
emptyDay('2026-04-05', 1),
emptyDay('2026-04-06', 2),
emptyDay('2026-04-07', 3),
emptyDay('2026-04-08', 4),
emptyDay('2026-04-09', 5),
emptyDay('2026-04-10', 6),
],
}
it('returns inclusive start and end range', () => {
const days = getDaysInRange(cache, '2026-04-07', '2026-04-09')
expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09'])
})
it('returns empty when range is entirely outside cache', () => {
expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([])
expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([])
})
it('clips to available cache days when range extends beyond', () => {
const days = getDaysInRange(cache, '2026-04-09', '2026-04-20')
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
})
})
describe('ensureCacheHydrated', () => {
it('does not recompute yesterday after it has already been cached', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 5, 10)],
complete: true,
watermarkTrusted: true,
}
await saveDailyCache(saved)
let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [],
)
expect(parseCalls).toBe(0)
expect(hydrated).toEqual(saved)
})
it('drops a cached today/future entry so it is recomputed live, keeping yesterday cached', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))
// A "today" entry can only exist via a backward clock change or a stale
// cache; it must be purged so today is served live, not from a frozen entry.
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
lastComputedDate: '2026-06-12',
days: [emptyDay('2026-06-11', 5, 10), emptyDay('2026-06-12', 9, 20)],
complete: true,
}
await saveDailyCache(saved)
let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [],
)
expect(parseCalls).toBe(0)
expect(hydrated.days.map(d => d.date)).toEqual(['2026-06-11'])
expect(hydrated.lastComputedDate).toBe('2026-06-11')
})
})
// Codex discovery went structural in v16 (#873/#626), admitting rollouts from
// third-party frontends that v15 rollups never counted. Every historical day is
// served from this cache (usage-aggregator only recomputes today) and retention
// is ten years, so without a schema bump an upgrading user keeps the pre-fix
// numbers forever: the session COUNT moves because discovery reruns, while
// cost/calls stay frozen — a self-contradicting report that reads as "fixed".
describe('ensureCacheHydrated: schema version invalidation (#873)', () => {
it('re-derives a warm v15 cache instead of serving its pre-fix rollups', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
// A cache exactly as a pre-fix release left it: current schema at the time,
// finalized off a complete parse, watermark at yesterday, matching tz.
// Nothing but the version bump can invalidate it.
const v15 = {
version: 15,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 4.55, 1)],
complete: true,
watermarkTrusted: true,
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), JSON.stringify(v15), 'utf-8')
let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [emptyDay('2026-06-11', 18.2, 2)],
)
// The whole point: the window is re-parsed rather than served frozen.
expect(parseCalls).toBe(1)
// ...and the fresh derivation wins over the stale v15 day.
expect(hydrated.days.find(d => d.date === '2026-06-11')?.cost).toBe(18.2)
expect(hydrated.days.find(d => d.date === '2026-06-11')?.calls).toBe(2)
expect(hydrated.version).toBe(DAILY_CACHE_VERSION)
// The v15 file is never rewritten or deleted — old binaries still own it.
expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), 'utf-8')).version).toBe(15)
})
it('carries a v15 day forward when its sources can no longer re-derive it', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const v15 = {
version: 15,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-04-02', 7, 3), emptyDay('2026-06-11', 4.55, 1)],
complete: true,
watermarkTrusted: true,
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), JSON.stringify(v15), 'utf-8')
// The parse can only still see the recent day; April's sources are gone.
const hydrated = await ensureCacheHydrated(
async () => [],
() => [emptyDay('2026-06-11', 18.2, 2)],
)
// NEVER-LOSE (v14) still holds across this bump: the sourceless day keeps
// its old accounting rather than being dropped or zeroed.
expect(hydrated.days.find(d => d.date === '2026-04-02')?.cost).toBe(7)
expect(hydrated.days.find(d => d.date === '2026-06-11')?.cost).toBe(18.2)
})
})
describe('ensureCacheHydrated: Codex usage-record accounting migration', () => {
it.each([40, 42])('re-derives shrinking Codex slices from v%i and carries days with missing sources', async sourceVersion => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-27T12:00:00.000Z'))
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const model = 'GPT-5.5'
const codexDay = (date: string, calls: number, inputTokens: number): DailyEntry => {
const cost = calls * 0.5
const modelStats = {
calls,
cost,
savingsUSD: 0,
inputTokens,
outputTokens: calls * 100,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
const providerSlice = {
calls,
cost,
savingsUSD: 0,
sessions: 1,
inputTokens,
outputTokens: calls * 100,
cacheReadTokens: 0,
cacheWriteTokens: 0,
models: { [model]: modelStats },
categories: {},
}
return {
date,
cost,
savingsUSD: 0,
calls,
sessions: 1,
inputTokens,
outputTokens: calls * 100,
cacheReadTokens: 0,
cacheWriteTokens: 0,
editTurns: 0,
oneShotTurns: 0,
models: { [model]: modelStats },
categories: {},
providers: { codex: providerSlice },
}
}
const oldCodexDay = codexDay('2026-09-01', 2, 1_000)
const sourceGoneDay = codexDay('2026-08-01', 4, 2_000)
await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${sourceVersion}.json`), JSON.stringify({
version: sourceVersion,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-09-01',
days: [sourceGoneDay, oldCodexDay],
complete: true,
watermarkTrusted: true,
}), 'utf-8')
let parseCalls = 0
const freshCodexDay = codexDay('2026-09-01', 1, 700)
const hydrated = await ensureCacheHydrated(
async () => { parseCalls += 1; return [] },
() => [freshCodexDay],
)
expect(parseCalls).toBe(1)
expect(hydrated.version).toBe(DAILY_CACHE_VERSION)
expect(hydrated.days.find(day => day.date === '2026-09-01')?.providers['codex']?.calls).toBe(1)
expect(hydrated.days.find(day => day.date === '2026-08-01')).toMatchObject({
calls: 4,
carried: true,
providers: { codex: { calls: 4 } },
})
})
})
describe('ensureCacheHydrated: Claude queued prompt categories', () => {
it.each([39, 43])('re-derives a settled v%i day when turns are reclassified but calls stay equal', async sourceVersion => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-27T12:00:00.000Z'))
const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const date = '2026-09-01'
const category = { turns: 1, cost: 1, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
const oldDay = emptyDay(date, 1, 1)
oldDay.categories = { exploration: category }
oldDay.providers = { claude: { calls: 1, cost: 1, savingsUSD: 0, categories: oldDay.categories } }
const freshDay = emptyDay(date, 1, 1)
freshDay.categories = { feature: category }
freshDay.providers = { claude: { calls: 1, cost: 1, savingsUSD: 0, categories: freshDay.categories } }
await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${sourceVersion}.json`), JSON.stringify({
version: sourceVersion,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: date,
days: [oldDay],
complete: true,
watermarkTrusted: true,
}), 'utf-8')
let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => { parseCalls += 1; return [] },
() => [freshDay],
)
expect(parseCalls).toBe(1)
expect(hydrated.version).toBe(DAILY_CACHE_VERSION)
expect(hydrated.days.find(day => day.date === date)?.providers['claude']?.categories)
.toEqual({ feature: category })
})
})
describe('withDailyCacheLock', () => {
it('serializes concurrent operations', async () => {
const sequence: string[] = []
const op = async (tag: string): Promise<void> => {
await withDailyCacheLock(async () => {
sequence.push(`start-${tag}`)
await new Promise(r => setTimeout(r, 20))
sequence.push(`end-${tag}`)
})
}
await Promise.all([op('a'), op('b'), op('c')])
for (let i = 0; i < sequence.length; i += 2) {
expect(sequence[i]?.startsWith('start-')).toBe(true)
expect(sequence[i + 1]?.startsWith('end-')).toBe(true)
expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4))
}
})
})
describe('ensureCacheHydrated: savings config invalidation', () => {
it('re-derives on savingsConfigHash change but CARRIES days the parse cannot re-derive', async () => {
// Seed a cache with a day OLDER than yesterday so the hydration window
// (which keeps `d.date < yesterdayStr`) actually retains it.
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}`
const seeded: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
complete: true,
}
await saveDailyCache(seeded)
// The re-derive parse finds NOTHING (session files already deleted). The
// day must survive as carried — this exact path used to wipe it.
const parseSessions = async (): Promise<ProjectSummary[]> => []
const aggregateDays = (): DailyEntry[] => []
const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-B')
expect(rehydrated.savingsConfigHash).toBe('cfg-B')
expect(rehydrated.days).toHaveLength(1)
expect(rehydrated.days[0]).toMatchObject({ date: twoDaysAgoStr, cost: 1.5, calls: 3, carried: true })
expect(rehydrated.complete).toBe(true)
// Same hash → cached days survive untouched (no carried marker).
const seeded2: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: 'cfg-C',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
complete: true,
}
await saveDailyCache(seeded2)
const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-C')
expect(preserved.days).toHaveLength(1)
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
expect(preserved.days[0]!.carried).toBeUndefined()
})
})
describe('ensureCacheHydrated: timezone invalidation', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
const twoDaysAgoStr = `${twoDaysAgo.getFullYear()}-${String(twoDaysAgo.getMonth() + 1).padStart(2, '0')}-${String(twoDaysAgo.getDate()).padStart(2, '0')}`
const parseSessions = async (): Promise<ProjectSummary[]> => []
const aggregateDays = (): DailyEntry[] => []
it('re-derives on timezone change but keeps days whose sources are gone', async () => {
// Days are bucketed by local midnight, so a cache tagged under a different
// timezone re-derives everything. Days that can no longer be re-derived stay
// (old-tz bucketing beats a silent zero). 'Test/OtherZone' can never equal a
// real IANA zone.
const seeded: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
tzKey: 'Test/OtherZone',
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
complete: true,
}
await saveDailyCache(seeded)
const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, '')
expect(rehydrated.tzKey).toBe(currentTzKey())
expect(rehydrated.days).toHaveLength(1)
expect(rehydrated.days[0]).toMatchObject({ date: twoDaysAgoStr, cost: 1.5, carried: true })
})
it('keeps cached days when the tzKey matches the current timezone', async () => {
const seeded: DailyCache = {
version: DAILY_CACHE_VERSION,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: twoDaysAgoStr,
days: [emptyDay(twoDaysAgoStr, 1.5, 3)],
complete: true,
}
await saveDailyCache(seeded)
const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, '')
expect(preserved.days).toHaveLength(1)
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
})
})