codeburn/tests/date-range-filter.test.ts
Resham Joshi daa673449c
Some checks are pending
CI / semgrep (push) Waiting to run
Menubar and CLI hardening from multi-agent audit (#257)
Two passes of validators across CLI accuracy, dashboard UX, menubar Swift,
performance, security, and end-to-end smoke tests on real session data.

Data-correctness fixes:

- parseLocalDate rejects month/day overflow. JS Date silently rolled
  Feb 31 to Mar 3, so --from 2026-02-31 --to 2026-03-15 quietly dropped
  sessions on Feb 28 - Mar 2. Now throws "Invalid date" with a clear
  reason. Leap-day case covered (2024-02-29 valid, 2025-02-29 rejected).

- CSV/JSON exports use the active currency's natural decimal places. The
  previous round2 helper produced ¥412.37 in CSV while the dashboard
  rendered ¥412 — finance teams comparing the two surfaces saw a
  discrepancy. New roundForActiveCurrency consults Intl.NumberFormat for
  the right precision (0 for JPY/KRW/CLP, 2 for USD/EUR, etc).

- Copilot toolRequests is Array.isArray-guarded in both modern and legacy
  event branches. Previously a corrupt session with toolRequests=null or
  a string aborted the whole file's parse loop and silently dropped every
  legitimate call after it.

- Codex token_count dedup uses a null sentinel for prevCumulativeTotal so
  the first event is never confused with a duplicate. Sessions that emit
  only last_token_usage (no total_token_usage) report cumulativeTotal=0
  on every event; with the previous 0-initialized prev, the first event
  matched the dedup guard and was dropped.

- LiteLLM pricing values are clamped to [0, 1] per token via safePerTokenRate.
  Defense in depth against a tampered upstream JSON shipping negative or
  absurdly large per-token costs that would otherwise propagate into all
  cost totals.

Performance:

- Cursor SQLite parse no longer pegs at minutes on multi-GB DBs. Two
  changes: per-conversation user-message buffer uses an index pointer
  instead of Array.shift() (which was O(n) per call); and a real ROWID
  cutoff via subquery limits the scan to the most recent 250k bubbles
  with a stderr warning so power users get a partial report rather than
  a stalled CLI.

- Spawned codeburn CLI subprocesses are terminated when the calling Task
  is cancelled. Without this, rapid period/provider tab clicks in the
  menubar cancelled the Task but left the subprocess running to
  completion, piling up zombie processes.

UX:

- Dashboard period switch flips to loading and clears projects
  synchronously before reloadData runs, eliminating the frame where the
  new period label rendered over the old period's projects.

- Optimize findings tab paginates 3-at-a-time with j/k scroll. With 4
  new detectors plus 7 originals, 8-10 findings * 6 lines was scrolling
  the StatusBar off the alt buffer top.

- Custom --from/--to ranges hide the period tab strip and disable the
  1-5 / arrow keys so a stray period press no longer abandons the user's
  explicit range. A "Custom range: X to Y" banner replaces the tab strip.

- OpenCode storage-format warning is per-table-set, rate-limited to once
  per process, and points the user at OpenCode's migration step or the
  issue tracker. The previous all-or-nothing check fired the generic
  "format not recognized" string for any schema mismatch.

Menubar / OAuth:

- Both Claude and Codex bootstrap (Reconnect button) now honour the
  usageBlockedUntil 429 backoff that refreshIfBootstrapped respects.
  Spamming Reconnect during sustained rate-limit windows previously
  hammered the upstream endpoint on every click.

- Codex Retry-After HTTP header is parsed (delta-seconds plus IMF-fixdate
  fallback) so we don't over-back-off when ChatGPT tells us a shorter
  window than our 5-minute floor.

- Both credential cache files are written via SafeFile.write
  (O_CREAT | O_EXCL | O_NOFOLLOW with explicit 0600) so there is no race
  window where the temp file briefly exists at default umask, and a
  symlink at the destination cannot redirect the write. Reads now route
  through SafeFile.read with a 64 KiB cap, closing the symlink-follow gap
  on Data(contentsOf:).

CI signal:

- TypeScript strict typecheck (tsc --noEmit) is now zero errors. The
  six errors in src/providers/copilot.ts came from a discriminated-union
  catch-all branch whose `data: Record<string, unknown>` shape TS picked
  over the specific event branches when narrowing on `type`. Removed the
  catch-all; runtime falls through unknown event types via the existing
  if/else chain.

Tests added: 16 new (now 555 total)
- date-range-filter: month/day/year overflow rejection, leap-day correctness
- currency-rounding: convertCost no-rounding contract, roundForActiveCurrency
  for USD/JPY/KRW/EUR
- providers/copilot: malformed toolRequests does not abort the parse
- providers/cursor-bubble-dedup: re-parse after token mutation does not
  double-count, single parse yields one call per bubble
- providers/codex: first event with cumulativeTotal=0 not dropped,
  consecutive zero-cumulative duplicates still deduped
2026-05-06 22:15:11 -07:00

89 lines
3.8 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { formatDateRangeLabel, parseDateRangeFlags } from '../src/cli-date.js'
describe('parseDateRangeFlags', () => {
it('returns null when neither flag is provided', () => {
expect(parseDateRangeFlags(undefined, undefined)).toBeNull()
})
it('parses a symmetric range in local time', () => {
const range = parseDateRangeFlags('2026-04-07', '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getFullYear()).toBe(2026)
expect(range!.start.getMonth()).toBe(3)
expect(range!.start.getDate()).toBe(7)
expect(range!.start.getHours()).toBe(0)
expect(range!.end.getDate()).toBe(10)
expect(range!.end.getHours()).toBe(23)
expect(range!.end.getMinutes()).toBe(59)
expect(range!.end.getSeconds()).toBe(59)
})
it('accepts --from alone (open-ended to today 23:59:59)', () => {
const range = parseDateRangeFlags('2026-04-01', undefined)
expect(range).not.toBeNull()
expect(range!.start.getDate()).toBe(1)
expect(range!.end.getHours()).toBe(23)
})
it('accepts --to alone with a 6-month default start', () => {
// Previously the missing --from defaulted to epoch (1970), opening a
// 55-year scan window that was almost never what the user meant. The
// default is now 6 months back from now, matching the dashboard's
// "6 Months" period boundary.
const range = parseDateRangeFlags(undefined, '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getTime()).toBeGreaterThan(new Date(0).getTime())
const sixMonthsMs = 6 * 31 * 24 * 60 * 60 * 1000
const ageMs = Date.now() - range!.start.getTime()
expect(ageMs).toBeLessThanOrEqual(sixMonthsMs + 1000)
expect(ageMs).toBeGreaterThanOrEqual(sixMonthsMs - 1000)
expect(range!.end.getDate()).toBe(10)
})
it('throws when --from > --to', () => {
expect(() => parseDateRangeFlags('2026-04-10', '2026-04-07'))
.toThrow('--from must not be after --to')
})
it('throws on a non-ISO string', () => {
expect(() => parseDateRangeFlags('April 7', undefined))
.toThrow('Invalid date format')
})
it('throws on wrong digit count', () => {
expect(() => parseDateRangeFlags('26-4-7', undefined))
.toThrow('Invalid date format')
})
it('rejects month/day overflow instead of silently rolling forward', () => {
// Without overflow validation, JS Date silently turns Feb 31 into Mar 3
// and 13/32 into 02/01 of the following year. That made `--from
// 2026-02-31 --to 2026-03-15` quietly drop sessions on Feb 28 - Mar 2.
expect(() => parseDateRangeFlags('2026-02-31', '2026-03-15'))
.toThrow('Invalid date "2026-02-31"')
expect(() => parseDateRangeFlags('2026-13-01', undefined))
.toThrow('Invalid date "2026-13-01"')
expect(() => parseDateRangeFlags('2026-04-31', undefined))
.toThrow('Invalid date "2026-04-31"')
expect(() => parseDateRangeFlags(undefined, '2026-02-30'))
.toThrow('Invalid date "2026-02-30"')
// Leap-day check: 2024 is a leap year, 2025 is not.
expect(parseDateRangeFlags('2024-02-29', '2024-03-01')).not.toBeNull()
expect(() => parseDateRangeFlags('2025-02-29', undefined))
.toThrow('Invalid date "2025-02-29"')
})
it('same day is valid (start midnight, end 23:59:59)', () => {
const range = parseDateRangeFlags('2026-04-10', '2026-04-10')
expect(range).not.toBeNull()
expect(range!.start.getDate()).toBe(10)
expect(range!.end.getDate()).toBe(10)
})
it('formats custom range labels consistently', () => {
expect(formatDateRangeLabel('2026-04-07', '2026-04-10')).toBe('2026-04-07 to 2026-04-10')
expect(formatDateRangeLabel(undefined, '2026-04-10')).toBe('all to 2026-04-10')
expect(formatDateRangeLabel('2026-04-07', undefined)).toBe('2026-04-07 to today')
})
})