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 => { 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 => [] 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 => [] 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) }) })