# Current test inventory — what exists and what it asserts

Status: complete inventory as of 2026-07-07. Source of truth: the six
files in `/srv/apps/work/tests/*.test.ts` (read directly), cross-checked
against `docs/tests-catalog.md` and the extraction in
`scratchpad/extract/docs-features-d.md`. Where the catalog disagrees
with the test files, **the test files win** — every disagreement is
listed in "Catalog staleness" below.

This document is the input to the (not-yet-written) `06-test-plan.md`.
The R1 success criterion in `00-vision-and-scope.md` is "all ported
tests green (the current 116 + ports of the RPC/permission semantics)"
— the count **116** is verified here (see totals table).

## How the current suite runs

- Runner: **vitest** (`"test": "vitest run"`, `"test:watch": "vitest"`
  in `package.json`; vitest `^4.1.7`, coverage via
  `@vitest/coverage-v8`).
- All 116 tests are **pure unit tests of `lib/*` helpers** — no
  database, no network, no Supabase, no React rendering. Imports use
  the `@/lib/...` path alias.
- Everything DB/auth-dependent is deliberately untested (the 🟡
  catalog-only list at the bottom).
- One suite (`filters.test.ts`) pins the clock with
  `vi.useFakeTimers()` + `vi.setSystemTime(new Date('2026-05-15T12:00:00Z'))`
  (reset in `afterEach`); FLT-004 re-pins to `2026-01-15T12:00:00Z`
  inside the test. Any port must reproduce clock-pinning.

## Totals and ports-to summary

| File | Suite prefix(es) | # tests | Module under test | Ports to |
|---|---|---:|---|---|
| `tests/clockify.test.ts` | CLK | 43 | `lib/clockify.ts` | Python (pytest) — import pipeline; **except** CLK-019/020 → dropped |
| `tests/cc-expenses.test.ts` | CCE | 29 | `lib/cc-expenses.ts` | Python (pytest) — CC-expense paste pipeline + exports |
| `tests/toggl.test.ts` | TGL | 14 | `lib/toggl.ts` | dropped-with-feature — **except** TGL-009/011/012 (blocked predicate) → Python |
| `tests/invoices.test.ts` | INV | 11 | `lib/invoices.ts` | Python (paste block); Vitest (date default) |
| `tests/filters.test.ts` | FLT | 10 | `lib/filters.ts` | Vitest — filter serialization (with a pytest twin at the API boundary) |
| `tests/format.test.ts` | FMT | 9 | `lib/format.ts` | Vitest — display formatting |
| **Total** | | **116** | | |

Ports-to legend (from `04-architecture.md` "Testing"):
- **Python port** — pytest under `tests/` mirroring `services/` 1:1;
  the architecture doc explicitly says "import pipeline ports with the
  CLK-* fixtures".
- **Vitest port** — SPA-side logic ("filter serialization, table
  interaction hooks") plus pure display helpers.
- **dropped-with-feature** — the Toggl export is a confirmed phase-out
  (`00-vision-and-scope.md`: rian 2026-07-06, "I no longer have any
  need for toggl"); `rate_proportion` / converted-hours machinery is
  never implemented in the rebuild (ADR #034).

---

## File-by-file inventory

Format per test: **ID** — Given / When / Then in one line → *ports to*.

### 1. `tests/clockify.test.ts` (43 tests) — `lib/clockify.ts`

Functions under test: `parseDateOnly(s, fmt)`, `parseTimeOnly(s, fmt?)`,
`detectDateFormat(samples)`, `formatDateRangeLabel(range|null)`,
`parseDurationSeconds(s)`, `parseClockifyCsv(csv, dateFmt, timeFmt)`,
`splitClientProject(s)`, `applyConversion(entry, TeamLookup)`,
`computeBilloutAmount(seconds|null, rate|null)`.

#### `parseDateOnly` (6)
- **CLK-001** — Given `'2026-05-20'` and `'2026-1-5'` with fmt `'YYYY-MM-DD'` / when parsed / then returns zero-padded `'2026-05-20'`, `'2026-01-05'`. → *Python*
- **CLK-002** — Given `'5/20/2026'` with fmt `'MM/DD/YYYY'` / then `'2026-05-20'` (US order). → *Python*
- **CLK-003** — Given `'2/12/2025'` / then `'DD/MM/YYYY'` yields `'2025-12-02'` while `'MM/DD/YYYY'` yields `'2025-02-12'` — same input, format-dependent result. → *Python*
- **CLK-004** — Given garbage (`'not a date'`, `''`) / then `null`. → *Python*
- **CLK-005** — Given out-of-range month/day (`'2026-13-01'`, `'2026-02-32'`, `'13/01/2026'` as MM/DD) / then `null`. → *Python*
- **CLK-006** — Given 2-digit years (`'5/20/26'`, `'20/5/26'`) / then interpreted as 20xx (`'2026-05-20'`). → *Python*

#### `parseTimeOnly` (5) — NOTE: the `fmt` arg is now **ignored**; AM/PM presence auto-detects 12h vs 24h per string
- **CLK-007** — Given 24-hour strings (`'13:45:30'`, `'0:00:00'`, `'9:05'`) / then normalized `'HH:MM:SS'` (missing seconds → `:00`). → *Python*
- **CLK-008** — Given 12-hour strings with AM/PM (case-insensitive) / then converted to 24h (`'1:45:30 PM'` → `'13:45:30'`). → *Python*
- **CLK-009** — Given `12:xx AM` / then hour `00`; given `12:xx PM` / then hour `12`. → *Python*
- **CLK-010** — Given `'25:00:00'`, `''`, or `'13:45:30 PM'` (hour 13 with PM) / then `null`. → *Python*
- **CLK-026** — Given a 24h string / then result identical for fmt `'12h'`, `'24h'`, or omitted (fmt arg ignored). → *Python*
- **CLK-027** — Given a 12h string with PM / then result identical regardless of fmt arg. → *Python*
- **CLK-028** — Given `'1:45:30'` with no AM/PM suffix / then treated as 24h → `'01:45:30'`, not `null` (regression: pre-auto-detect this failed under the 12h parser). → *Python*

(That block is 7 `it`s; CLK-026/027/028 were added 2026-06-10 with the auto-detect change.)

#### `detectDateFormat` (9)
- **CLK-029** — Given samples with a 4-digit-year prefix / then `'YYYY-MM-DD'`. → *Python*
- **CLK-030** — Given slash dates where ANY first slot > 12 (`'25/05/2026'`) / then `'DD/MM/YYYY'`. → *Python*
- **CLK-031** — Given slash dates where ANY second slot > 12 and no DD signal (`'5/20/2026'`) / then `'MM/DD/YYYY'`. → *Python*
- **CLK-032** — Given `[]` or all-gibberish samples / then `null`. → *Python*
- **CLK-032b** — Given an ambiguous week `01/06..08/06/2026` (all slots ≤ 12) / then **tightest-span heuristic** picks `'DD/MM/YYYY'` (one month vs eight). Regression for the 2026-06-09 incident where the fallback scattered a week across 8 months. → *Python*
- **CLK-032c** — Given the inverse US export `06/01..06/08/2026` / then `'MM/DD/YYYY'` (June 1–8, not Jan–Aug). → *Python*
- **CLK-032d** — Given `['1/2/2026','2/1/2026']` where both interpretations span equal months / then `null` (don't guess). → *Python*
- **CLK-033** — Given blanks/whitespace/gibberish mixed with one real `'25/05/2026'` / then unparseable entries ignored → `'DD/MM/YYYY'`. → *Python*
- **CLK-034** — Given mixed ISO + numeric rows / then lands on one of the two valid answers without crashing (first non-null signal wins; test accepts either). → *Python*

#### `formatDateRangeLabel` (5)
- **CLK-040** — Given `{min:'2026-06-02', max:'2026-06-08'}` / then `'Jun 2 – Jun 8, 2026'` (same-month). → *Python* (import-preview label; move to Vitest if the rebuilt SPA renders the label client-side)
- **CLK-041** — Given cross-month same-year / then `'May 28 – Jun 3, 2026'`. → *Python* (same note)
- **CLK-042** — Given cross-year / then both years: `'Dec 28, 2025 – Jan 3, 2026'`. → *Python* (same note)
- **CLK-043** — Given min == max / then single date `'Jun 2, 2026'`. → *Python* (same note)
- **CLK-044** — Given `null` / then `null`. → *Python* (same note)

#### `parseDurationSeconds` (4)
- **CLK-011** — Given `'1:00:00'`, `'0:01:30'`, `'5:20:36'` (HH:MM:SS) / then exact seconds. → *Python*
- **CLK-012** — Given `'1:30'`, `'0:30'` (HH:MM) / then minutes×60. → *Python*
- **CLK-013** — Given decimal hours `'1.5'`, `'0.25'`, `'5.34'` / then `Math.round(h × 3600)`. → *Python*
- **(no ID)** — Given `''` or `'forever'` / then `null`. **Un-ID'd test** — assign an ID (suggest CLK-013b) when porting. → *Python*

#### `parseClockifyCsv` (6 in file; fixture = the 17-column Clockify header `Project,Client,Description,Task,User,Group,Email,Tags,Billable,Start Date,Start Time,End Date,End Time,Duration (h),Duration (decimal),Billable Rate (USD),Billable Amount (USD)`)
- **CLK-014** — Given a header + 2 rows / then 2 entries, 0 errors; asserts `start_at`/`end_at` as naive `'YYYY-MM-DD HH:MM:SS'`, `duration_seconds`, `billable:true` from `'Yes'`; **CSV "Client" → `operator`; CSV "Project" is split** (`project` set, `client` null when no separator). → *Python*
- **CLK-021** — Given `splitClientProject` inputs / then splits on the FIRST `' : '` (space-colon-space, strict): `'Foo : Bar : Baz'` → client `'Foo'`, project `'Bar : Baz'`; `'NoColon'` and `'Foo:Bar'` (no spaces) → client `null`, project unchanged. → *Python*
- **CLK-022** — Given rows with `Project: 'DaxTech : Website'` and `'PromptVictoria'` / then csv-level split into `client`/`project`; colon-less → client `null`. → *Python*
- **CLK-015** — Given a CSV missing required headers / then 0 entries and `errors[0].message` matches `/missing required columns/i`. → *Python*
- **CLK-016** — Given rows with duplicate emails and scattered dates / then `uniqueEmails` deduped (`['a@x.com','b@x.com']`) and `dateRange` = `{min, max}` of parsed dates. → *Python*
- **CLK-017** — Given `'Mixed.Case@EXAMPLE.com'` / then `source_user_email` lowercased. → *Python*

#### `applyConversion` (4) — TeamLookup = `Map<email, {id, display_name, rate_proportion, consolidate_as, cost_rate_usd, billout_rate_usd}>`
- **CLK-018** — Given an email not in the lookup / then ALL null: `{team_member_id, converted_user, converted_duration_seconds, cost_rate_usd, billout_rate_usd}` all `null`. → *Python, rewritten*: the surviving semantic is "unknown worker email leaves the entry unresolved" (engagement resolution in the new model); the converted_* fields themselves are dropped.
- **CLK-019** — Given a member with `consolidate_as:'Tingang'` vs one without / then `converted_user` = `consolidate_as` if set, else `display_name`. → **dropped-with-feature** (consolidate-as is Toggl-export plumbing).
- **CLK-020** — Given 7200s with `rate_proportion` 0.5 vs 1.0 / then `converted_duration_seconds` = duration × proportion (3600 / 7200). → **dropped-with-feature** (ADR #034: no proportions, ever — judgment call #12).
- **CLK-021 (DUPLICATE ID** — second use, distinct test) — Given a lookup member with `billout_rate_usd: 25` / then `applyConversion` surfaces `billout_rate_usd` in its result. → *Python, rewritten*: billout rate comes from engagement compensation terms; the "rate is stamped from the member's current rate at import" semantic survives.

#### `computeBilloutAmount` (2)
- **CLK-022 (DUPLICATE ID** — second use) — Given `null` seconds or `null` rate / then `null`. → *Python* (money-stamping math; invariant §5 of `02-domain-model.md`)
- **CLK-023** — Given (3600, 25) → 25; (1800, 25) → 12.5; (60, 25) ≈ 0.42 / then amount = `converted_seconds / 3600 × rate`, rounded to 2dp. → *Python* (same; in the rebuild the input is source seconds, not converted seconds)

### 2. `tests/cc-expenses.test.ts` (29 tests) — `lib/cc-expenses.ts`

Functions: `parseExpenseDate(s)`, `parseCurrency(s)`, `parseCcLine(line)`,
`parseCcPaste(text)`, `findHighlightMatch(desc, keywords)`,
`findAutoRuleMatch(desc, rules)`, `formatCcPasteBlock(lines)`.
All parsing/matching runs in the server action (`appendCcLines` in
`app/(app)/tools/cc-expenses/actions.ts`) → all port to **Python**.

#### `parseExpenseDate` (5)
- **CCE-D-001** — `'May 20, 2026'` → `'2026-05-20'` (long format). → *Python*
- **CCE-D-002** — ISO `'2026-05-20'` passes through unchanged. → *Python*
- **CCE-D-003** — `'may 20 2026'` / `'MAY 20 2026'` (mixed case, no comma) still parse. → *Python*
- **CCE-D-004** — `'nonsense'`, `''`, `'Foobar 20, 2026'` → `null`. → *Python*
- **CCE-D-005** — `'Jan 5, 2026'` → `'2026-01-05'` (day zero-padded). → *Python*

#### `parseCurrency` (3)
- **CCE-C-001** — Strips `$` and commas: `'$7.15'`→7.15, `'$3,856.56'`→3856.56, `'1234'`→1234. → *Python*
- **CCE-C-002** — `''`, whitespace, `'abc'` → `null`. → *Python*
- **CCE-C-003** — Rounds to 2dp: `'$0.999'`→1, `'$10.234'`→10.23. → *Python*

#### `parseCcLine` (5) — input is one tab-separated bank-paste line: date⇥description⇥debit⇥credit⇥balance
- **CCE-L-001** — Standard 5-column line (trailing tab OK) / then `{expense_date, description, amount_usd (from debit), balance_usd}` all parsed. → *Python*
- **CCE-L-002** — Debit blank, credit `'$50.00'` / then `amount_usd` = 50 (credit column used). → *Python*
- **CCE-L-003** — Empty/whitespace-only line → `null`. → *Python*
- **CCE-L-004** — The verbatim input is preserved as `raw_line` (audit). → *Python*
- **CCE-L-005** — Partial parse: unparseable date → `expense_date:null` but description + amount still extracted. → *Python*

#### `parseCcPaste` (2)
- **CCE-P-001** — Splits on newlines, skips blank/whitespace lines, preserves order. → *Python*
- **CCE-P-002** — Handles CRLF (`\r\n`) line endings. → *Python*

#### `findHighlightMatch` (5) — keywords = `[{keyword: string}]`
- **CCE-H-001** — Substring contains → returns the matched keyword string (`'UBER *EATS'` → `'UBER'`). → *Python*
- **CCE-H-002** — Case-insensitive. → *Python*
- **CCE-H-003** — No match / `null` desc / `''` desc → `null`. → *Python*
- **CCE-H-004** — Multi-match → first-in-list wins (list order semantics; NOTE flag #6 in the extraction — differs from auto-rules' created_at-DESC ordering, normalize in rebuild). → *Python*
- **CCE-H-005** — An entry with `keyword: ''` is skipped (doesn't match everything). → *Python*

#### `findAutoRuleMatch` (5) — rules = `[{id, keyword, project_id, assignment_description, category}]`
- **CCE-R-001** — Match returns the whole rule object (all fields, for pre-fill). → *Python*
- **CCE-R-002** — Case-insensitive substring contains. → *Python*
- **CCE-R-003** — No matching rule → `null`. → *Python*
- **CCE-R-004** — First-match wins; **caller controls order** (call sites pass created_at DESC — newest rule wins). → *Python*
- **CCE-R-005** — `null` / `''` description → `null`. → *Python*

#### `formatCcPasteBlock` (4) — 7-column tab-separated export, one line per assigned expense
- **CCE-X-001** — `[]` → `''`. → *Python*
- **CCE-X-002** — Column shape: `['{client} : {project}', assignment_description, amount 2dp, expense_date, '', 'Bowden Works', category]`. → *Python*
- **CCE-X-003** — Amount always 2dp, never a `$`. → *Python*
- **CCE-X-004** — Multiple lines joined with `\n`. → *Python*

### 3. `tests/toggl.test.ts` (14 tests) — `lib/toggl.ts`

The Toggl export (`/transfer`, `buildTogglCsv`, `formatDurationSeconds`,
`splitNaiveTimestamp`, `TOGGL_COLUMNS`, `ADI_EMAIL`) is a **confirmed
phase-out** — every test on those is dropped-with-feature.
**Exception:** `blockerReason` is not Toggl-only — it is imported by
`entries/entries-table.tsx` (blocked-status display) and mirrored as a
SQL predicate in `entries/actions.ts` (source comment: "Update all
three together"), and it gates invoice eligibility. Its 3 tests port
to Python as the blocked-entry predicate.

- **TGL-001** — `formatDurationSeconds(0)` → `'00:00:00'`. → **dropped-with-feature**
- **TGL-002** — 3661→`'01:01:01'`, 7200→`'02:00:00'`, 90→`'00:01:30'`. → **dropped-with-feature**
- **TGL-003** — `splitNaiveTimestamp` accepts both `'T'` and space separators → `{date, time}`. → **dropped-with-feature** (only used inside `buildTogglCsv`)
- **TGL-004** — `buildTogglCsv([entry])` emits NO header row by default (raw data for the invoicing sheet; first cell is the member value, not `'Member'`). → **dropped-with-feature**
- **TGL-005** — Source employee email goes in the **Tags** column (last), converted user in the Member column (first). → **dropped-with-feature**
- **TGL-006** — Email column (2nd) is always `ADI_EMAIL` regardless of source. → **dropped-with-feature**
- **TGL-015** — `buildTogglCsv(entries, {header:true})` re-enables the header row and it equals `TOGGL_COLUMNS` (14 columns: Member, Email, Client, Project, Project end, Description, Teams, Start date, Stop date, Project start, Start time, Stop time, Duration, Tags). → **dropped-with-feature**
- **TGL-016** — `{teams:'2026-04'}` option populates column G (index 6, "Teams"). → **dropped-with-feature**
- **TGL-017** — Teams defaults to `'-'` placeholder when omitted or whitespace-only. → **dropped-with-feature**
- **TGL-009** — `blockerReason({...eligible, team_member_id:null})` matches `/team member/i` (unknown email). → *Python* (blocked predicate)
- **TGL-011** — Nulling any required field flags it: `operator`, `client`, `project`, `description`, `end_at`, `source_user_email` (fixture also requires `start_at`; converted_* fields intentionally NOT required post-invoice-migration). → *Python* (blocked predicate)
- **TGL-012** — A fully-resolved entry → `null` (not blocked). → *Python* (blocked predicate)
- **TGL-013** — Toggl CSV recombination: Project column = `'{client} : {project}'`, Client column = `operator`. → **dropped-with-feature**
- **TGL-014** — Client `null` → Project column is just the project name (no separator). → **dropped-with-feature**

### 4. `tests/invoices.test.ts` (11 tests) — `lib/invoices.ts`

- **INV-D-001** — `defaultInvoiceDate(new Date(2026,4,15))` → `'2026-04-30'` (last day of previous month). → *Vitest* (form default pre-fill; add a pytest twin if the create-invoice API computes the default server-side)
- **INV-D-002** — On the 1st (Jun 1) → `'2026-05-31'` (end of the month that just finished). → *Vitest* (same)
- **INV-D-003** — January rolls back to previous December (`Jan 10, 2026` → `'2025-12-31'`). → *Vitest* (same)
- **INV-D-004** — Leap-year February: `Mar 5, 2024` → `'2024-02-29'`; `Mar 5, 2026` → `'2026-02-28'`. → *Vitest* (same)
- **INV-P-001** — `formatPasteBlock([], date)` → `''`. → *Python* (paste-block export carries over unchanged per judgment call #15; server-generated text)
- **INV-P-002** — One tab-separated line per breakdown row, fixed 7 columns: `['{client} : {project}', 'Bowden Works Team', amount 2dp, invoice_date, '', 'Bowden Works', 'Labour']`. → *Python*
- **INV-P-003** — Amount 2dp, no `$`. → *Python*
- **INV-P-004** — Multiple rows joined with `\n`; row order is the caller's (block does not sort). → *Python*
- **INV-P-005** — `client:null` / `project:null` → `'(no client) : (no project)'`. → *Python*
- **INV-P-006** — Separator is `' : '` colon-with-spaces (same convention as the Clockify/Toggl split). → *Python*
- **INV-P-007** — 5th column (between date and `'Bowden Works'`) is empty string. → *Python*

Breakdown row shape used by fixtures: `{client, project, row_count,
source_seconds, cost_usd, billout_amount_usd}` (only client, project,
billout_amount_usd affect the block).

### 5. `tests/filters.test.ts` (10 tests) — `lib/filters.ts`

Function: `readFiltersFromSearchParams(searchParams, defaults?)` →
`{startBound, endBound, status, batch, q, imported_by, ...}`. Bounds
are naive `'YYYY-MM-DD HH:MM:SS'` strings; ranges are half-open
`[start, end)`. Clock pinned to 2026-05-15T12:00Z.

- **FLT-001** — No `date` param → this-month default: `'2026-05-01 00:00:00'` / `'2026-06-01 00:00:00'`. → *Vitest*
- **FLT-002** — `date=all` → both bounds `null`. → *Vitest*
- **FLT-003** — `date=this-month` → [first-of-this-month, first-of-next-month). → *Vitest*
- **FLT-004** — `date=last-month` in January → `'2025-12-01'`..`'2026-01-01'` (year rollover). → *Vitest*
- **FLT-005** — `date=custom&start=2026-04-10&end=2026-04-15` → endBound `'2026-04-16 00:00:00'` (**inclusive-end-day**: end + 1 day). → *Vitest*
- **FLT-006** — `status` passes through (`transferred`, `blocked`), defaults to `'all'` (server applies the actual blocker filter). → *Vitest* (note: `transferred` status value dies with the Toggl phase-out; `blocked`/`pending`/`all` survive)
- **FLT-007** — `batch=all` → `null`; any other string preserved. → *Vitest*
- **FLT-008** — Empty/whitespace `q` → `null`; non-empty preserved. → *Vitest*
- **FLT-009** — `imported_by` passes through as string; empty/absent → `null`. → *Vitest*
- **FLT-010** — A `defaults` argument (`{date:'all', status:'pending'}`) overrides built-in defaults when the URL has no params (this is how `/entries` gets its `all`+`pending` default). → *Vitest*

Rebuild note: in the SPA+FastAPI split, URL→filter-state parsing stays
Vitest, but the **date-window computation and half-open semantics must
also be pinned by pytest** at the API layer, since the API now owns the
query (04-architecture: filter serialization is named Vitest turf; the
SQL predicate equivalence of `status=blocked` is ENT-016's territory).

### 6. `tests/format.test.ts` (9 tests) — `lib/format.ts`

All null/undefined inputs render the em-dash `'—'`.

- **FMT-001** — `fmtHoursDecimal`: 3600→`'1.00'`, 5400→`'1.50'`, null→`'—'`. → *Vitest*
- **FMT-002** — `fmtHoursMinutes`: 5400→`'1:30'`, 3600→`'1:00'`, 0→`'0:00'`. → *Vitest*
- **FMT-003** — `fmtHoursMinutes` rounds to nearest minute: 5430→`'1:31'`, 5429→`'1:30'`. → *Vitest*
- **FMT-004** — `fmtHoursMinutes(null|undefined)` → `'—'`. → *Vitest*
- **FMT-005** — `fmtUsd`: 1593.89→`'$1,593.89'` (thousands separator), 0→`'$0.00'`, null→`'—'`. → *Vitest*
- **FMT-006** — `fmtMoneyHours(5400, 14)` → `'$21.00 (1:30)'` (dollars = seconds/3600 × rate, plus hh:mm). → *Vitest*
- **FMT-007** — `fmtMoneyHours` → `'—'` for 0 / null / undefined seconds. → *Vitest*
- **FMT-009** — `fmtHoursMinutesSeconds` exact, no rounding: 5400→`'1:30:00'`, 3661→`'1:01:01'`, 0→`'0:00:00'`, null/undefined→`'—'`. → *Vitest*
- **FMT-008** — `fmtTimestampShort`: truncates seconds and normalizes `'T'`→space: `'2026-05-20T09:30:00'` and `'2026-05-20 09:30:00'` both → `'2026-05-20 09:30'`; null→`'—'`. → *Vitest*

---

## `docs/tests-catalog.md` reconciliation

The catalog is the declared "stack-agnostic checklist the new test
suite must cover" with stable IDs (✅ = vitest test exists, 🟡 =
catalog only). It matches the FMT and FLT sections exactly. It is
**stale** everywhere else:

### Catalog staleness (catalog vs actual test files — test files win)

1. **CLK section stops at CLK-022.** Missing from the catalog but
   present in `clockify.test.ts`: CLK-026/027/028 (time auto-detect),
   CLK-029–034 + 032b/032c/032d (`detectDateFormat` incl. the
   tightest-span regression), CLK-040–044 (`formatDateRangeLabel`),
   CLK-023 + the duplicate-ID CLK-021/CLK-022 (`billout_rate_usd`
   surfacing, `computeBilloutAmount`) — ~20 tests uncataloged.
2. **CLK ID collisions.** `CLK-021` is used twice (splitClientProject
   AND billout_rate_usd surfacing); `CLK-022` is used twice
   (parseClockifyCsv split AND computeBilloutAmount nulls). Renumber
   during the port.
3. **One un-ID'd test** (`parseDurationSeconds` garbage → null).
4. **TGL-004 meaning drifted.** Catalog: "emits the 13 expected
   columns in the documented order." Actual test: "emits no header row
   by default"; the column-order assertion moved to TGL-015
   (`{header:true}` equals `TOGGL_COLUMNS`) — and `TOGGL_COLUMNS` now
   has **14** columns (Teams was added), not 13.
5. **TGL-007, TGL-008, TGL-010 are ghosts.** Catalog marks them ✅
   (Billable Yes/No; Task empty; blockerReason flags missing converted
   duration) but no such tests exist in `toggl.test.ts`. TGL-010's
   removal is deliberate — post-invoice-migration, converted_* fields
   are no longer eligibility requirements (comment in both the test
   file and `lib/toggl.ts`). TGL-007/008 assertions were folded away
   when the header/format tests were reworked.
6. **TGL-011 description stale.** Catalog says it flags missing
   "client / project / description / start / end / source email /
   converted user"; the actual test checks `operator`, client, project,
   description, `end_at`, `source_user_email` — operator added,
   converted-user and start checks gone.
7. **TGL-015/016/017 uncataloged** (header option, Teams column,
   `'-'` placeholder).
8. **The INV-* suite (11 tests) is entirely absent from the catalog.**
   `invoices.test.ts` exists and passes but has no catalog section.
9. **The CCE-* suite (29 tests) is entirely absent from the catalog.**
   The CC-expenses feature doc lists ~29 ✅ CCE IDs + 5 🟡 CCE-A IDs,
   none merged into the catalog (extraction flag #3 — if the catalog
   is the rebuild checklist, these would silently drop).

### Catalog 🟡 list — behaviors with NO test (integration/manual only)

These 44 IDs are cataloged but untested because they need the DB or
Supabase Auth. Verbatim from `docs/tests-catalog.md`, with the rebuild
owner added. Legend: **py** = pytest+httpx API/domain test, **pw** =
Playwright smoke, **drop** = dies with a phased-out feature,
**rewrite** = semantic survives in a different mechanism.

| ID | Behavior (one line) | Rebuild owner |
|---|---|---|
| AUTH-001 | First-login redirects to /change-password | **drop** (must_change_password dies with id-auth Pattern B) |
| AUTH-002 | /change-password flips `must_change_password=false` then redirects to / | **drop** (same) |
| RBAC-001 | Adi (manager) sees /transfer but no "Mark as transferred" button | **drop** — also STALE: contradicts RBAC-005/006 and the permissions matrix; extraction flag #2 says retire it |
| RBAC-002 | Rian as super admin sees the "View as ▾" switcher | **py + pw** (view-as survives as app-layer impersonation) |
| RBAC-003 | View-as cookie surfaces banner and disables owner-only buttons | **pw** |
| IMP-001 | Uploading a Clockify CSV creates a batch + N entries | **py** (+pw smoke: import→resolve→commit) |
| IMP-002 | Unknown emails leave `team_member_id` NULL on those rows | **py** (rewrite to engagement resolution) |
| IMP-003 | Cannot delete an import batch if any entry is transferred | **rewrite → py**: "transferred" is dead; the surviving lock is *invoiced* entries block batch delete |
| TRN-001 | Mark-selected assigns one shared `transfer_batch_id` | **drop** (Toggl transfer) |
| TRN-002 | Undo batch clears transferred_at/by/transfer_batch_id | **drop** |
| TRN-003 | Download CSV does not write to the DB | **drop** |
| SUM-001 | `summary_by_project` RPC: one row per project, this/prev/all-time seconds | **py** (RPC semantics port to a service query) |
| SUM-002 | Summary dollars = (converted_seconds/3600) × adi_rate | **rewrite → py**: converted seconds die; dollars come from stamped billout/cost |
| ENT-005 | Bulk Mark explicit IDs: one shared batch id; transferred+blocked skipped | **drop** |
| ENT-006 | Bulk Mark "all matching filter" respects active filters | **drop** (but see ENT-020 — the scope-derivation invariant survives) |
| ENT-007 | Bulk Unmark clears transfer fields only on transferred rows | **drop** |
| ENT-008 | "All dates" applies unbounded filter (strip-on-`all` regression) | **Vitest + py** |
| ENT-009 | Cost column = (converted_duration_seconds/3600) × org.default_hourly_rate_usd | **rewrite → py**: cost is a locked-at-write stamp, not a live computation (LOCK-001 semantics won) |
| ENT-010 | Sortable column headers update ?sort=/?dir= and reorder | **Vitest** (table interaction hook) + **py** (order-by) |
| TEAM-003 | Base-rate update writes org.default_hourly_rate_usd, reflected in Effective rate / Cost / Summary | **rewrite → py**: rates become engagement compensation terms |
| TEAM-004 | Effective rate displays as base × proportion, read-only | **drop** (proportions die, ADR #034) |
| LOCK-001 | `billout_cost_usd` captured at import, not changed by later rate updates | **py** — load-bearing invariant (02-domain-model §5) |
| LOCK-002 | Editing billout duration recomputes `billout_cost_usd` at CURRENT rate | **py** — explicit re-stamp invariant |
| LOCK-003 | Re-resolve recomputes from current proportion × current base rate | **py** (rewrite minus proportion) |
| RBAC-004 | Manager cannot edit/delete/re-resolve a transferred entry; UI shows "Locked" | **rewrite → py**: lock trigger becomes *invoiced*, not transferred |
| RBAC-005 | No `canTransfer` → no Transfer nav link | **drop** (page dies) — capability truth-table grid replaces it |
| RBAC-006 | Direct /transfer as non-transfer user redirects to / | **drop** (same) |
| SUM-003 | Summary hides projects with zero hours in both prev+this (still in all-time) | **py** |
| ENT-011 | Totals card (`entries_filter_totals` RPC) covers FULL filter match across pages | **py** |
| ENT-012 | Project/User/Source-email filter narrows list AND totals; bulk respects | **py** |
| ENT-013 | Client filter narrows list AND totals; bulk respects | **py** |
| ENT-014 | `bulkUpdateFields`: empty input no-ops that field; values update client/project/converted_user | **py** (converted_user part drops) |
| ENT-015 | `bulkUpdateFields` as manager touches only `transferred_at IS NULL` rows | **rewrite → py** (invoiced-lock instead) |
| ENT-016 | `status=blocked` is a SQL-level predicate packed onto page 1, not JS post-filter | **py** — pairs with the TGL-009/011/012 predicate port |
| ENT-017 | Operator column + filter sort/filter correctly | **rewrite → py** (operator becomes an FK-resolved party, not a text column) |
| ENT-018 | Filter pivots from `entries_filter_options` RPC reflect the filtered set | **py** |
| COM-101 | Nav-bell comment visible to every org member | **py** |
| COM-102 | Resolve sets resolved_at/resolved_by; badge decrements | **py + pw** |
| COM-103 | Delete allowed for author or super_admin; others RLS-denied | **py** (authz grid — RLS denial becomes service-layer denial) |
| ENT-020 | Bulk with all_matching=1 + status + operator only updates rows matching BOTH (missing-applyBulkFilter-dimension regression) | **py** — the "3,274-rows-from-a-353-row-selection" incident; judgment call #11 makes scope-derivation architectural |
| ENT-021 | Bulk aborts when pre-count ≠ user-confirmed `expected_count`; nothing written | **py** — expected-count precondition is an R1 requirement (judgment call #11) |
| ENT-022 | Per-row Edit Save persists via `updateEntry` (nested-form silent-fail regression) | **pw** (form wiring) + **py** (updateEntry) |
| ENT-023 | Fresh /entries applies date=all + status=pending to query AND dropdown UI | **Vitest + pw** |
| ENT-024 | "Clear filters" strips every filter param and resets to defaults | **Vitest** |
| SUM-004 | `summary_by_project` returns one row per (operator, client, project) TRIPLE; three sortable columns | **py** |

Also cataloged only in the CC-expenses feature doc (not in
tests-catalog.md), all 🟡: **CCE-A-001** (re-paste preserves order via
`line_index`), **CCE-A-002** (per-row Save updates only that row),
**CCE-A-003** (delete batch cascades to lines), **CCE-A-004**
(auto-rule fires at paste, pre-fills assignment), **CCE-A-005** (new
rule doesn't touch existing lines — rules apply only at paste time).
All → **py**.

---

## Rebuild deltas (vs 00 / 02 / 04 / 07)

- **Where tests live in the rebuild** (04-architecture): pytest+httpx
  for API/domain — authz truth-table grids (actor × action × resource,
  per 02-domain-model §4: capability functions `canTransfer`,
  `canManageTeam`, `canManageImports`, `canEditEntry`, `canSeeIncome`,
  `canEnterViewAs` port with **identical truth tables** — meaning the
  *effective enforcement*; for canManageTeam that is the RLS rule, not
  the dead function (02 §4) — before any new semantics), money stamping, and the import pipeline "with the CLK-*
  fixtures". Vitest for frontend logic (filter serialization, table
  interaction hooks). Playwright smoke for import→resolve→commit,
  entry edit, invoice create+lock — behavior assertions, not render
  assertions. CI runs pytest + vitest + tsc from R0.
- **`06-test-plan.md` does not exist yet** (04-architecture references
  it); this inventory is its primary input.
- **The diff harness is a separate oracle** (R0, built FIRST): it
  compares data exports against the live app (invoice totals, monthly
  summaries, per-project aggregates). It is not part of this unit-test
  inventory but covers much of what the 🟡 list leaves untested.
- **Counts**: 116 vitest tests verified by direct count (29 CCE + 43
  CLK + 10 FLT + 9 FMT + 11 INV + 14 TGL), matching
  00-vision-and-scope's "the current 116". Judgment call #14 says the
  import pipeline ports "with its 44-test suite" — `clockify.test.ts`
  has **43** `it()` blocks; treat 43 as correct (44 is off by one,
  or counts the planned re-ID of the un-ID'd duration test).
- **Net port math**: of 116 existing tests, ~14 drop outright
  (11 Toggl-CSV/format tests + CLK-019 + CLK-020, plus TGL-003's
  helper), ~3 port with rewritten semantics (CLK-018, CLK-021-dup,
  eligibility fixtures), and the remaining ~99 port mechanically
  (Python: CLK, CCE, INV-P, TGL-009/011/012; Vitest: FMT, FLT, INV-D).
- **Fixture-language rule** (04-architecture): rebuilt fixtures use
  the real business graph (Adi, Brentwood, PlusROI), not user1/
  project1 — the current tests' anonymous fixtures (`dev@example.com`,
  'Test Client') should be re-expressed on the seed slices.
- **Explicitly new-in-rebuild test surface** (no current equivalent):
  the authz capability grids, audit-log writes on every mutation,
  Pattern B auth middleware (with `AUTH_DEV_USER` dev-mode stub,
  judgment call #13), structured `{error_code, summary, detail}`
  errors, and zero-cost-worker rates ("Gary: cost $0, billout $25" —
  judgment call #12, "tested explicitly").

## Quirks a rebuilder must not "fix" silently

- `' : '` (space-colon-space) is the universal client/project
  separator, split on FIRST occurrence, strict (no-space `'Foo:Bar'`
  does NOT split). Appears in CLK-021, TGL-013, INV-P-006, CCE-X-002.
- Naive local timestamps everywhere (`'YYYY-MM-DD HH:MM:SS'`, no
  timezone) — CLK-014, TGL-003, FLT bounds. 02-domain-model carries
  this over deliberately.
- Half-open date ranges `[start, end)` with inclusive-end-day for
  custom ranges (FLT-005).
- Em-dash `'—'` (not `'-'`, not empty) for null display values (FMT).
- 2dp rounding at format time for money; `computeBilloutAmount`
  rounds at computation time (CLK-023).
- `detectDateFormat`'s tightest-span heuristic and its "don't guess"
  null (CLK-032b/c/d) encode a real 2026-06-09 data-corruption
  incident — port the fixtures verbatim.
