# Tests catalog

Stack-agnostic list of behaviors that must hold. When the app is rebuilt,
this is the checklist the new test suite must cover. The current
implementation of each entry lives in `tests/*.test.ts` (vitest); the IDs
are stable across stack changes.

Format: `<ID>` — one-line behavior. Status: ✅ test exists, 🟡 catalog only.

---

## CLK — Clockify CSV parsing (`lib/clockify.ts`)

- **CLK-001** ✅ Parses a YYYY-MM-DD date string into `'YYYY-MM-DD'`.
- **CLK-002** ✅ Parses an MM/DD/YYYY date string with the US format selector.
- **CLK-003** ✅ Parses a DD/MM/YYYY date string with the non-US format selector — and the same input differs between the two formats.
- **CLK-004** ✅ Returns `null` on an unparseable date string.
- **CLK-005** ✅ Rejects out-of-range months / days.
- **CLK-006** ✅ Treats 2-digit years as 2000-series.
- **CLK-007** ✅ Parses 24-hour time `13:45:30` to `'13:45:30'`.
- **CLK-008** ✅ Parses 12-hour time `1:45:30 PM` to `'13:45:30'`.
- **CLK-009** ✅ Handles 12 AM / 12 PM boundary correctly.
- **CLK-010** ✅ Returns `null` for malformed time strings.
- **CLK-011** ✅ Parses `HH:MM:SS` duration to seconds.
- **CLK-012** ✅ Parses `HH:MM` duration (no seconds) to seconds.
- **CLK-013** ✅ Parses decimal-hours duration to seconds.
- **CLK-014** ✅ `parseClockifyCsv` returns one entry per non-empty row.
- **CLK-015** ✅ `parseClockifyCsv` reports an error if required headers are missing.
- **CLK-016** ✅ `parseClockifyCsv` collects the set of unique emails and the date range.
- **CLK-017** ✅ `parseClockifyCsv` normalizes emails to lowercase.
- **CLK-018** ✅ `applyConversion` returns null fields for an unknown email.
- **CLK-019** ✅ `applyConversion` uses `consolidate_as` if set, else `display_name`, for the converted user label.
- **CLK-020** ✅ `applyConversion` multiplies duration by the rate proportion (Adi-hours math).
- **CLK-021** ✅ `splitClientProject` splits on the first ` : ` (space-colon-space); strings without it return `client: null`, `project: original`.
- **CLK-022** ✅ `parseClockifyCsv` splits the CSV "Project" field into separate `client` and `project` outputs; the CSV "Client" column becomes `operator`.

## TGL — Toggl CSV output and eligibility (`lib/toggl.ts`)

- **TGL-001** ✅ `formatDurationSeconds(0)` is `'00:00:00'`.
- **TGL-002** ✅ `formatDurationSeconds` handles seconds past 1 hour correctly.
- **TGL-003** ✅ `splitNaiveTimestamp` accepts both `T` and space separators.
- **TGL-004** ✅ `buildTogglCsv` emits the 13 expected columns in the documented order.
- **TGL-005** ✅ `buildTogglCsv` puts the source employee email in the Tags column (not the User column).
- **TGL-006** ✅ `buildTogglCsv` always sets Email to Adi's email regardless of source.
- **TGL-007** ✅ `buildTogglCsv` formats Billable as 'Yes' / 'No'.
- **TGL-008** ✅ `buildTogglCsv` leaves Task empty.
- **TGL-009** ✅ `blockerReason` flags missing team_member_id ("Unknown email").
- **TGL-010** ✅ `blockerReason` flags missing converted duration.
- **TGL-011** ✅ `blockerReason` flags any of: missing client / project / description / start / end / source email / converted user.
- **TGL-012** ✅ `blockerReason` returns null for a fully-resolved eligible entry.
- **TGL-013** ✅ `buildTogglCsv` recombines client + project into Toggl's Project column as `"{client} : {project}"`; operator goes into Toggl's Client column.
- **TGL-014** ✅ When client is null, Toggl's Project column gets just the project name.

## FMT — Formatting helpers (`lib/format.ts`)

- **FMT-001** ✅ `fmtHoursDecimal(3600)` is `'1.00'`.
- **FMT-002** ✅ `fmtHoursMinutes(5400)` is `'1:30'`.
- **FMT-003** ✅ `fmtHoursMinutes` rounds to the nearest minute.
- **FMT-004** ✅ `fmtHoursMinutes(null)` is `'—'`.
- **FMT-005** ✅ `fmtUsd(1593.89)` is `'$1,593.89'`.
- **FMT-006** ✅ `fmtMoneyHours` combines dollars + hh:mm when seconds > 0.
- **FMT-007** ✅ `fmtMoneyHours` returns `'—'` for zero or null seconds.
- **FMT-008** ✅ `fmtTimestampShort` truncates seconds and normalizes the T separator.
- **FMT-009** ✅ `fmtHoursMinutesSeconds` shows exact `h:mm:ss` (no rounding).

## FLT — URL filter parsing (`lib/filters.ts`)

- **FLT-001** ✅ Defaults to `this-month` when `date` query param is absent.
- **FLT-002** ✅ Returns `null` bounds when `date=all`.
- **FLT-003** ✅ `this-month` returns a [first-of-this-month, first-of-next-month) range.
- **FLT-004** ✅ `last-month` returns a [first-of-last-month, first-of-this-month) range and handles January correctly.
- **FLT-005** ✅ `custom` with explicit start/end uses an inclusive-end-day semantic (end becomes start of next day).
- **FLT-006** ✅ `status=all` and `status=blocked` pass through (server applies blocker filter).
- **FLT-007** ✅ `batch=all` becomes `null`; any other string is preserved.
- **FLT-008** ✅ Empty / whitespace-only `q` becomes `null`.
- **FLT-009** ✅ `imported_by` passes through as a string; empty/absent becomes `null`.
- **FLT-010** ✅ The `defaults` argument overrides built-in defaults (e.g. `{date:'all', status:'pending'}` gives unbounded dates + pending status when the URL has no params).

---

## Behaviors not yet tested (manual / integration only)

These are documented in their respective `features/*.md` but don't have
vitest tests yet, because they depend on the database or Supabase Auth.

- 🟡 **AUTH-001** First-login redirects to /change-password.
- 🟡 **AUTH-002** /change-password updates `must_change_password = false` then redirects to /.
- 🟡 **RBAC-001** Adi (manager) sees /transfer but no "Mark as transferred" button.
- 🟡 **RBAC-002** Rian as super admin sees the "View as ▾" switcher.
- 🟡 **RBAC-003** Setting view-as cookie surfaces a banner and disables owner-only buttons.
- 🟡 **IMP-001** Uploading a Clockify CSV creates a batch + N entries.
- 🟡 **IMP-002** Importing rows with unknown emails leaves team_member_id NULL on those rows.
- 🟡 **IMP-003** Cannot delete an import batch if any entry in it is transferred.
- 🟡 **TRN-001** Marking selected entries assigns them all the same `transfer_batch_id`.
- 🟡 **TRN-002** Undoing a batch clears `transferred_at`, `transferred_by`, `transfer_batch_id` for all rows in that batch.
- 🟡 **TRN-003** Download CSV does not write to the database.
- 🟡 **SUM-001** Summary's `summary_by_project` RPC returns one row per project with this/prev/all-time seconds.
- 🟡 **SUM-002** Dollar amounts on Summary equal `(converted_seconds / 3600) × adi_rate`.
- 🟡 **ENT-005** Bulk Mark on explicit IDs assigns one shared `transfer_batch_id`; already-transferred + blocked rows are skipped.
- 🟡 **ENT-006** Bulk Mark "all matching filter" respects active date / batch / search filters and eligibility.
- 🟡 **ENT-007** Bulk Unmark clears `transferred_at` / `transferred_by` / `transfer_batch_id` only on currently-transferred rows.
- 🟡 **ENT-008** Selecting "All dates" in the filter bar applies an unbounded date filter (regression test for the strip-on-`all` bug).
- 🟡 **ENT-009** Cost column on /entries equals `(converted_duration_seconds / 3600) × org.default_hourly_rate_usd`.
- 🟡 **ENT-010** Clicking a sortable column header updates `?sort=` and `?dir=`, and the page re-renders ordered by that column.
- 🟡 **TEAM-003** Updating base rate on /team writes to `organizations.default_hourly_rate_usd` and is reflected immediately in Effective rate, Entries Cost, and Summary dollars.
- 🟡 **TEAM-004** A team member's effective rate displays as `base × proportion`, read-only.
- 🟡 **LOCK-001** Cost (`billout_cost_usd`) is captured at import and not changed by later updates to base rate or proportion.
- 🟡 **LOCK-002** Editing an entry's billout duration recomputes its `billout_cost_usd` using the current base rate.
- 🟡 **LOCK-003** Re-resolve recomputes `billout_cost_usd` from the team member's current proportion × current base rate.
- 🟡 **RBAC-004** A manager (e.g. Adi) cannot edit, delete, or re-resolve a transferred entry — server actions reject and UI shows "Locked".
- 🟡 **RBAC-005** A user without `canTransfer` doesn't see the Transfer nav link.
- 🟡 **RBAC-006** Hitting `/transfer` directly as a non-transfer user redirects to `/`.
- 🟡 **SUM-003** `summary_by_project` hides projects with zero hours in both prev and this on the page (still counted in all-time totals).
- 🟡 **ENT-011** Entries totals card (`entries_filter_totals` RPC) shows source / billout / cost for the FULL filter match across all pages.
- 🟡 **ENT-012** Adding a Project / User / Source-email filter narrows both the entries list AND the totals card; bulk "all matching" respects them.
- 🟡 **ENT-013** A Client filter narrows both the entries list AND the totals card; bulk "all matching" respects it.
- 🟡 **ENT-014** `bulkUpdateFields` with empty inputs no-ops on that field; with values, updates client / project / converted_user across the selection.
- 🟡 **ENT-015** `bulkUpdateFields` as a manager (Adi) only touches rows where `transferred_at IS NULL`.
- 🟡 **ENT-016** `status=blocked` returns the full blocked set packed onto page 1 (SQL-level predicate, not JS post-filter).
- 🟡 **ENT-017** Operator column + filter exist and sort/filter the entries correctly.
- 🟡 **ENT-018** Filter pivots are populated from `entries_filter_options` RPC and reflect the currently-filtered result set.
- 🟡 **COM-101** Posting a comment via the nav bell makes it visible to every org member.
- 🟡 **COM-102** Resolve sets `resolved_at` / `resolved_by`; the unresolved-count badge decrements.
- 🟡 **COM-103** Delete allowed for author or super_admin; others get RLS-denied.
- 🟡 **ENT-020** `bulkUpdateFields` with `all_matching=1` + status + operator filters only updates rows that match BOTH filters (regression for the bug where missing applyBulkFilter dimensions widened to the whole org).
- 🟡 **ENT-021** `bulkUpdateFields` aborts if the action's pre-count differs from the `expected_count` confirmed by the user; nothing is written.
- 🟡 **ENT-022** Per-row Edit Save submits to `updateEntry` and persists the changes (regression for the nested-form bug — save would silently fail).
- 🟡 **ENT-023** Fresh `/entries` visit (no query params) applies `date=all + status=pending` to both the data query and the dropdown UI.
- 🟡 **ENT-024** "Clear filters" button strips every filter param from the URL and resets the page to defaults.
- 🟡 **SUM-004** `summary_by_project` returns one row per `(operator, client, project)` triple; the Summary table renders Operator / Client / Project as three sortable columns.

Adding integration tests is on the list — see [decisions.md](decisions.md).
