Files
atomic-design-poc/docs/project/archive/refactor-backlog-setup/refactor-backlog/02-testability.md
T
ehoandClaude Opus 5 12f17d9d73 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>
2026-09-08 23:00:38 +02:00

41 KiB
Raw Blame History

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 identicallyapps/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 UploadMsgs — 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.createObjectURLdocument.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/infrastructureparseStamdataTable/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 RenderNodeRenderParagraphsRender, 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-appliedAuthz.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 changesDiplomaRules.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%) SM 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% SM 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.