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>
7.7 KiB
7.7 KiB
WP-70 — Test-data builders: illegal fixtures unrepresentable
Status: done (2eea860..a82332f) Phase: 12 — DDD hardening
Why
Decision #3 in CLAUDE.md — "make illegal states unrepresentable" — is honoured in
production code (AanvraagStatus's private-ctor/factory shape, the FE's tagged-union
machines, branded value objects behind parse*) but not in the test suites that exercise
them. Every layer independently reinvented ad-hoc, hand-built fixtures that reach around the
production construction path:
- Backend:
Aanvraagis a mutable EF-backed bag with independent public setters. Its ownStatusAtdereferencesReferentie!three times on the unstated assumption "Submitted ⇒ Referentie != null" — a convention two test files (RuleTests.cs,OpenZaakZaakSourceTests.cs) kept consistent by hand across eight inline fixtures. - Frontend: no shared fixture helper existed anywhere. Every machine spec redefined its own
throwaway literal helper (
editing1/2/3,editingWith), each hardcoding fields likeerrors: {}— asserting against shapes the real reducer may never produce. - E2E: the seeded BSN and a diploma id were copy-pasted across all three specs, coupled to
SeedData.cs's exact shape by comment only.
Read first
- ADR-0006 (
docs/reference/architecture/0006-test-data-builders.md) — the principle and the full decision table this WP implements. CLAUDE.md§"The decisions" #3, #5.backend/src/BigRegister.Api/Data/ApplicationStore.cs(Aanvraag,StatusAt).backend/src/BigRegister.Api/Domain/Applications/AanvraagStatus.cs— the exemplar this WP's backend builder mirrors.backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs.
Decisions (pre-made, don't relitigate)
- No
With*-per-field builders anywhere. A builder that opens every field back up is an object literal with extra syntax — reject that shape on either side of the seam. - Backend: a type-state builder.
Given.Concept()→ConceptAanvraag(only.AtStep/.Submitted/.Buildexist) →SubmittedAanvraag(only.Decided/.Buildexist) →DecidedAanvraag.Decided(...)validates a toelichting by calling the realBeoordelingRules.RequiresToelichting, not by re-stating the rule. Aanvraagitself stays mutable — WP-68 deliberately kept it an EF-backed class; fixing that for real is an EF-mapping refactor, out of scope here (see Follow-ups).- Frontend: replay, don't fabricate. One combinator,
given(reduce, initial)(...msgs)(libs/shared/src/testing/machine.ts), replaces every hand-written state literal. Value objects:unwrapOk(parseX(raw)), never a cast.RemoteData: named constructors (loading()/success(v)/failure(e)), replacing duplicated per-file literals. - E2E stays a flat smoke suite (WP-19's scope). Only extract shared
Actors/SeedRefs/loginAs— no page-object layer, no Given/When/Then runner, no dev-only seeding API. The shared-mutable-backend isolation problem is a documented follow-up, not fixed here. - Convert worst offenders only, not a full sweep:
RuleTests.cs'sDecided()helper +OpenZaakZaakSourceTests.cs's seven inline initializers (backend);herregistratie.machine.spec.ts+change-request.machine.spec.ts+ both RemoteData specs (frontend); all three e2e specs (actors/seed-refs only).
Files
| Area | Path |
|---|---|
| New (BE) | backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs, Acceptance/BesluitLifecycleTests.cs |
| Edit (BE) | RuleTests.cs, OpenZaakZaakSourceTests.cs |
| New (FE) | libs/shared/src/testing/{machine,remote-data,value-object}.ts, herregistratie/domain/intake.testing.ts, intake.acceptance.spec.ts |
| Edit (FE) | herregistratie.machine.spec.ts, change-request.machine.spec.ts, remote-data.spec.ts, machine-remote-data.spec.ts, both tsconfig.app.json, angular.json |
| New (e2e) | e2e/support/actors.ts |
| Edit (e2e) | smoke.spec.ts, brief-v2.spec.ts, error-state.spec.ts |
| Docs | ADR-0006, libs/shared/docs/testing.mdx, .claude/skills/test-strategy/SKILL.md, this file + backlog README row |
Steps
Executed as three file-disjoint parallel tracks (backend / frontend / e2e), each ending its own layer's tests green, then a combined gate, then docs written up against the interfaces as actually shipped.
Acceptance criteria
Given.Concept().Decided(...)does not compile (proved live: temporarily inserted the call, confirmeddotnet buildfails withCS1061, reverted).Decided(Besluit.Afwijzen)/MeerInfoOpvragenwith no toelichting throws, via the realBeoordelingRules.RequiresToelichting.- Backend tests: 220/220 passing (was 216 before; +4 from
BesluitLifecycleTests). - Frontend:
npm testgreen across all four projects (ssp/behandelportal/shared/beheer); converted specs assert the same behaviour as before (diffed, not just re-passed) — one case (editing3's hardcodederrors: {}at step 3 with invalid punten) was confirmed reachable viaSetFieldafterNext, not an unrepresentable state, so the assertion carried over unchanged. - No fixture-only export (
givenIntakeetc.) leaks into a production bundle — confirmed viagrep -rlondist/after both a plain and a--localizebuild. npm run cigreen (lint, format, tokens, both localized builds, audit, backend format+test, snippet-drift, api-client-drift).- [~]
npm run e2e— refactor reviewed line-by-line (zero assertions changed), but not run to completion in this environment: port 4200 was occupied by an unrelated container (team-monitor-web-1), not this repo's stack. Confirm on a clean runner/CI before relying on it; not a regression introduced by this WP.
Verification
cd backend && dotnet format --verify-no-changes && dotnet test # 220/220
npm run ci # green (2026-08-18)
npm run e2e # run on a clean port 4200
Out of scope
- E2E test isolation (a dev-only seed endpoint) — the real fix for the shared-mutable-backend problem; a new production-adjacent surface needing its own security review.
- Making
Aanvraagitself illegal-states-unrepresentable (EF-mapping refactor). RegistrationStatus's equivalent flat-record gap (Domain/Registrations/) — same class of defect, separate WP.- E2E coverage for
apps/behandelportal(currently zero).
Risks
- The backend type-state builder only guards the fields it models (
Submitted,Referentie,SubmittedAt,BesluitStatus,BesluitToelichting); otherAanvraagfields (e.g.ZaakUrl) are still set post-.Build()directly, sinceAanvraagremains mutable. A future field added to the lifecycle needs a deliberate builder update, or it silently reopens the same gap this WP closed.