docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (domain, application, infrastructure, ui, layout, kernel, upload, testing), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata, tests)
|
||||
|
||||
## Status: complete
|
||||
|
||||
## Last updated: 2026-08-26
|
||||
|
||||
## Depends on: 00-baseline.md
|
||||
|
||||
## ---
|
||||
|
||||
# 02 — Testability
|
||||
|
||||
What blocks a **unit** test — static/singleton dependencies, hidden I/O, work in
|
||||
constructors/field initializers, pure logic entangled with impure. Every finding cites a
|
||||
`BL-###` or a metric row from `00-baseline.md`. Seams proposed are extractions, never
|
||||
rewrites.
|
||||
|
||||
## How this file reads the baseline
|
||||
|
||||
Three filters were applied before anything was written down, and they killed more
|
||||
candidates than they kept:
|
||||
|
||||
1. **BL-004's carve-out is honoured.** No finding is filed against a `ui/` (or
|
||||
`layout/` component) file for lacking a Vitest spec. 66 Storybook stories + the a11y
|
||||
addon are the house strategy (CLAUDE.md §5), not a gap.
|
||||
2. **"Untested" ≠ "untestable".** Several of the worst-covered non-`ui/` files are
|
||||
perfectly injectable and simply have no spec (`submit-besluit.ts`,
|
||||
`Contracts/Mappers.cs`, `breadcrumb-trail.ts`). Those are noted in their module
|
||||
section but not filed as testability findings — there is no seam to add. Whoever
|
||||
owns coverage should pick them up.
|
||||
3. **Already-covered code needs a positive argument.** Backend line coverage is 97.6%
|
||||
(BL-005); a "this is untestable" claim there has to point at a branch the current
|
||||
test shape genuinely cannot reach. Two do (TE-007, TE-008); one points at the cost
|
||||
of how it is reached (TE-009).
|
||||
|
||||
**Two baseline items are closed as false gaps** — see `libs/shared/domain` and
|
||||
`libs/beheer/contracts` below. BL-004 names both as "genuine gaps"; on inspection
|
||||
neither contains an executable statement.
|
||||
|
||||
**Deliberate decisions engaged with, not overridden:** the 7 static backend stores
|
||||
(documented in `Data/Db.cs`) are left alone — TE-009 extracts rules _out_ of one of
|
||||
them without touching its shape. BL-002's auth duplication is respected — TE-004 lands
|
||||
the same seam twice rather than proposing a shared extraction.
|
||||
|
||||
---
|
||||
|
||||
## apps/ssp — auth
|
||||
|
||||
**TE-001 — `SessionStore.restore()` reads `localStorage` inline, so its shape guard cannot be unit-tested**
|
||||
|
||||
- Module / file:line — `apps/ssp/src/app/auth/application/session.store.ts:12-21`
|
||||
- **What blocks unit testing.** `restore()` is module-private and calls
|
||||
`localStorage.getItem(STORAGE_KEY)` itself, then does the parse + shape validation in
|
||||
the same function. It is invoked from a field initializer
|
||||
(`private _session = signal<Session | null>(restore())`, L37), so the storage read
|
||||
happens the instant the singleton is constructed. A spec cannot feed it a raw string;
|
||||
it must stub the `localStorage` global before the injector builds the store. The
|
||||
logic being guarded is not incidental — the comments mark it G1 (never persist the
|
||||
BSN) and G2 (validate the shape before trusting it), i.e. a trust boundary, and
|
||||
CLAUDE.md §5 mandates a spec for boundary `parse*` adapters.
|
||||
- **Baseline citation.** §3a: `ssp/auth` 42.9% line / 46.2% branch — **jointly the worst
|
||||
line coverage in the frontend table** (§8 ranking). Per-file lcov for this file:
|
||||
**LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13 (23.1% branch)** — 4 of the module's 6
|
||||
files are spec-reached (§3b, 67%), yet this one barely executes.
|
||||
- **Minimal seam.** Split the pure half out and move it next to the type it produces:
|
||||
`export function parseStoredSession(raw: string | null): Session | null` in
|
||||
`auth/domain/session.ts` — which **already has a spec file**
|
||||
(`auth/domain/session.spec.ts`) and is pure TS, so no new test scaffolding is needed.
|
||||
`restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))`. Three
|
||||
test cases (absent, non-JSON, wrong shape) cover the guard.
|
||||
- **Effort S.** Independently shippable in one deploy — pure move, no call-site change
|
||||
outside the file.
|
||||
- **Note on BL-002.** `bhp/auth` carries the identical function; the seam lands **twice**,
|
||||
once per app. That is correct, not duplication to fix — ADR-0002 / CLAUDE.md §1 make
|
||||
`auth` deliberately unshared, and BL-002 flags any extract-to-shared here as
|
||||
contradicting an accepted ADR. Agent 06 owns whether that prediction still holds.
|
||||
|
||||
## apps/ssp — registratie
|
||||
|
||||
**No findings.**
|
||||
|
||||
The module's shape is the reason. Every `parse*` in its six adapters is exported and
|
||||
directly spec'd (`applications`, `big-register`, `brp`, `dashboard-view`, `duo` all have
|
||||
`.spec.ts` files); the machines are pure `domain/` units with specs; the five value
|
||||
objects each have one.
|
||||
|
||||
`createDraftSync` deserves an explicit acquittal: at 143 lines it is the longest function
|
||||
in the repo (§4a, "Functions over 75 lines — the entire population") and it owns a
|
||||
`setTimeout` debounce, a `Router` navigation and an in-flight-create race guard. It is
|
||||
nevertheless **the best-seamed effectful unit in the frontend** — deps arrive through an
|
||||
explicit `DraftSyncDeps` object (`draft-sync.ts:24-33`), `Router`/`ActivatedRoute` are
|
||||
`inject(..., { optional: true })` so it is inert without them, and `enabled()` exists
|
||||
specifically so stories and tests can neutralize it (L31-32). It has a spec. Its length
|
||||
is agent 01's call, not a testability defect.
|
||||
|
||||
`BigProfileStore` creates two `resource()`s in field initializers (constructor-time I/O),
|
||||
which is normally a blocker — but the store is pure glue over `parseDashboardView`
|
||||
(exported, spec'd) and `map`/`fromResource` (spec'd), so there is no untested decision
|
||||
hiding behind the construction. §3a: 80.0% line / 77.3% branch, §3b 51% reach with the
|
||||
20 unreached files being 11 `ui/` components (BL-004) and 3 pure-type `contracts/` files.
|
||||
|
||||
## apps/ssp — herregistratie
|
||||
|
||||
**No findings.** §3a 70.9% / 67.8%, §3b 56% reach. The four unreached files are the
|
||||
`ui/` pages and wizard organisms (BL-004) plus `intake-policy.store.ts`, a thin
|
||||
`resource()` wrapper over the exported-and-spec'd `parseIntakePolicy`. Both machines are
|
||||
pure, Angular-free and carry four spec files between them, including an acceptance spec.
|
||||
`intake.testing.ts` gives the wizard specs a fixture builder — the seam already exists.
|
||||
|
||||
## apps/ssp — brief
|
||||
|
||||
**TE-002 — `RevealBigNummerAdapter` hides a trust boundary inside a global-`fetch` method**
|
||||
|
||||
- Module / file:line — `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:32-41`
|
||||
- **What blocks unit testing.** The shape validation of the response body — the code's
|
||||
own comment calls it a "Trust boundary" — is written inline inside `async reveal()`,
|
||||
after an `await fetch(...)` on the **global** `fetch` (L24). There is no injected transport. To assert that a
|
||||
`{ bigNummer: 42 }` response is rejected, a spec must stub `globalThis.fetch`; the
|
||||
boundary itself is not callable. Every other `parse*` in the repo is exported (30 of
|
||||
them, §7) — this one is the outlier, and it guards a PII reveal (PRD-0002 §5c). The
|
||||
same shape recurs in the sibling hand-written-`fetch` adapters:
|
||||
`letter-preview.adapter.ts:56-62` (`errorMessage`) and
|
||||
`org-template.adapter.ts:82-89` (the proefbrief error mapping) — both un-exported,
|
||||
both unreachable without a `fetch` stub.
|
||||
- **Baseline citation.** §3b: `ssp/brief` **42% spec reach** (11 of 26 files) — the
|
||||
lowest of any non-zero context outside `bhp/behandeling`; §3a 75.3% line / **68.8%
|
||||
branch**. All three `fetch` adapters are among the 15 unreached files, and none of
|
||||
them is a `ui/` component, so BL-004's Storybook carve-out does not cover them.
|
||||
- **Minimal seam.** Export the pure half as a named boundary, matching the file's 30
|
||||
siblings: `export function parseRevealed(body: unknown): Result<string, string>` —
|
||||
five lines moved verbatim out of the method, which becomes
|
||||
`return res.ok ? parseRevealed(await res.json().catch(() => null)) : err(...)`. Same
|
||||
move for `errorMessage` in the other two adapters (already separate functions; they
|
||||
only need `export` + a spec). No transport abstraction, no `HttpClient` migration —
|
||||
the hand-written `fetch` stays, and the documented reasons for it
|
||||
(`.ExcludeFromDescription()`, per-request headers) are untouched.
|
||||
- **Effort S.** Independently shippable.
|
||||
|
||||
## apps/ssp — showcase, shell, root
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3b lists `ssp/root` + `ssp/shell` (with the behandelportal equivalents) at **0% reach,
|
||||
12 files**. That is the correct number for what these files are: `main.ts`,
|
||||
`app.config.ts`, `app.routes.ts`, `app.ts` and `shell/nav.config.ts` are composition
|
||||
roots and static data — a spec asserting a provider array restates it. `debug-state`
|
||||
is a dev-only devtool with a Storybook story.
|
||||
|
||||
One honest note, no ticket: `shell/debug-state/mask.ts::redactProfile` is a pure,
|
||||
Angular-free PII-redaction function (it maps a `BigProfile` to a redacted shape) with no
|
||||
spec and **no blocker** — it is directly callable today. Its dependencies
|
||||
(`maskTail`, `REDACTED`) are in `libs/shared/kernel/pii.ts`, which is spec'd at 96.4%.
|
||||
Missing test, not blocked test.
|
||||
|
||||
`showcase` is 100% line coverage on its one reached file (§3a); `snippets.generated.ts`
|
||||
is generated and `concepts.page.ts` is a teaching page.
|
||||
|
||||
## apps/behandelportal — auth
|
||||
|
||||
**TE-001 applies here identically** — `apps/behandelportal/src/app/auth/application/session.store.ts:12-21`,
|
||||
same `restore()`, same `localStorage` read in a field initializer, same §3a row
|
||||
(`bhp/auth` 42.9% / 46.2%). Fix it in `apps/behandelportal/src/app/auth/domain/session.ts`,
|
||||
which also already has a spec. Counted once as TE-001; it is two commits, or one commit
|
||||
touching two apps.
|
||||
|
||||
No additional findings. `medewerker.ts:17-22` reads `window.location.search` +
|
||||
`sessionStorage` directly, but it is a five-line dev-only role stand-in with the same
|
||||
shape as `libs/shared/infrastructure/role.ts` — whose twin `subject.ts` is spec'd, so
|
||||
the pattern is demonstrably testable as written.
|
||||
|
||||
## apps/behandelportal — behandeling
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3b 31% reach (5 of 16) looks alarming and is not: the 11 unreached are 5 `ui/` files
|
||||
(BL-004), the three `resource()`-wrapper stores, and the two command factories. §3a
|
||||
records **91.6% line / 81.5% branch** on what is reached — the highest line coverage of
|
||||
any frontend module in the table.
|
||||
|
||||
Explicitly not a testability finding: `application/submit-besluit.ts` is
|
||||
**structurally identical** to `apps/ssp/src/app/registratie/application/submit-change-request.ts`,
|
||||
which has a spec (`submit-change-request.spec.ts`). Same `inject()` + `runSubmit`
|
||||
factory, same signature shape. It is not blocked by anything; it is a missing spec whose
|
||||
template already exists in the repo. Both adapters' `parse*` functions are exported and
|
||||
spec'd (`beoordeling.adapter.spec.ts`, `werkvoorraad.adapter.spec.ts`).
|
||||
|
||||
## apps/behandelportal — shell, root
|
||||
|
||||
**No findings.** Same composition-root reasoning as `ssp/shell + root` above.
|
||||
|
||||
## libs/shared — domain
|
||||
|
||||
**No findings — BL-004's "genuine gap" is a false positive here, and can be closed.**
|
||||
|
||||
§3b lists `libs/shared/domain` at **0% reached, 3 files**, and BL-004 names it first
|
||||
among "the genuine gaps are non-`ui/` files with no spec". Reading all three files
|
||||
(30 lines total): `capability.ts` is a 9-member string-literal union, `role.ts` is a
|
||||
3-member union, `feature-flag.ts` is one `interface` plus one exported string constant.
|
||||
**There is no executable statement in the folder.** 0% is the correct and unimprovable
|
||||
number; the types are checked by `tsc` and their runtime counterparts are validated in
|
||||
`parseMe` (spec'd, 94.7% infrastructure coverage). No ticket should be written against
|
||||
this row.
|
||||
|
||||
## libs/shared — application
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3a 80.3% / 70.0%, §3b 73% reach (8 of 11). The three unreached are `session.port.ts`
|
||||
(an `InjectionToken` + interface — a declaration, nothing to run), `feature-flags.store.ts`
|
||||
and `access.store.ts`. The seam kit itself (`remote-data`, `store`, `submit`,
|
||||
`history`, `pending-saves`, `machine-remote-data`, `debounced-save`) is fully spec'd —
|
||||
this is the folder that makes the rest of the frontend testable.
|
||||
|
||||
Noted without a ticket: `AccessStore.can()` (`access.store.ts:34-37`) is a
|
||||
deny-by-default security gate whose decision reduces to
|
||||
`rd.tag === 'Success' && rd.value.includes(capability)` over a `resource()` created in a
|
||||
field initializer. The decision is two lines; the substance it guards
|
||||
(`parseMe`, where a real silent-deny bug shipped — see the WP-66 regression test in
|
||||
`me.adapter.spec.ts:26-31`) is already exported and thoroughly spec'd. Extracting a pure
|
||||
`canFrom(rd, cap)` would be honest but buys close to nothing. Filing it would be volume,
|
||||
not quality.
|
||||
|
||||
## libs/shared — infrastructure
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3a 94.7% line / 81.0% branch, §3b 82% reach — the second-best module in the repo.
|
||||
BL-001 singles out `api-client.provider.ts:49 fetch` (CC 19) as one of only two CC>10
|
||||
functions outside the mandated idioms, so it is worth stating why it is _not_ a
|
||||
testability finding: `httpClientFetch(http: HttpClient)` takes its dependency as an
|
||||
ordinary function parameter (L47) rather than injecting it, and it has a spec
|
||||
(`api-client.provider.spec.ts`). Its complexity is agent 01's call. The module-level
|
||||
mutable `pendingIdempotencyKey` (L21) is self-clearing in a `finally` (L25), so it does
|
||||
not leak between tests.
|
||||
|
||||
## libs/shared — ui
|
||||
|
||||
**No findings — BL-004 governs.** 34 files, 13 reached; the unreached 21 are components
|
||||
covered by the Storybook + a11y strategy CLAUDE.md §5 mandates. The one non-component
|
||||
module in the folder, `rich-text-editor/rich-text-dom.ts` (home of `collect`, CC 11 —
|
||||
the other non-idiom CC>10 function per BL-001), **is** spec'd
|
||||
(`rich-text-dom.spec.ts`). The layer is doing what the house rules ask.
|
||||
|
||||
## libs/shared — layout
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3b 18% reach (2 of 11) is the lowest non-zero row, but 8 of the 9 unreached are
|
||||
components (`shell`, `page-shell`, `site-header`, `site-footer`, `breadcrumb`,
|
||||
`language-switcher`, `wizard-shell`) — BL-004 applies to `layout/` exactly as to `ui/`,
|
||||
since CLAUDE.md §5 titles both under `Design System/`.
|
||||
|
||||
Two non-component files, neither ticketed:
|
||||
|
||||
- `breadcrumb/breadcrumb-trail.ts::trailFor` is a pure exported function with a subtle
|
||||
parent-walk and a `delete trail[last].link` mutation, and has no spec. **No blocker** —
|
||||
it is directly callable, and its sibling `language-switcher/locale-links.ts` is the
|
||||
spec'd proof. Missing test, not blocked test.
|
||||
- `route-focus.ts` is a 20-line `ENVIRONMENT_INITIALIZER` wrapping a `Router` subscription
|
||||
and `afterNextRender`. Genuinely awkward to unit-test, but it is a11y wiring with no
|
||||
branch worth asserting; a seam here would cost more than it returns.
|
||||
|
||||
## libs/shared — kernel
|
||||
|
||||
**No findings.** §3a 96.4% line / 90.0% branch, §3b **100% reach** — the best module in
|
||||
the repo, and (§6) the most-depended-on at I = 5% with Ca 71. Pure functions, all spec'd.
|
||||
This is the reference standard the other findings point back at.
|
||||
|
||||
## libs/shared — upload
|
||||
|
||||
Three findings. This module carries the frontend's weakest testability profile, and
|
||||
BL-010 already flags it as sitting outside the layer convention.
|
||||
|
||||
**TE-003 — `UploadShellService` declares a port, then injects the concrete class instead**
|
||||
|
||||
- Module / file:line — `libs/shared/src/upload/upload-shell.service.ts:12-24` and `:35`
|
||||
- **What blocks unit testing.** The file defines `export interface UploadTransport` and
|
||||
documents it as _the_ swap seam ("swapping it in touches only this interface", L10-11).
|
||||
It then binds it as
|
||||
`private transport: UploadTransport = inject(KeepaliveTransport)` (L35) — the
|
||||
**concrete class**, which is `@Injectable` but **not exported** (L18). A spec that
|
||||
wants a fake transport cannot reference the class to override its provider, and cannot
|
||||
provide against the interface (interfaces are not DI tokens). Result: every one of
|
||||
`upload()`, `delete()`, `cancel()` and `pollReturning()` — the code that translates
|
||||
transport outcomes into `UploadMsg`s — is reachable only through a real
|
||||
`XMLHttpRequest`. The port exists on paper and does nothing.
|
||||
- **Baseline citation.** §3a: `libs/shared/upload` 52.0% line / 50.0% branch — the
|
||||
worst line coverage of any module except the two `auth` rows. §3b: 50% reach, and
|
||||
per the lcov file list the **two unreached files are `upload-shell.service.ts` and
|
||||
`upload-controller.ts`** — neither is a `ui/` component, so this is exactly the
|
||||
non-`ui/` gap BL-004 says is genuine.
|
||||
- **Minimal seam.** Add the token the repo already uses elsewhere:
|
||||
`export const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT',
|
||||
{ providedIn: 'root', factory: () => inject(KeepaliveTransport) })`, then
|
||||
`inject(UPLOAD_TRANSPORT)` on L35. This **extends an existing pattern** — §7 records
|
||||
exactly one explicit port in the frontend, `SessionPort` + `SESSION_PORT`
|
||||
(`libs/shared/src/application/session.port.ts`), with the same interface-plus-token
|
||||
shape. Runtime behaviour is byte-identical; the default factory returns the same
|
||||
instance.
|
||||
- **Effort S.** Independently shippable in one deploy.
|
||||
|
||||
**TE-004 — `createUploadController` performs injection, DOM subscription and an `effect()` at call time**
|
||||
|
||||
- Module / file:line — `libs/shared/src/upload/upload-controller.ts:23-46`, policy at `:62-74`
|
||||
- **What blocks unit testing.** Calling the factory does four irreversible things before
|
||||
returning: three `inject()` calls (L24-25, L46), an `effect()` registration (L31), and
|
||||
`window.addEventListener('focus', onFocus)` (L45). It must therefore run inside a
|
||||
`TestBed` injection context with `UploadAdapter`, `UploadShellService` and `DestroyRef`
|
||||
all satisfied — and `UploadShellService` is itself un-fakeable per TE-003, so the
|
||||
mocking cost compounds. What is trapped behind that cost is real policy:
|
||||
`onFileSelected` (L62-74) decides per file whether to emit `FileRejected` with reason
|
||||
`'multiple'`, `FileRejected` with a `rejectReason` result, or to start an upload — a
|
||||
decision over `(categories, categoryId, files)` with no I/O in it.
|
||||
- **Baseline citation.** §3a `libs/shared/upload` 52.0% / 50.0%; §3b 50% reach with this
|
||||
file among the two unreached. §4a additionally records the module's `max CC 27` and the
|
||||
repo's only two >75-line functions include `reduceUpload` (109 lines) — the reducer this
|
||||
controller feeds. The reducer is spec'd (`upload.machine.spec.ts`); the code choosing
|
||||
_which_ messages reach it is not.
|
||||
- **Minimal seam.** Pure-function split into the file that is already the tested unit:
|
||||
add `export function planFileSelection(state: UploadState, categoryId: string, files:
|
||||
{ name: string; type: string; size: number }[]): UploadMsg[]` to `upload.machine.ts`,
|
||||
moving L62-73 verbatim. `rejectReason` — the predicate it calls — is already exported
|
||||
from that file and already spec'd, so the move is downhill. The controller keeps the
|
||||
`crypto.randomUUID()` + `files.set()` + `shell.upload()` side effects and just executes
|
||||
the plan. No change to the controller's public surface or to the organism that calls it.
|
||||
- **Effort S.** Independently shippable.
|
||||
|
||||
**TE-005 — `UploadAdapter.xhrUpload` buries response interpretation inside an `XMLHttpRequest` closure**
|
||||
|
||||
- Module / file:line — `libs/shared/src/upload/upload.adapter.ts:113-157`, helpers at `:169-199`
|
||||
- **What blocks unit testing.** The method constructs `new XMLHttpRequest()` directly
|
||||
(L118) — no transport parameter, no injected factory — and attaches four listeners
|
||||
whose bodies contain the actual decisions: 2xx-vs-not (L131), `JSON.parse` of the body
|
||||
with a fallback (L132-136), ProblemDetails mapping via the un-exported `parseError`
|
||||
(L193-199), and abort-vs-error disambiguation (L142-144). None of it can be reached
|
||||
without stubbing the XHR global. Compounding it, the method also branches on
|
||||
`currentScenario()` at L115 and returns a `setTimeout`-driven dev simulator
|
||||
(`simulateUpload`, L169-192), so a dev-only fake and the production transport share
|
||||
one entry point.
|
||||
- **Baseline citation.** Per-file lcov: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57
|
||||
(5.3% branch)**. The file is counted as "reached" in §3b only because another spec
|
||||
imports it — essentially nothing in it executes. It is the single largest contributor
|
||||
to the module's 52.0% / 50.0% row in §3a.
|
||||
- **Minimal seam.** Extract the interpretation, not the transport:
|
||||
`export function uploadOutcome(status: number, responseText: string): Result<string,
|
||||
{ documentId: string }>` containing L131-139's logic plus `parseError`. The listener
|
||||
becomes a two-line dispatch into it. Optionally (same ticket, still small) move the
|
||||
`currentScenario()` branch from L115 up into `KeepaliveTransport.send()` — the seam
|
||||
TE-003 makes usable — so `xhrUpload` is transport only. Do **not** abstract
|
||||
`XMLHttpRequest`: the file documents why XHR is required (progress events +
|
||||
cancellation, which `fetch` cannot give) and that reason still holds.
|
||||
- **Effort S** for `uploadOutcome` alone, **M** if the scenario branch moves too.
|
||||
Independently shippable; sequence it after TE-003 if both are taken.
|
||||
|
||||
## libs/shared — testing
|
||||
|
||||
**No findings.** §3a 100% line coverage. `given()` (`machine.ts`) and the `RemoteData`
|
||||
constructors (`remote-data.ts`) are the DSL the domain specs are built on, and the
|
||||
`no-testing-in-production` dependency-cruiser rule (§6) keeps them out of shipped code.
|
||||
The gap this folder does _not_ yet cover is a `resource()`-shaped fake — which is why
|
||||
`AccessStore`/`BigProfileStore` stay unreached — but adding one is a test-infrastructure
|
||||
task, not a source-code seam, and no metric row demands it.
|
||||
|
||||
## libs/beheer
|
||||
|
||||
**TE-006 — blob-to-browser handoff is inlined in three application-layer commands**
|
||||
|
||||
- Module / file:line — `libs/beheer/src/application/stamdata.store.ts:137-147`;
|
||||
also `apps/ssp/src/app/brief/application/brief.store.ts:230` and
|
||||
`apps/ssp/src/app/brief/application/org-template.store.ts:217`
|
||||
- **What blocks unit testing.** Each of the three commands ends in raw DOM/browser API
|
||||
calls that jsdom cannot meaningfully execute: `StamdataStore.download()` does
|
||||
`URL.createObjectURL` → `document.createElement('a')` → `a.click()` →
|
||||
`URL.revokeObjectURL`; `BriefStore.previewLetter()` and
|
||||
`OrgTemplateStore.proefbrief()` both do `window.open(URL.createObjectURL(blob),
|
||||
'_blank')`. Because the call is the **last statement**, the entire success path of each
|
||||
command is unassertable — a spec can only exercise the early-return/failure branches.
|
||||
`brief.store.spec.ts` demonstrates this exactly: it tests `previewLetter`'s failure
|
||||
case (which returns at the `!r.ok` guard) and cannot test the success case. In
|
||||
`download()` the untestable tail sits directly behind a two-clause guard
|
||||
(`if (!s || !this.canDownload()) return;`, L139), so the guard's true-branch is
|
||||
permanently dark.
|
||||
- **Baseline citation.** §3a: `libs/beheer/application` **40.5% branch — the worst
|
||||
branch coverage of any frontend module in the table**, and its 65.7% line figure is
|
||||
third-worst. Per-file lcov confirms `stamdata.store.ts` _is_ that row: LH 46 / LF 70,
|
||||
**BRH 15 / BRF 37**. On the brief side, §3a `ssp/brief` is 68.8% branch and
|
||||
`brief.store.ts` measures **BRH 32 / BRF 64 — exactly 50%**.
|
||||
- **Minimal seam.** One small injectable in `libs/shared/src/application`, mirroring the
|
||||
`SESSION_PORT` token shape already in that folder:
|
||||
`export const BLOB_PRESENTER = new InjectionToken<{ open(b: Blob): void; download(b:
|
||||
Blob, filename: string): void }>('BLOB_PRESENTER', { providedIn: 'root', factory: () =>
|
||||
realBlobPresenter })`. The three commands each lose 1-4 lines of DOM code and gain one
|
||||
method call; specs provide a recording fake and finally assert the success paths
|
||||
(including `toJson(...)`'s output actually reaching the file, which today is only
|
||||
tested one level down in `beheer/domain`). The content-producing logic stays exactly
|
||||
where it is.
|
||||
- **Effort S** (one token + three one-line edits) — **M** including the specs the seam
|
||||
unlocks. Independently shippable; the three call sites can also land separately.
|
||||
|
||||
**Other beheer layers — no findings.**
|
||||
|
||||
- `libs/beheer/contracts` — §3b lists it at **0% reached, 1 file**, and BL-004 names it
|
||||
as a genuine gap. It is not: `stamdata.dto.ts` is 30 lines of `interface` and `type`
|
||||
declarations with **zero executable statements** and, by design, zero imports (it is
|
||||
the wire seam). Like `libs/shared/domain`, this row should be closed rather than
|
||||
ticketed.
|
||||
- `libs/beheer/ui` — 0% reach, 4 files, all components → BL-004 / Storybook.
|
||||
- `libs/beheer/domain` — 98.1% line, spec'd machine and rules. Nothing blocked.
|
||||
- `libs/beheer/infrastructure` — `parseStamdataTable`/`parseColumn`/`parseRows` all
|
||||
exported and spec'd; the 60.5% branch figure is unexercised defensive arms in an
|
||||
otherwise open unit.
|
||||
|
||||
---
|
||||
|
||||
## backend/Program.cs
|
||||
|
||||
**No findings.**
|
||||
|
||||
§3c: 97.4% line / 84.8% branch, the second-best branch figure on the backend. Ten
|
||||
endpoint bodies read the wall clock inline (`DateTimeOffset.UtcNow` at L282, L286, L301,
|
||||
L390, L394, L426, L435, L449, L482, L931; `DateOnly.FromDateTime(DateTime.Today)` at
|
||||
L138), which would normally be a finding — but **every rule and mapper they hand it to
|
||||
already takes the instant as a parameter**: `HerregistratieRule.Evaluate(reg, today)`,
|
||||
`ToDetailDto(a, now)`, `ToStatusDto(a, now)`, `ListCases(now)`,
|
||||
`ApplicationStore.RecordBesluit(..., now)`. The clock-dependent _decisions_ are all
|
||||
testable at any date; only the endpoint wiring is pinned to now, and that wiring is what
|
||||
`EndpointTests`/`AdminCasesTests` legitimately cover through the host. Injecting
|
||||
`TimeProvider` into 48 minimal-API lambdas would be a rewrite, not a seam, and no metric
|
||||
row asks for it.
|
||||
|
||||
BL-003 (940 lines, file CC 78, read/write split by comment banner) is a **structure**
|
||||
finding, explicitly, and belongs to agents 03/04.
|
||||
|
||||
## backend/Domain
|
||||
|
||||
**TE-007 — `LetterHtml.ResolveAuto` reads the wall clock although `Render` is already given the instant**
|
||||
|
||||
- Module / file:line — `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs:138`
|
||||
(resolver) vs. `:27` (signature) and `:49` (the correct usage)
|
||||
- **What blocks unit testing.** `Render(BriefEntity brief, OrgTemplateDto template,
|
||||
string at, bool watermark)` already accepts the letter's instant, and uses it properly
|
||||
for the letterhead: `sb.Append(Enc(FormatDatumNl(at)))` at L49. But the body's
|
||||
`datum` placeholder resolves through `ResolveAuto`, which ignores `at` and calls
|
||||
`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` (L138). `ResolveAuto` is
|
||||
`private static`, reached only via `RenderNode` ← `RenderParagraphs` ← `Render`, so a
|
||||
test has no way to pin the value: it can only assert "whatever today is". The gap is
|
||||
visible in the existing test file — `LetterHtmlTests.cs:26` declares a
|
||||
`new PlaceholderDefDto("datum", "Datum", true)` in the fixture and **no assertion
|
||||
anywhere in the file checks what it renders to**. This is a pure `Domain/` rule class
|
||||
reaching for ambient state, which is precisely the purity §7 credits the folder with.
|
||||
- **Baseline citation.** §3c: `backend/Domain` **82.0% branch** — the third-weakest
|
||||
branch axis (BL-005). §4b: `Domain/Letters/LetterHtml.cs` file **CC 21**, the
|
||||
third-highest-CC file in the backend after `Program.cs` (78) and
|
||||
`Data/ApplicationStore.cs` (27).
|
||||
- **Minimal seam.** Thread the parameter that already exists: `ResolveAuto(string key,
|
||||
string label, string at)` → `"datum" => FormatDatumNl(at)`, passing `at` down through
|
||||
`RenderParagraphs`/`RenderNode` (both private, both already in `Render`'s call chain
|
||||
with `at` in scope). Two signature changes, one expression change, zero public API
|
||||
change, zero call-site change. Then assert the rendered `datum` against a fixed
|
||||
expected string in `LetterHtmlTests`.
|
||||
- **Secondary benefit, stated conservatively.** This is not a shipped bug today — every
|
||||
caller (`Program.cs:697`, `:708`, `BriefStore.cs:120`) passes `Now()` at render time,
|
||||
so the two dates coincide. It becomes one the moment `Render` is called with a
|
||||
historical `at` (re-rendering an archive, back-dating a letter), at which point the
|
||||
letterhead and the body would disagree within a single document.
|
||||
- **Effort S.** Independently shippable.
|
||||
|
||||
No other Domain findings. §7's claim holds under inspection: `SubmissionRules`,
|
||||
`DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`,
|
||||
`HerregistratieRule`, `OrgTemplateRules`, `Authz` and `FeatureFlags` are static classes of
|
||||
pure functions with a matching file in `tests/Domain/`, and the clock-dependent ones take
|
||||
their instant as an argument. That is the correct shape.
|
||||
|
||||
## backend/Data
|
||||
|
||||
**TE-008 — brief state-transition and authorization rules live inside DB-opening, lock-held store methods**
|
||||
|
||||
- Module / file:line — `backend/src/BigRegister.Api/Data/BriefStore.cs`, five guard
|
||||
clusters: `:72-76` (Save), `:88-90` (Submit), `:111-112` (Send), `:162-164`
|
||||
(Approve/Reject shared path), plus the `RequiredFilled(e)` predicate
|
||||
- **What blocks unit testing.** Each guard is a pure decision over
|
||||
`(status tag, actor role, entity completeness)` — e.g. `Save` returns `Forbidden` if
|
||||
`!isDrafter`, `Conflict` unless the status is `draft` or `rejected`, and reopens a
|
||||
`rejected` letter to `draft`; `Submit` additionally requires `RequiredFilled`. But each
|
||||
sits **inside** a method that has already done `lock (_gate) { using var db =
|
||||
Db.Create(); ... }`, so exercising any of them requires a booted host and a real SQLite
|
||||
file. There is no `BriefRules` class: `Domain/Letters/` contains only `LetterHtml.cs`
|
||||
and `OrgTemplateRules.cs`. The pattern is visibly **half-applied** — `Authz.CanActOn`
|
||||
at L163 _is_ a pure `Domain/` call, sitting one line away from three guards that are not.
|
||||
The `Save` guard's own comment says it "mirrors the FE reducer", i.e. it is business
|
||||
logic with a known pure counterpart on the other side of the wire.
|
||||
- **Baseline citation.** §3c: `backend/Data` **75.5% branch** — named in BL-005 as one
|
||||
of the three weak branch axes, against 99.0% line coverage (the exact signature of
|
||||
"every unit is entered, edge branches are not"). §4b: `Data/BriefStore.cs` file
|
||||
**CC 17**, and its `ToDto` at **CC 16** is the highest-CC non-`Program.cs` method in
|
||||
the backend. §5: `backend/Data` 7.7% duplication.
|
||||
- **Minimal seam.** Add `Domain/Letters/BriefRules.cs` with pure statics —
|
||||
`CanSave(BriefStatusDto status, bool isDrafter) → Outcome`,
|
||||
`StatusAfterSave(BriefStatusDto) → BriefStatusDto`,
|
||||
`CanSubmit(status, isDrafter, bool requiredFilled) → Outcome`, `CanSend(status)`,
|
||||
`CanDecide(status, Principal, drafterId)` — and have each store method call one.
|
||||
The store keeps its lock, its `Db.Create()`, its static shape and its signature; only
|
||||
the `if` cascade moves. This **extends the pattern §7 already records** for
|
||||
`SubmissionRules` / `BeoordelingRules` / `OrgTemplateRules` / `DocumentRules`, and adds
|
||||
a `tests/Domain/BriefRuleTests.cs` alongside the seven that exist.
|
||||
- **Explicitly NOT proposed: changing the static-store shape.** `Data/Db.cs:6-12`
|
||||
documents the static, non-DI store decision, and
|
||||
`tests/TestWebApplicationFactory.cs:1-12` states the position outright — "Serializing
|
||||
test classes is the fix, not a redesign of the stores for a test-only concern."
|
||||
TE-008 respects that completely: it is orthogonal, and works _because_ the rules never
|
||||
needed the DbContext in the first place.
|
||||
- **The cost this seam actually pays down.** Because `Db.ConnectionString` is one static
|
||||
field, that same file carries
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]` — **all 241 backend
|
||||
tests run serially, process-wide**, and every brief-rule assertion currently pays a
|
||||
host boot + SQLite file for a decision that is a pure function of two enums. Each rule
|
||||
moved out of `BriefStore` moves a test out of the serialized integration lane into the
|
||||
free-running unit lane. That is the argument for the seam; it is not an argument for
|
||||
touching the stores.
|
||||
- **Effort M** (five extractions + one new test file). Independently shippable, and
|
||||
splittable one method at a time if preferred.
|
||||
|
||||
Two smaller Data notes, neither ticketed: `IdempotencyStore` is the only store that is
|
||||
purely in-memory with no `Reset()` and no TTL, so its dictionary survives
|
||||
`TestWebApplicationFactory` disposal and is shared by every test class in the process —
|
||||
harmless today only because `IdempotencyTests.cs:24` keys on `Guid.NewGuid()`. And
|
||||
`DocumentStore.cs:54` / `AuthzAuditStore.cs:35` stamp `DateTimeOffset.UtcNow` inline
|
||||
while `ApplicationStore.RecordBesluit` correctly takes `now` — an inconsistency, but
|
||||
neither audit timestamp is asserted on, so no metric supports a ticket.
|
||||
|
||||
## backend/Zgw
|
||||
|
||||
**No findings.** §3c 98.1% line / **85.5% branch — the strongest branch figure on the
|
||||
backend**, and §5 records 1.8% duplication. This is the module that was built as
|
||||
ports-and-adapters from the start (ADR-0005): `IZaakSource`/`IDocumentSource` each have
|
||||
two implementations (§7), `ZgwHttpClient` takes an injected `HttpClient`, and
|
||||
`tests/ZgwStubHandler.cs` provides the transport fake — five test files ride on it. It
|
||||
is the backend's worked example of the seam TE-003 asks the upload module for.
|
||||
|
||||
## backend/Contracts
|
||||
|
||||
**No testability findings — but state the gap accurately.**
|
||||
|
||||
§3c records `backend/Contracts` at **65.0% branch, the worst branch figure in the repo**
|
||||
(BL-005 names it first). It is nonetheless not a testability finding: `Mappers.cs` is 79
|
||||
lines of pure `static` extension methods over records, with the clock already injected
|
||||
where it matters (`ToStatusDto(this Aanvraag a, DateTimeOffset now)` at `:52`,
|
||||
`ToSummaryDto(..., now)` at `:68`, `ToDetailDto(..., now)` at `:76`), and `Dtos.cs` is
|
||||
250 lines of `record` declarations. §4b confirms the shape: file CC 4, max method CC 3,
|
||||
the lowest complexity of any backend folder. **Nothing blocks a unit test here.**
|
||||
|
||||
What is missing is a test _file_: `backend/tests/` has `Domain/`, `Acceptance/` and
|
||||
`Builders/` folders but no `Contracts/`, so all 65% is incidental coverage picked up
|
||||
through endpoint tests. That is a coverage ticket for whoever owns coverage, requiring
|
||||
zero source change — and per BL-009 there is no ratchet, so it would have to be verified
|
||||
against §3c's numbers by hand.
|
||||
|
||||
## backend/Stamdata
|
||||
|
||||
**TE-009 — `Professions.ByProgram` freezes its valid-time filter at type-load from `DateTime.Today`**
|
||||
|
||||
- Module / file:line — `backend/src/BigRegister.Api/Stamdata/Professions.cs:25-27`
|
||||
- **What blocks unit testing.** `ByProgram` is a `static readonly IReadOnlyDictionary`
|
||||
whose initializer runs `Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan,
|
||||
m.GeldigTot, DateOnly.FromDateTime(DateTime.Today)))`. Two compounding problems: the
|
||||
peildatum is the ambient wall clock, **and** the result is computed once per process at
|
||||
type-load and then immutable. A test cannot ask "which mappings are active on
|
||||
2030-01-01" — not by arranging state, not by ordering, not at all. The temporal
|
||||
behaviour of the one business-tunable table that has a validity window is therefore
|
||||
unreachable. The file's own comment concedes the consequence: it "preserves the
|
||||
pre-valid-time behaviour exactly **while the file's rows are all current**" — i.e. the
|
||||
`ActiveOn` call is presently a constant-true filter, so both of its interesting
|
||||
branches (not-yet-valid, expired) are dead in every run.
|
||||
- **Baseline citation.** §3c: `backend/Stamdata` 96.8% line but **71.7% branch** —
|
||||
named in BL-005 as the second-weakest branch axis, an exact 25-point line/branch split.
|
||||
§4b: `Stamdata/StamdataTable.cs` file CC 21, joint-third-highest in the backend.
|
||||
This is the rare backend case where "untestable" is defensible against 97.6% line
|
||||
coverage: the lines run, the branches provably cannot.
|
||||
- **Minimal seam.** Add the parameterized overload and define the existing field in terms
|
||||
of it:
|
||||
`public static IReadOnlyDictionary<string,string> ByProgramOn(DateOnly on) => Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)).ToDictionary(...);`
|
||||
then `public static readonly IReadOnlyDictionary<string,string> ByProgram = ByProgramOn(DateOnly.FromDateTime(DateTime.Today));`.
|
||||
**Zero call-site changes** — `DiplomaRules.ProfessionFor` and `All()` keep using
|
||||
`ByProgram`. `StamdataValidationTests` gains the ability to assert both validity-window
|
||||
branches against authored future/expired rows.
|
||||
- **This extends an existing pattern in the same folder.** `StamdataTable.cs:63` already
|
||||
does exactly this — `Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows()`,
|
||||
with `on` as a parameter — and `StamdataFile.ActiveOn(van, tot, on)` (`:36`) is already
|
||||
clock-free. `Professions.cs` is the one caller that swallows the parameter.
|
||||
- **Effort S.** Independently shippable; additive only.
|
||||
|
||||
## backend/tests
|
||||
|
||||
**No findings.** 39 files, 4 253 lines, 241 green tests, every backend source file
|
||||
reached (§3c). The suite already carries the fixtures a unit lane needs
|
||||
(`Builders/AanvraagBuilder.cs`, `ZgwStubHandler.cs`, `TestWebApplicationFactory` with
|
||||
per-class throwaway SQLite files).
|
||||
|
||||
The one structural observation is not a defect to fix here:
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
|
||||
(`TestWebApplicationFactory.cs:12`) serializes the entire suite because
|
||||
`Db.ConnectionString` is a process-global. The repo reached that decision deliberately
|
||||
and documented the race it prevents. Rather than reopen it, TE-008 and TE-009 reduce how
|
||||
much _needs_ to run in that serialized lane. Note also §4b's outlier in this folder: a
|
||||
**293-line test method at CC 20**
|
||||
(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`,
|
||||
`OpenZaakZaakSourceTests.cs`) — a test-readability item for agent 01, not a testability
|
||||
seam.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Title | Module | Blocker | Baseline | Effort | 1 deploy |
|
||||
| ------ | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ------ | -------- |
|
||||
| TE-001 | `SessionStore.restore()` reads `localStorage` inline | ssp/auth + bhp/auth | hidden I/O in a field initializer; guard is module-private | §3a 42.9%/46.2% (worst line); file LH 2/20, BRH 3/13 | S ×2 | yes |
|
||||
| TE-002 | Trust boundary hidden inside a global-`fetch` method | ssp/brief | un-exported shape validation behind `await fetch` | §3b 42% reach; §3a 68.8% branch | S | yes |
|
||||
| TE-003 | `UploadTransport` port declared, concrete class injected | libs/shared/upload | `inject(KeepaliveTransport)`; class not exported | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes |
|
||||
| TE-004 | `createUploadController` injects + binds `window` at call time | libs/shared/upload | 3× `inject()`, `effect()`, `addEventListener` before returning | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes |
|
||||
| TE-005 | `xhrUpload` interprets responses inside an XHR closure | libs/shared/upload | `new XMLHttpRequest()` hard-coded; dev simulator shares the method | file LH 5/64 (7.8%), BRH 3/57 (5.3%) | S–M | yes |
|
||||
| TE-006 | Blob-to-browser handoff inlined in 3 commands | libs/beheer + ssp/brief | `window.open` / `a.click()` as the last statement of each command | §3a beheer/application 40.5% branch (worst); brief.store 50% | S–M | yes |
|
||||
| TE-007 | `LetterHtml` resolves `datum` from `UtcNow`, not from `at` | backend/Domain | ambient clock in a private resolver inside a pure rule class | §3c Domain 82.0% branch; §4b file CC 21 | S | yes |
|
||||
| TE-008 | Brief transition rules live inside DB-opening store methods | backend/Data | 5 pure guards behind `lock` + `Db.Create()` | §3c Data 75.5% branch (BL-005); §4b CC 17, `ToDto` CC 16 | M | yes |
|
||||
| TE-009 | `Professions.ByProgram` freezes valid-time at type-load | backend/Stamdata | `static readonly` + `DateTime.Today`; both branches unreachable | §3c Stamdata 71.7% branch (BL-005) | S | yes |
|
||||
|
||||
**Modules with no findings:** ssp/registratie · ssp/herregistratie · ssp/showcase+shell+root ·
|
||||
bhp/behandeling · bhp/shell+root · libs/shared/{domain, application, infrastructure, ui,
|
||||
layout, kernel, testing} · libs/beheer/{domain, infrastructure, ui, contracts} ·
|
||||
backend/Program.cs · backend/Zgw · backend/Contracts · backend/tests.
|
||||
|
||||
**Baseline rows recommended for closure as false gaps:** `libs/shared/domain` (0% reach,
|
||||
3 files) and `libs/beheer/contracts` (0% reach, 1 file) — both named in BL-004 as genuine
|
||||
gaps; both contain only type declarations and no executable statement.
|
||||
|
||||
**Cross-references, not owned here:** BL-001 complexity (agent 01) · BL-002 auth
|
||||
duplication (agent 06) · BL-003 `Program.cs` structure (agents 03/04) · BL-006 backend
|
||||
architecture enforcement (agent 03) · BL-007 write-side placement (agent 04) · BL-008
|
||||
`coverageExclude` · BL-009 no coverage ratchet — which means none of the findings above
|
||||
can be verified as "improved" by CI alone; verify against `00-baseline.md`'s numbers ·
|
||||
BL-010 `libs/shared/upload` layer placement (TE-003/004/005 all land inside that
|
||||
carve-out and do not resolve it) · BL-011 suite flakiness under parallel load.
|
||||
Reference in New Issue
Block a user