Skip to content

test: raise non-UI unit coverage from 71.9% to 99.5% statements - #71

Merged
chriskehayias merged 2 commits into
mainfrom
test/unit-coverage-non-ui-90
Aug 21, 2026
Merged

test: raise non-UI unit coverage from 71.9% to 99.5% statements#71
chriskehayias merged 2 commits into
mainfrom
test/unit-coverage-non-ui-90

Conversation

@chriskehayias

Copy link
Copy Markdown
Contributor

Summary

Pushes unit test coverage for non-UI functional code past the 90% target, fixes the measurement that was hiding the gap, and documents — without fixing — the defects the old coverage was pointing away from.

Non-UI functional code = every src/**/*.ts plus src/contexts/*.tsx, excluding generated MP models, the two codegen scripts, and test files. 760 statements.

Metric Before After Target
Statements 71.93% (546/759) 99.47% (756/760) ≥90% ✅
Branches 70.73% (220/311) 95.49% (297/311) ≥85% ✅
Functions 72.28% (120/166) 98.20% (164/167) ≥90% ✅
Lines 72.93% (539/739) 99.72% (738/740) ≥90% ✅

279 tests / 21 files → 419 tests / 30 files, still ~3s.

Tests added

Nine new files, six extended. The five untested MP sub-services were 163 of the 213 missing statements:

File Tests Stmts
file.service.test.ts 35 +88
procedure.service.test.ts 16 +27
communication.service.test.ts 13 +25
metadata.service.test.ts 8 +12
domain.service.test.ts 8 +11
client-credentials.test.ts 5 +7
lib/utils.test.ts, lib/auth-client.test.ts, shared-actions/domain.test.ts 14 +5

All five sub-services share the ensureValidTokengetHttpClient → error-wrap shape that table.service.ts already had covered, so the harness was copy-adaptable.

Extended: provider.test.ts 9 → 24 (the CommunicationService/FileService pass-throughs were entirely untested; provider.ts 60% → 100%), auth.test.ts 12 → 25, contact-logs/actions.test.ts 19 → 24, domainTimezoneService.test.ts 16 → 18, http-client.test.ts 26 → 28, plus branch fills in contact-lookup-details/actions, user-menu/actions, and user-context.

One source change

src/lib/auth.ts — the customSession callback body is extracted to an exported enrichSessionUser(user, session). Behavior identical. The better-auth plugin closes over its callback and never exposes it, so this was the only way to unit test the logic short of driving a full getSession() through the whole auth stack. auth.ts 44% → 96%.

7 tautological tests removed

The old auth.test.ts "Name Splitting" and "Session Structure" blocks re-implemented the transformation inside the test body and asserted against their own copy:

// the old test — this asserts String.prototype.split works
const enriched = { ...user, firstName: user.name?.split(' ')[0] || '' };
expect(enriched.firstName).toBe('John');

They would have passed with the customSession callback deleted outright — which is why auth.ts reported 18.5% while the file held 12 tests. Now rewritten to call the real export.

Verified by mutation: replacing firstName with a constant fails 6 tests. The old versions failed none.

Measurement is now honest and ratcheted

vitest.config.ts gains an explicit coverage.include. Without it, v8 reports only on files some test imported, so untested files silently leave the denominator — the repo read 71.6% while true statement coverage was 32.7%.

Adds per-glob coverage.thresholds. A breach fails the run with exit 1, verified by deliberately breaching one rather than only confirming a pass:

ERROR: Coverage for statements (100%) does not meet "src/proxy.ts" threshold (101%)

Excludes components/ui/ (thin Radix wrappers) and the codegen scripts (dev-only, run manually). Feature components stay visible in the report but ungated.

Two Vitest 4 notes for whoever edits this next: coverage.all no longer exists and setting it is a tsc error (include replaces it), and --reporter=basic was removed.

Deferred to .claude/TODO — no behavior changes here

Eight files, one per issue. Six were already known and are all still present in today's tree, each verified rather than assumed:

  • mp-filter-injection-numeric-ids.md 🔴 — numeric IDs interpolated into MP filters unsanitized. Confirmed exploitable; getContactLogById("1 OR 1=1") produces filter: "Contact_Log_ID = 1 OR 1=1". The file is at 100% statements and 100% branches.
  • server-action-search-contacts-unauthenticated.md 🔴 — 'use server', zero getSession calls, returns 20 contacts with email and mobile phone.
  • server-action-user-profile-unauthenticated.md 🔴 — no auth and no ownership check; returns any user's profile plus roles and groups.
  • contact-log-actions-authenticate-but-not-authorize.md 🟠 — any authenticated session can delete any contact log by ID. Needs a policy decision, not just code.
  • n-plus-1-contact-log-types-lookup.md 🟡 — getContactLogTypes() called inside logs.map().
  • mp-client-token-lifetime-ignores-expires-in.md 🟡 — every token capped at 5 minutes; the comment says the opposite of what the code does.

Two found while writing these tests:

  • contact-log-actions-bypass-session-context-service.md 🟠 — contact-logs/actions.ts re-implements the dp_Users User_ID lookup twice, work resolveMpUserId already caches and SessionContextService.getActingUserIdForWrite() exists to serve. It also throws when User_ID won't resolve, contradicting that service's documented log-and-proceed policy for unattributed writes.
  • contact-logs-component-untested.md 🔴 — contact-logs.tsx, 602 lines at 0%, driving every MP write a user can reach. Out of scope for a non-UI target, but the highest-value gap left in the repo.

Tests pinning behavior a TODO proposes changing carry a comment naming the TODO file, so the assertion reads as a snapshot rather than a specification.

Docs

  • .claude/references/testing.md — the 95.39% / 228 tests / 19 files claims were wrong on all three counts and not reproducible under any configuration. Rewritten against measured numbers, with the new mock patterns (MP sub-service harness, fetch stubbing, FormData assertions) and an explicit rule against asserting on a re-implementation of the subject.
  • .claude/docs/TestCoverage.md — rewritten as current state, each §5 finding linked to its TODO file.

Note on the two numbers

npm run test:coverage prints 71.45% — the whole-app figure, with untested feature components and app pages in the denominator but ungated. The 99.47% above is the non-UI subset. Both are honest; they differ only in denominator, and the reproduction command for each is in TestCoverage.md §3. Quoting either without its scope is how the old 95.39% claim happened.

Test plan

  • npm run test:run — 419 passed (30 files)
  • npm run test:coverage — passes, threshold gate satisfied
  • Threshold gate fails with exit 1 when deliberately breached
  • npx tsc --noEmit — clean
  • npx eslint . — clean
  • Mutation check confirms the rewritten auth tests bind to real code
  • No Ministry Platform data read or written; every test mocks above the network

🤖 Generated with Claude Code

chriskehayias and others added 2 commits August 21, 2026 07:09
Push unit test coverage for non-UI functional code past the 90% target, fix
the measurement that was hiding the gap, and document (without fixing) the
defects the old coverage was pointing away from.

Coverage — non-UI functional code (all src/**/*.ts plus src/contexts/*.tsx,
excluding generated models, codegen scripts, and tests; 760 statements):

  Statements  71.93% -> 99.47%  (756/760)
  Branches    70.73% -> 95.49%  (297/311)
  Functions   72.28% -> 98.20%  (164/167)
  Lines       72.93% -> 99.72%  (738/740)

279 tests / 21 files -> 419 tests / 30 files, still ~3s. tsc --noEmit and
eslint . clean.

New test files (9)

  file.service.test.ts             35 tests  (+88 stmts)
  procedure.service.test.ts        16 tests  (+27)
  communication.service.test.ts    13 tests  (+25)
  metadata.service.test.ts          8 tests  (+12)
  domain.service.test.ts            8 tests  (+11)
  client-credentials.test.ts        5 tests  (+7)
  lib/utils.test.ts                 7 tests
  lib/auth-client.test.ts           4 tests
  shared-actions/domain.test.ts     3 tests

The five untested MP sub-services were 163 of the 213 missing statements.
All five share the ensureValidToken -> getHttpClient -> error-wrap shape that
table.service.ts already had covered, so the harness was copy-adaptable.

Extended (6): provider.test.ts 9 -> 24 (the CommunicationService and
FileService pass-throughs were entirely untested; provider.ts 60% -> 100%),
auth.test.ts 12 -> 25, contact-logs/actions.test.ts 19 -> 24,
domainTimezoneService.test.ts 16 -> 18, http-client.test.ts 26 -> 28, plus
branch fills in contact-lookup-details/actions, user-menu/actions, and
user-context.

Source change (one)

src/lib/auth.ts: extract the customSession callback body to an exported
enrichSessionUser(user, session). Behavior identical. The better-auth plugin
closes over its callback and never exposes it, so this was the only way to
unit test the logic short of driving a full getSession() through the whole
auth stack. auth.ts 44% -> 96%.

Removed 7 tautological tests

The old auth.test.ts "Name Splitting" and "Session Structure" blocks
re-implemented the transformation inside the test body and asserted against
their own copy — they would have passed with the customSession callback
deleted outright, which is why auth.ts reported 18.5% while the file held 12
tests. Rewritten to call the real export. Verified by mutation: replacing
firstName with a constant fails 6 tests; the old versions failed none.

Measurement

vitest.config.ts gains an explicit coverage.include — without it, v8 reports
only on files some test imported, so untested files silently leave the
denominator (the repo read 71.6% while true statement coverage was 32.7%).
Adds per-glob coverage.thresholds; a breach fails the run with exit 1, which
was verified by deliberately breaching one rather than only confirming a pass.
Excludes components/ui/ (thin Radix wrappers) and the codegen scripts
(dev-only tooling). Feature components stay visible in the report but ungated.

Two Vitest 4 notes for whoever edits this next: coverage.all no longer exists
and setting it is a tsc error (include replaces it), and --reporter=basic was
removed.

Deferred to .claude/TODO — no behavior changes in this commit

Eight files, one per issue. Six were already known and are still present:
numeric IDs interpolated into MP filters unsanitized (confirmed exploitable,
in a file at 100%/100% coverage), searchContacts and getCurrentUserProfile as
'use server' actions with no session check, contact-log actions that
authenticate but never authorize, an N+1 lookup fetch, and client.ts
discarding expires_in.

Two found while writing these tests:
- contact-logs/actions.ts re-implements the dp_Users User_ID lookup that
  SessionContextService exists to serve, and throws when it cannot resolve —
  contradicting that service's log-and-proceed policy for unattributed writes.
- contact-logs.tsx: 602 lines at 0%, driving every MP write a user can reach.
  Out of scope for a non-UI target, but the highest-value gap left.

Tests that pin behavior a TODO proposes changing carry a comment naming the
TODO file, so the assertion reads as a snapshot rather than a specification.

Docs

.claude/references/testing.md: the 95.39% / 228 tests / 19 files claims were
wrong on all three counts and not reproducible under any configuration.
Rewritten against measured numbers, with the new mock patterns (MP sub-service
harness, fetch stubbing, FormData assertions) and an explicit rule against
asserting on a re-implementation of the subject.

.claude/docs/TestCoverage.md: rewritten as current state, with each §5 finding
linked to its TODO file.

No Ministry Platform data was read or written. Every test mocks at a boundary
above the network — which matters most for communication.service (sends real
email/SMS in production), procedure.service (procs can mutate), and the file
and table write paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI has been red on main since 64f18f0 ("Package Update Cleanup"): `npm ci`
fails in ~8s on an ajv lockfile mismatch, before any test runs. Found while
verifying this branch — the failure is byte-identical on main, and this branch
touches neither package.json nor package-lock.json.

Cause: eslint wants ajv@^6, @hookform/resolvers wants ajv@^8, and the lockfile
carries only one ajv entry (6.15.0). The nested ajv@8.x subtree, plus
fast-uri and json-schema-traverse@1.0.0, is missing.

Same mechanism as investigate-emnapi-lockfile-drift.md — a Windows install
pruning nested entries that npm ci on Linux then requires. The TODO notes that
the regeneration must happen on Linux (or with --os=linux --cpu=x64), since
fixing ajv on Windows risks reintroducing the emnapi drift in the same commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@chriskehayias
chriskehayias merged commit 4fe9199 into main Aug 21, 2026
1 check failed
@chriskehayias
chriskehayias deleted the test/unit-coverage-non-ui-90 branch August 21, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant