Compare commits

...
5 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 66224b1644 docs: record WP-70 commit range
CI / changes (push) Successful in 10s
CI / lint (push) Successful in 1m58s
CI / frontend (push) Successful in 3m40s
CI / backend (push) Successful in 2m6s
CI / e2e (push) Successful in 3m36s
CI / semgrep (push) Successful in 1m4s
CI / api-client-drift (push) Successful in 1m48s
CI / storybook-a11y (push) Successful in 13m50s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:32 +02:00
ehoandClaude Sonnet 5 a82332fa20 docs: ADR-0006 test-data builders, close out WP-70
Writes up the principle behind WP-70's three tracks ("build test data
through the same door production code uses") as ADR-0006, with a decision
table for which fixture idiom fits which test type. Updates the
test-strategy skill (adds the Fixtures rule, fixes its stale pre-monorepo
src/app/... worked-example paths) and the shared Storybook testing.mdx page
to match. Closes WP-70 with the signatures/counts as actually shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:20 +02:00
ehoandClaude Sonnet 5 a7bf228ca8 test(e2e): shared Actors/SeedRefs/loginAs, kill duplicated magic strings (WP-70)
The demo BSN, password, and DigiD login sequence were copy-pasted verbatim
into all three specs; the diploma id #diploma-d1 was coupled to SeedData.cs's
ordering by comment only, with no compile-time check if the seed shape
changed. e2e/support/actors.ts names both: Actors.zorgverlener + loginAs()
for the login sequence, SeedRefs.diplomaZonderPolicyVragen for the seed
coupling (with the "why d1" reasoning attached to the name, not scattered
across specs). Zero assertions changed — pure extract-and-rename of test
setup. e2e test-isolation (the shared mutable backend) is a documented
follow-up, not fixed here — see ADR-0006.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:12 +02:00
ehoandClaude Sonnet 5 a73a1c6f1e test(frontend): replay real messages instead of hand-built state literals (WP-70)
Every machine spec redefined its own throwaway fixture helper (editing1/2/3,
editingWith), hardcoding fields like errors: {} that assert against shapes
the reducer may never actually produce. given(reduce, initial)(...msgs)
(libs/shared/src/testing/machine.ts) replaces them by replaying real Msgs
through the real reduce, so a fixture is provably reachable. Adds the same
idiom for value objects (unwrapOk) and RemoteData (loading/success/failure),
plus intake.acceptance.spec.ts as a worked full-journey example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:31:06 +02:00
ehoandClaude Sonnet 5 2eea860efe test(backend): type-state Aanvraag builder, illegal fixtures unrepresentable (WP-70)
RuleTests/OpenZaakZaakSourceTests hand-built Aanvraag fixtures by initializer,
keeping Submitted/Referentie/SubmittedAt/BesluitStatus consistent by hand. A
type-state builder (Given.Concept().Submitted().Decided()) makes an illegal
sequence a compile error instead, and delegates the toelichting-required rule
to the real BeoordelingRules so it can't drift from production. Adds
BesluitLifecycleTests covering the WP-68 besluit invariants end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 15:30:57 +02:00
25 changed files with 942 additions and 120 deletions
+14 -3
View File
@@ -22,6 +22,13 @@ No `TestBed` for domain. Never assert on user-facing copy.
only for wiring axe can't see.
- **Never assert on `$localize` copy.** It changes per locale/edit — assert on the
`Result`, the value object, or the message id.
- **Fixtures go through the production door, never a hand-built literal** (ADR-0006).
Replay real `Msg`s through the real `reduce` (`given(reduce, initial)(...msgs)`) for a
state machine; `unwrapOk(parseX(raw))` for a value object; a type-state builder
(`Given.Concept().Submitted()...`) for a backend aggregate with an ordered lifecycle.
The one deliberate exception is a trust-boundary `parse*` spec, below — there the fixture
must be a raw, possibly-malformed literal, because the test's whole point is "what if this
shape is wrong." See ADR-0006's decision table for which idiom fits which test type.
## Skeleton
@@ -54,9 +61,13 @@ describe('parseThing', () => {
## Worked examples
- `src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style.
- `src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`).
- `src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer.
- `apps/ssp/src/app/registratie/domain/value-objects/postcode.spec.ts` — parser style.
- `apps/ssp/src/app/registratie/infrastructure/brp.adapter.spec.ts` — trust boundary (`null`/`{}`).
- `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` — pure reducer.
- `libs/shared/src/testing/{machine,remote-data,value-object}.ts` — the shared fixture
helpers (ADR-0006); `apps/ssp/src/app/herregistratie/domain/intake.testing.ts` — a
per-context wrapper (`givenIntake = given(reduce, initial)`); `intake.acceptance.spec.ts`
— a full journey expressed as one replayed message sequence.
## Verify
+4
View File
@@ -108,6 +108,7 @@
"**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts",
"apps/ssp/src/main.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
@@ -236,6 +237,7 @@
"**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts",
"apps/behandelportal/src/main.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
@@ -293,6 +295,7 @@
"**/contracts/**",
"src/infrastructure/api-client.ts",
"src/test-entry.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
@@ -330,6 +333,7 @@
"**/*.stories.ts",
"**/contracts/**",
"src/test-entry.ts",
"**/*.testing.ts",
"**/*.d.ts"
]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"]
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts", "src/**/*.testing.ts"]
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import { given } from '@shared/testing/machine';
import {
initial,
next,
@@ -12,74 +12,80 @@ import {
WizardState,
} from './herregistratie.machine';
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({
tag: 'Editing',
step: 1,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({
tag: 'Editing',
step: 2,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({
tag: 'Editing',
step: 3,
draft: { uren, jaren, punten },
errors: {},
upload: initialUpload,
});
const wizard = given(reduce, initial);
/** Replay to a step-1 Editing state with the given draft values — the ONLY
door to a WizardState is `reduce`, so a fixture here is provably reachable. */
const toStep1 = (uren: string, jaren = '5'): WizardState =>
wizard(
{ tag: 'SetField', key: 'uren', value: uren },
{ tag: 'SetField', key: 'jaren', value: jaren },
);
/** Replay to a step-2 Editing state: reach step 1, advance, then set punten
(which may itself be invalid — Next only gated step 1's own fields). */
const toStep2 = (uren: string, punten: string, jaren = '5'): WizardState =>
given(reduce, toStep1(uren, jaren))(
{ tag: 'Next' },
{ tag: 'SetField', key: 'punten', value: punten },
);
/** Replay to a step-3 Editing state: reach step 2 with a placeholder-valid
punten so `Next` actually advances, THEN overwrite punten with the real
(possibly invalid) value — exactly what a user editing step 3 can do,
since `SetField` never re-checks the step it's setting a field for. */
const toStep3 = (uren: string, punten: string, jaren = '5'): WizardState =>
given(reduce, toStep2(uren, '1', jaren))(
{ tag: 'Next' },
{ tag: 'SetField', key: 'punten', value: punten },
);
describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => {
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
expect((next(initial) as any).errors.uren).toBeTruthy();
expect((next(editing1('4160')) as any).step).toBe(2);
expect((next(toStep1('4160')) as any).step).toBe(2);
});
it('next advances step 2 → 3 only when punten parses', () => {
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy();
expect((next(editing2('4160', '200')) as any).step).toBe(3);
expect((next(toStep2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
expect((next(toStep2('4160', 'x')) as any).errors.punten).toBeTruthy();
expect((next(toStep2('4160', '200')) as any).step).toBe(3);
});
it('submit reaches Submitting ONLY from step 3 with fully valid data', () => {
expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten
const good = submit(editing3('4160', '200'));
expect(submit(toStep2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
expect(submit(toStep3('4160', 'x')).tag).toBe('Editing'); // invalid punten
const good = submit(toStep3('4160', '200'));
expect(good.tag).toBe('Submitting');
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('next requires BOTH step-1 fields (uren and jaren)', () => {
expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
expect((next(editing1('4160', '')) as any).step).toBe(1);
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
expect((next(toStep1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
expect((next(toStep1('4160', '')) as any).step).toBe(1);
expect((next(toStep1('4160', '5')) as any).step).toBe(2); // both valid -> advance
});
it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => {
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
expect((back(editing3('1', '2')) as any).step).toBe(2);
expect((back(editing2('1', '2')) as any).step).toBe(1);
expect((back(toStep3('1', '2')) as any).step).toBe(2);
expect((back(toStep2('1', '2')) as any).step).toBe(1);
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(editing3('4160', '200'));
const submitting = submit(toStep3('4160', '200'));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1);
expect((gaNaarStap(toStep3('4160', '200'), 1) as any).step).toBe(1);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
const e3 = editing3('4160', '200');
const e3 = toStep3('4160', '200');
expect(gaNaarStap(e3, 3)).toBe(e3); // same step -> no-op
const submitting = submit(e3);
expect(gaNaarStap(submitting, 1)).toBe(submitting); // not Editing -> no-op
@@ -113,7 +119,7 @@ describe('reduce (message-driven)', () => {
multiple: false,
allowPostDelivery: true,
};
let s = reduce(editing3('4160', '200'), {
let s = reduce(toStep3('4160', '200'), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -130,7 +136,7 @@ describe('reduce (message-driven)', () => {
});
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), {
let s = reduce(reduce(toStep3('4160', '200'), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -141,6 +147,6 @@ describe('reduce (message-driven)', () => {
});
it('Seed mounts an arbitrary state', () => {
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
expect(reduce(initial, { tag: 'Seed', state: toStep2('1', '2') }).tag).toBe('Editing');
});
});
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest';
import { given } from '@shared/testing/machine';
import { reduce, IntakeState } from './intake.machine';
import { givenIntake } from './intake.testing';
/**
* Behaviour-level journeys: each test replays the exact `IntakeMsg` sequence a
* real user (or the server, for `SetPolicy`) would send, and asserts only on
* the reachable end state — never a hand-assembled `IntakeState` literal. No
* `$localize` copy is asserted, only tags/fields/error PRESENCE.
*/
describe('intake acceptance journeys', () => {
it('high uren, no buitenland werk: no scholing question, straight through to Submitted', () => {
const s = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
{ tag: 'Next' },
{ tag: 'Submit' },
{ tag: 'SubmitConfirmed' },
);
expect(s.tag).toBe('Submitted');
expect(s.tag === 'Submitted' && s.data).toEqual({
werktBuitenland: false,
land: undefined,
buitenlandseUren: undefined,
uren: 1200,
aanvullendeScholing: undefined,
punten: undefined,
});
});
it('low uren requires the scholing question, and punten only once scholing is followed', () => {
// Leaving the werk step without answering the scholing question is blocked.
const blocked = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'Next' },
);
expect(blocked.tag).toBe('Answering');
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(1); // still on 'werk'
expect(blocked.tag === 'Answering' && blocked.errors.scholingGevolgd).toBeTruthy();
// Answering scholing but not yet punten is still blocked.
const stillBlocked = given(reduce, blocked)(
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
{ tag: 'Next' },
);
expect(stillBlocked.tag).toBe('Answering');
expect(stillBlocked.tag === 'Answering' && stillBlocked.cursor).toBe(1);
expect(stillBlocked.tag === 'Answering' && stillBlocked.errors.punten).toBeTruthy();
// Supplying punten unblocks: reach review, submit, fail, retry, succeed.
const submitting = given(reduce, stillBlocked)(
{ tag: 'SetAnswer', key: 'punten', value: '150' },
{ tag: 'Next' },
{ tag: 'Submit' },
);
expect(submitting.tag).toBe('Submitting');
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'netwerkfout' });
expect(failed.tag).toBe('Failed');
const retried = reduce(failed, { tag: 'Retry' });
expect(retried.tag).toBe('Submitting');
const done = reduce(retried, { tag: 'SubmitConfirmed' });
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data).toEqual({
werktBuitenland: false,
land: undefined,
buitenlandseUren: undefined,
uren: 500,
aanvullendeScholing: true,
punten: 150,
});
});
it('buitenland gewerkt requires land + hours abroad, and gaNaarStap corrects an earlier answer', () => {
const blocked = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
{ tag: 'Next' },
);
expect(blocked.tag).toBe('Answering');
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(0);
expect(blocked.tag === 'Answering' && blocked.errors.land).toBeTruthy();
const reviewing = given(reduce, blocked)(
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
{ tag: 'Next' }, // buitenland step now valid -> werk
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
{ tag: 'Next' }, // werk step valid, uren high enough to skip scholing -> review
);
expect(reviewing.tag).toBe('Answering');
expect(reviewing.tag === 'Answering' && reviewing.cursor).toBe(2); // review
// Jump back from review to correct the country, without losing later answers.
const corrected: IntakeState = given(reduce, reviewing)(
{ tag: 'GaNaarStap', cursor: 0 },
{ tag: 'SetAnswer', key: 'land', value: 'België' },
{ tag: 'Next' }, // buitenland step re-validated
{ tag: 'Next' }, // werk step re-validated (earlier 'uren' answer preserved)
);
expect(corrected.tag).toBe('Answering');
expect(corrected.tag === 'Answering' && corrected.cursor).toBe(2);
expect(corrected.tag === 'Answering' && corrected.answers.land).toBe('België');
// A forward jump (would skip validation) is refused — a true no-op.
const noForwardJump = reduce(corrected, { tag: 'GaNaarStap', cursor: 2 });
expect(noForwardJump).toBe(corrected);
const done = given(reduce, corrected)({ tag: 'Submit' }, { tag: 'SubmitConfirmed' });
expect(done.tag).toBe('Submitted');
expect(done.tag === 'Submitted' && done.data).toEqual({
werktBuitenland: true,
land: 'België',
buitenlandseUren: 300,
uren: 1200,
aanvullendeScholing: undefined,
punten: undefined,
});
});
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
const atWerkStep = givenIntake(
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
{ tag: 'Next' },
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
);
// With the default threshold (1000), 1200 hours needs no scholing question.
const acceptedWithDefault = reduce(atWerkStep, { tag: 'Next' });
expect(acceptedWithDefault.tag).toBe('Answering');
expect(acceptedWithDefault.tag === 'Answering' && acceptedWithDefault.cursor).toBe(2);
// The server raises the threshold above 1200 -> the same answer now requires it.
const raised = reduce(atWerkStep, { tag: 'SetPolicy', scholingThreshold: 1500 });
const blockedByNewPolicy = reduce(raised, { tag: 'Next' });
expect(blockedByNewPolicy.tag).toBe('Answering');
expect(blockedByNewPolicy.tag === 'Answering' && blockedByNewPolicy.cursor).toBe(1);
expect(
blockedByNewPolicy.tag === 'Answering' && blockedByNewPolicy.errors.scholingGevolgd,
).toBeTruthy();
});
});
@@ -0,0 +1,7 @@
import { given } from '@shared/testing/machine';
import { reduce, initial } from './intake.machine';
/** Replay real `IntakeMsg`s through the real `reduce`, starting from `initial`.
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
export const givenIntake = given(reduce, initial);
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest';
import { given } from '@shared/testing/machine';
import { ChangeRequestState, reduce, initial } from './change-request.machine';
const editingWith = (telefoon: string): ChangeRequestState => ({
tag: 'Editing',
draft: { telefoon },
errors: {},
});
const givenChangeRequest = given(reduce, initial);
const editingWith = (telefoon: string): ChangeRequestState =>
givenChangeRequest({ tag: 'SetField', key: 'telefoon', value: telefoon });
describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => {
+1 -1
View File
@@ -7,5 +7,5 @@
"types": ["@angular/localize"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts"]
"exclude": ["src/**/*.spec.ts", "src/**/*.stories.ts", "src/**/*.testing.ts"]
}
@@ -0,0 +1,111 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance;
/// <summary>
/// Behaviour-level tests for the besluit lifecycle (WP-65b/66/68), built through the
/// <see cref="Given"/> type-state builder (WP-70) rather than the full wizard/upload dance
/// <see cref="BeoordelingTests"/> uses — a fixture that's already Submitted (or already
/// Decided) is a two-line Given, not fifteen. Each test persists its own Given-built
/// <see cref="Aanvraag"/> straight into the isolated per-class SQLite file (no HTTP round trip
/// needed to create it) and exercises the real write path from there.
/// </summary>
public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
// Booting the client (once, here) is what makes Db.ConnectionString point at THIS class's
// throwaway file and runs its migrations — see TestWebApplicationFactory's own docs.
private readonly HttpClient _client = factory.CreateClient();
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.SaveChanges();
}
[Fact]
public void A_terminal_besluit_is_frozen()
{
// Given a case already decided Goedgekeurd — terminal, per BeoordelingRules.CanDecide.
var aanvraag = Given.Concept(type: "registratie").Submitted().Decided(Besluit.Goedkeuren).Build();
Persist(aanvraag);
// When a behandelaar tries to record a further besluit on it...
var (outcome, updated) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Afwijzen, "te laat", DateTimeOffset.UtcNow);
// Then the write is refused, and the original decision still stands.
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Conflict, outcome);
Assert.Null(updated);
var stillGoedgekeurd = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(DateTimeOffset.UtcNow);
Assert.Equal(AanvraagStatusTag.Goedgekeurd, stillGoedgekeurd.Tag);
}
[Fact]
public void MeerInfoGevraagd_can_be_decided_again()
{
// Given a case a behandelaar sent back for more information — not terminal.
var aanvraag = Given.Concept(type: "registratie").Submitted().Decided(Besluit.MeerInfoOpvragen, "stuur een geldig diploma").Build();
Persist(aanvraag);
// When a further besluit is recorded on it...
var (outcome, updated) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow);
// Then, unlike a terminal decision, it succeeds and advances the status.
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Ok, outcome);
Assert.Equal(AanvraagStatusTag.Goedgekeurd, updated!.StatusAt(DateTimeOffset.UtcNow).Tag);
}
[Fact]
public void A_recorded_decision_wins_over_the_auto_approve_computation()
{
// Given an auto-approvable submission that a behandelaar decides (Afwijzen) before the
// auto-approve window would otherwise have closed it as Goedgekeurd.
var aanvraag = Given.Concept(type: "registratie").Submitted(autoApprovable: true).Build();
Persist(aanvraag);
var (outcome, _) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Afwijzen, "diploma niet erkend", DateTimeOffset.UtcNow);
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Ok, outcome);
// When the status is read long after the auto-approve window has passed — the instant an
// undecided auto-approvable case of the same shape WOULD read Goedgekeurd (see
// ApplicationTests.AutoApprovable_flips_to_goedgekeurd_after_the_window)...
var longAfterTheWindow = aanvraag.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(longAfterTheWindow);
// Then the recorded decision still wins — Afgewezen, never Goedgekeurd.
Assert.Equal(AanvraagStatusTag.Afgewezen, status.Tag);
}
private Task<HttpResponseMessage> PostBesluit(string id, object body)
{
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{id}/besluit") { Content = JsonContent.Create(body) };
req.Headers.Add("X-Medewerker", "medewerker-1");
return _client.SendAsync(req);
}
[Fact]
public async Task Afwijzen_requires_a_toelichting()
{
// Given an open, decidable case (no decision recorded yet).
var aanvraag = Given.Concept(type: "registratie").Submitted().Build();
Persist(aanvraag);
// When a behandelaar posts Afwijzen with no toelichting...
var missing = await PostBesluit(aanvraag.Id, new { besluit = "Afwijzen" });
// Then the request is rejected — the wire boundary enforces the same rule
// (BeoordelingRules.RequiresToelichting) the builder enforces for a built fixture.
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
// And the identical request WITH a toelichting succeeds.
var withToelichting = await PostBesluit(aanvraag.Id, new { besluit = "Afwijzen", toelichting = "Diploma niet erkend" });
withToelichting.EnsureSuccessStatusCode();
var body = (await withToelichting.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
}
@@ -0,0 +1,160 @@
using System.Threading;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
namespace BigRegister.Tests.Builders;
/// <summary>Fixture identities test builders share across the suite.</summary>
public static class TestIdentities
{
/// The default owner for a builder-made <see cref="Aanvraag"/> — matches
/// <see cref="DocumentStore.DemoOwner"/> (the demo's only seeded user) so a fixture that
/// doesn't care about identity gets a realistic, elfproef-valid BSN for free.
public const string DemoBsn = DocumentStore.DemoOwner;
}
/// <summary>
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70). "Build test data through the
/// same door production code uses" — a Concept can only ever become Submitted, and only a
/// Submitted aanvraag can be Decided, so the compiler refuses a fixture built through an illegal
/// path (e.g. deciding a still-Concept aanvraag) instead of that being a runtime assertion nobody
/// wrote. Start at <see cref="Given.Concept"/>.
///
/// ponytail: <see cref="Aanvraag"/> itself stays exactly what it always was — a mutable,
/// EF-backed bag with no invariants of its own (that's Data/ApplicationStore.cs's job in
/// production, via its own lock + <see cref="BeoordelingRules"/> checks). This builder does not
/// refactor it into an immutable aggregate; it's the one enforced DOOR through which TEST code
/// builds one, so the invariants a real request path enforces don't quietly go missing from a
/// fixture assembled by hand.
/// </summary>
public static class Given
{
/// A fresh, unsubmitted wizard draft — step 0 of 0 until <see cref="ConceptAanvraag.AtStep"/>
/// says otherwise, exactly what <c>ApplicationStore.CreateConcept</c> hands back.
public static ConceptAanvraag Concept(string type = "registratie", string owner = TestIdentities.DemoBsn) =>
new(type, owner);
}
/// <summary>A not-yet-submitted aanvraag. The only next step is <see cref="Submitted"/> — there
/// is deliberately no <c>Decided</c> here, since only a submitted aanvraag can be decided.</summary>
public sealed class ConceptAanvraag
{
private readonly string _type;
private readonly string _owner;
private int _stepIndex;
private int _stepCount;
internal ConceptAanvraag(string type, string owner)
{
_type = type;
_owner = owner;
}
/// The wizard's current position — step <paramref name="index"/> of <paramref name="of"/>.
public ConceptAanvraag AtStep(int index, int of)
{
_stepIndex = index;
_stepCount = of;
return this;
}
/// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors
/// <c>ApplicationStore.Submit</c>), so <c>Aanvraag.StatusAt</c>'s <c>Referentie!</c> is honest
/// for every fixture built this way, never a null-ref waiting to happen.
public SubmittedAanvraag Submitted(bool autoApprovable = false) =>
new(_type, _owner, _stepIndex, _stepCount, autoApprovable);
public Aanvraag Build() => new()
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>A submitted aanvraag, open for a behandelaar's decision. The only next step is
/// <see cref="Decided"/> — there is no way back to <c>ConceptAanvraag</c>.</summary>
public sealed class SubmittedAanvraag
{
private static int _referentieSeq;
private readonly string _type;
private readonly string _owner;
private readonly int _stepIndex;
private readonly int _stepCount;
private readonly bool _autoApprovable;
private readonly string _referentie;
private readonly DateTimeOffset _submittedAt;
internal SubmittedAanvraag(string type, string owner, int stepIndex, int stepCount, bool autoApprovable)
{
_type = type;
_owner = owner;
_stepIndex = stepIndex;
_stepCount = stepCount;
_autoApprovable = autoApprovable;
// A plausible reference in SubmissionRules.NewReference's shape ("BIG-2026-" + a number) —
// sequential (not random) so a fixture's value is reproducible across a test run.
_referentie = $"BIG-2026-{Interlocked.Increment(ref _referentieSeq)}";
_submittedAt = DateTimeOffset.UtcNow;
}
/// <summary>Records a behandelaar's decision — reusing <see cref="BeoordelingRules.RequiresToelichting"/>,
/// the SAME rule production's besluit endpoint runs, rather than restating it here where it
/// could quietly drift. Throws <see cref="ArgumentException"/> for an Afwijzen/MeerInfoOpvragen
/// with a null/blank <paramref name="toelichting"/> — exactly what that endpoint rejects with
/// a 400, just caught here at fixture-build time instead.</summary>
public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null)
{
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(toelichting))
throw new ArgumentException($"{besluit} requires a toelichting.", nameof(toelichting));
return new DecidedAanvraag(this, besluit, toelichting);
}
public Aanvraag Build() => new()
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
Submitted = true,
Referentie = _referentie,
AutoApprovable = _autoApprovable,
SubmittedAt = _submittedAt,
CreatedAt = _submittedAt,
UpdatedAt = _submittedAt,
};
}
/// <summary>A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the
/// builder too — there's nothing past <see cref="Build"/>, matching Goedgekeurd/Afgewezen being
/// terminal in the domain (<see cref="BeoordelingRules.CanDecide"/>); a fixture that needs a
/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would.</summary>
public sealed class DecidedAanvraag
{
private readonly SubmittedAanvraag _submitted;
private readonly Besluit _besluit;
private readonly string? _toelichting;
internal DecidedAanvraag(SubmittedAanvraag submitted, Besluit besluit, string? toelichting)
{
_submitted = submitted;
_besluit = besluit;
_toelichting = toelichting;
}
public Aanvraag Build()
{
var aanvraag = _submitted.Build();
aanvraag.BesluitStatus = _besluit;
aanvraag.BesluitToelichting = _toelichting;
return aanvraag;
}
}
@@ -3,6 +3,7 @@ using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization;
using BigRegister.Tests.Builders;
namespace BigRegister.Tests;
@@ -140,17 +141,12 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
};
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller);
// The stub echoes back its own (hardcoded) identificatie, same as a real OpenZaak response.
Assert.Equal("BIG-2026-000123", referentie);
Assert.Equal("InBehandeling", status.Tag);
Assert.Equal("BIG-2026-000123", status.Referentie);
@@ -160,7 +156,7 @@ public class OpenZaakZaakSourceTests
var zaakBody = handler.BodyOf($"{ZrcBase}/zaken");
Assert.Contains(zaaktypeUrl, zaakBody);
Assert.Contains("123443210", zaakBody);
Assert.Contains("BIG-2026-000123", zaakBody);
Assert.Contains(aanvraag.Referentie!, zaakBody);
// Status: points at the created zaak's URL and the resolved statustype.
var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
@@ -180,7 +176,7 @@ public class OpenZaakZaakSourceTests
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
var aanvraag = Given.Concept(type: "unknown-type", owner: "111222333").Submitted().Build();
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
@@ -217,14 +213,8 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
};
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Afwijzen, "onvolledig", DateTimeOffset.UtcNow, caller);
@@ -268,14 +258,8 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
};
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
@@ -296,7 +280,8 @@ public class OpenZaakZaakSourceTests
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", ZaakUrl = null };
// ZaakUrl deliberately left unset — no builder call touches it, so it stays null.
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
@@ -310,7 +295,8 @@ public class OpenZaakZaakSourceTests
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", ZaakUrl = $"{ZrcBase}/zaken/uuid-existing" };
var aanvraag = Given.Concept(type: "unknown-type", owner: "111222333").Submitted().Build();
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
Assert.Throws<InvalidOperationException>(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller));
@@ -331,7 +317,7 @@ public class OpenZaakZaakSourceTests
VerantwoordelijkeOrganisatie = "123443210",
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", Referentie = "BIG-2026-000123" };
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
return (options, aanvraag, caller);
}
+8 -19
View File
@@ -1,10 +1,10 @@
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using BigRegister.Tests.Builders;
namespace BigRegister.Tests;
@@ -207,35 +207,24 @@ public class BeoordelingRuleTests
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
private static Aanvraag Decided(Besluit besluit) => new()
{
Id = "x",
Type = "registratie",
Owner = "test",
Submitted = true,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
BesluitStatus = besluit,
BesluitToelichting = besluit == Besluit.Goedkeuren ? null : "toelichting",
};
// WP-70: built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with
// no toelichting simply couldn't compile as a fixture here.
[Theory]
[InlineData(Besluit.Goedkeuren)]
[InlineData(Besluit.Afwijzen)]
public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded)
{
var now = DateTimeOffset.UtcNow;
var tag = Decided(recorded).StatusAt(now).Tag!.Value;
Assert.False(BeoordelingRules.CanDecide(tag));
var toelichting = recorded == Besluit.Goedkeuren ? null : "toelichting";
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(recorded, toelichting).Build();
Assert.False(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
}
[Fact]
public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal()
{
var now = DateTimeOffset.UtcNow;
var tag = Decided(Besluit.MeerInfoOpvragen).StatusAt(now).Tag!.Value;
Assert.True(BeoordelingRules.CanDecide(tag));
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(Besluit.MeerInfoOpvragen, "toelichting").Build();
Assert.True(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
}
}
+1
View File
@@ -120,6 +120,7 @@ for its existing violations, so every WP ends green.
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | done |
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | todo |
| [WP-70](WP-70-test-data-builders.md) | Test-data builders: illegal fixtures unrepresentable (ADR-0006) | 12 · DDD hardening | done |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -0,0 +1,118 @@
# 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: `Aanvraag` is a mutable EF-backed bag with independent public setters. Its own
`StatusAt` dereferences `Referentie!` 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 like
`errors: {}` — 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)
1. **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.
2. **Backend: a type-state builder.** `Given.Concept()``ConceptAanvraag` (only `.AtStep`/
`.Submitted`/`.Build` exist) → `SubmittedAanvraag` (only `.Decided`/`.Build` exist) →
`DecidedAanvraag`. `Decided(...)` validates a toelichting by calling the real
`BeoordelingRules.RequiresToelichting`, not by re-stating the rule.
3. **`Aanvraag` itself 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).
4. **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.
5. **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.
6. **Convert worst offenders only**, not a full sweep: `RuleTests.cs`'s `Decided()` 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
- [x] `Given.Concept().Decided(...)` does not compile (proved live: temporarily inserted the
call, confirmed `dotnet build` fails with `CS1061`, reverted).
- [x] `Decided(Besluit.Afwijzen)`/`MeerInfoOpvragen` with no toelichting throws, via the real
`BeoordelingRules.RequiresToelichting`.
- [x] Backend tests: 220/220 passing (was 216 before; +4 from `BesluitLifecycleTests`).
- [x] Frontend: `npm test` green 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 hardcoded `errors: {}` at step 3 with invalid punten) was
confirmed reachable via `SetField` after `Next`, not an unrepresentable state, so the
assertion carried over unchanged.
- [x] No fixture-only export (`givenIntake` etc.) leaks into a production bundle — confirmed
via `grep -rl` on `dist/` after both a plain and a `--localize` build.
- [x] `npm run ci` green (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
```bash
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 `Aanvraag` itself 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`); other `Aanvraag` fields (e.g.
`ZaakUrl`) are still set post-`.Build()` directly, since `Aanvraag` remains mutable. A
future field added to the lifecycle needs a deliberate builder update, or it silently
reopens the same gap this WP closed.
@@ -0,0 +1,164 @@
# ADR-0006 — Test data through the production door (builders, replay, and where each applies)
Status: Accepted · Date: 2026-08-18
## Context
Decision #3 in `CLAUDE.md` is "make illegal states unrepresentable," and the production code
mostly honours it: `AanvraagStatus` (backend) is a `sealed class` with a private constructor
reachable only through five static factories; the frontend's wizards are tagged-union state
machines driven by a pure `reduce`; form inputs are branded value objects reachable only
through a `parse*` that returns `Result`.
The test suites are the one place this invariant is not enforced — they build fixtures by
hand instead of through those same doors:
- **Backend.** `Aanvraag` (`Data/ApplicationStore.cs`) is a mutable EF-backed bag: `Submitted`,
`Referentie`, `BesluitStatus`, `SubmittedAt` are independent public setters. Its own
`StatusAt` dereferences `Referentie!` three times — "Submitted ⇒ Referentie != null" is
convention, not type. Two test files (`RuleTests.cs`, `OpenZaakZaakSourceTests.cs`) kept
eight such fixtures internally consistent by hand, each re-deciding for itself which fields
a given scenario needs.
- **Frontend.** No shared fixture helper existed anywhere in `apps/` or `libs/`. Every spec
redefined its own throwaway literal function (`editing1/editing2/editing3`, `editingWith`,
a local `ok()`), each hardcoding fields like `errors: {}` — asserting against a shape the
real reducer may never actually produce, because the literal skips the reducer entirely.
- **E2E.** The one seeded citizen's BSN and a diploma id were duplicated as bare string
literals across every spec, coupled to `SeedData.cs`'s exact ordering by comment only, with
no compiler check if the seed ever changed shape.
A hand-rolled literal is not "faster test setup" — it is a second, unchecked implementation
of the domain's construction rules, sitting right next to the real one.
## Decision
**Build test data through the same door production code uses. A test-data helper's job is to
supply _defaults_, never to bypass _invariants_.**
Concretely: reject any test helper shaped as a field-by-field builder (`.withX().withY()...`
over an otherwise-open constructor) — that is an object literal with extra syntax, and it
re-opens every illegal state the production type closed. Each layer instead gets the
narrowest helper that **cannot** construct an illegal instance, because it has no path to one.
### 1. Backend aggregates with a lifecycle → a type-state builder
Where a production type enforces its invariants (or should), the test builder mirrors that
enforcement as separate **types per stage**, so an illegal call is a compile error, not a
runtime surprise:
```csharp
Given.Concept() // ConceptAanvraag — only .Submitted() or .Build() exist
.Submitted() // SubmittedAanvraag — only .Decided() or .Build() exist
.Decided(Besluit.Afwijzen, "reden"); // DecidedAanvraag
```
`Given.Concept().Decided(...)` does not compile — `Decided` is simply not a member of
`ConceptAanvraag`. Where the production rule is more subtle than "which methods exist"
(e.g. "Afwijzen requires a toelichting"), the builder **calls the real production rule**
(`BeoordelingRules.RequiresToelichting`) rather than re-stating it — this is what keeps the
builder from drifting out of sync with the domain as the domain changes.
Use this shape whenever a production aggregate has an ordered lifecycle and either (a)
already guards it with factories (mirror them 1:1), or (b) doesn't yet guard it (as with
`Aanvraag` itself, see Consequences) — the test-only builder is not a substitute for fixing
the production type, but it stops the test suite from being the place the ungated shape leaks
out into assertions.
### 2. Frontend state machines → replay real messages through the real reducer
No object is built directly. A fixture is the result of running real `Msg`s through the real
`reduce`:
```ts
export const given =
<S, M>(reduce: (s: S, m: M) => S, initial: S) =>
(...msgs: M[]): S =>
msgs.reduce(reduce, initial);
export const givenIntake = given(reduce, initial); // per-context wrapper, pure TS
```
There is no way to hand-write a `Submitting` state whose draft contradicts its step, or to
assert `errors: {}` into existence — the only states reachable are the ones the reducer can
actually produce, because production is the only code path that produces them.
### 3. Value objects → `unwrapOk`, never a cast
A test that needs a valid branded value calls the real `parse*` and unwraps it:
```ts
export const unwrapOk = <E, T>(r: Result<E, T>): T => {
if (!r.ok) throw new Error('unwrapOk: parser rejected the input');
return r.value;
};
const postcode = unwrapOk(parsePostcode('1234 AB'));
```
This closes the `'garbage' as Postcode` route — a spec can only ever hold a value the real
parser accepted.
### 4. RemoteData → named constructors, not ad-hoc literals
`loading()` / `success(v)` / `failure(e)` in `libs/shared/src/testing/remote-data.ts` replace
the per-spec local `ok()`/`loading`/`failure` literals. `RemoteData` has no invariant to
protect (it's a plain closed union with no smart constructor in production either), so this
one is about **removing duplication**, not closing an illegal-state gap — named constructors
belong here because they are shorter and consistent, not because the literal was unsafe.
### 5. E2E — shared actors/seed-refs, not a DSL
E2E fixtures are named, not built: `Actors.zorgverlener`, `SeedRefs.diplomaZonderPolicyVragen`
in `e2e/support/actors.ts`, with `loginAs(page, actor)` replacing the duplicated login
sequence. No page-object layer, no Given/When/Then runner — Playwright specs stay flat
`page.getByRole` sequences (matching WP-19's "smoke, not full coverage" scope), the only
change is that the values they use have one source instead of N copies. See "Where this does
**not** reach" below for why the deeper e2e problem is out of scope here.
## Decision table — what to reach for, by test type
| Test type | Where it lives | Fixture idiom | Do **not** |
| -------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Domain aggregate with a guarded lifecycle (backend) | `*.Tests/Builders/` | Type-state builder mirroring the production factories; delegate any non-trivial rule to the real rule class | A field-by-field `.WithX()` builder, or an object initializer with all fields public |
| Pure reducer / state machine (frontend) | `domain/*.testing.ts` | `given(reduce, initial)(...msgs)` — replay real messages | A literal returning `{ tag: 'Editing', ... }` by hand |
| Value object / parser | co-located with the parser's spec | `unwrapOk(parseX(raw))` | `'x' as BrandedType` |
| Plain closed union with no invariant (e.g. `RemoteData`) | `libs/shared/src/testing/` | Named one-line constructors (`loading()`, `success(v)`) | Redefining the same literal per spec file |
| Trust-boundary `parse*` (adapter) | co-located, per `test-strategy` skill | Hand-written DTO literals **are** correct here — the point of the test is "what if the untrusted shape is wrong," so the fixture must be a raw, possibly-malformed literal, not a validated domain value | Routing malformed-input tests through a builder that can't express malformed shapes |
| UI component | Storybook story + axe | Args as `input()`s on the component; no fixture builder needed | A component test with a hand-built store/model |
| Acceptance / behaviour test (either side) | `Acceptance/*Tests.cs` (backend), `*.acceptance.spec.ts` (frontend) | The same builder/replay idiom as above, composed into one Given→When→Then read | A separate BDD/Gherkin runner — the language's own test framework plus the builder is enough |
| E2E | `e2e/support/` | Named actor/seed-ref constants + a thin `loginAs`-style setup helper | A page-object framework or DSL — out of proportion to a 3-spec smoke suite |
The common thread: **the fixture idiom is only ever a thinner or safer path to the same
construction the domain already performs** — never a parallel, unchecked one. The trust-
boundary row is the deliberate exception, not a contradiction: there the entire point of the
test is to exercise what happens when the input _isn't_ valid, so the fixture must be able to
represent the invalid shape a builder would refuse to construct.
## Consequences
- **+** An illegal backend fixture (e.g. a decided-but-not-submitted `Aanvraag`) is now a
compile error in the builder path, not a silent bad test.
- **+** Frontend specs can no longer assert against a state the reducer cannot actually reach;
a hardcoded `errors: {}` fixture literal can't drift from what validation actually produces.
- **+** One seeded identity/diploma reference in e2e instead of N copies — a reseed shows up as
one changed constant, not a hunt through three spec files.
- **** `Aanvraag` itself is **not** made illegal-states-unrepresentable by this ADR — it
remains a mutable EF-backed class (WP-68 kept it that way deliberately; `ApplicationStore`
is its only production writer). The builder is a test-only enforcement layer sitting in
front of a production type that still allows the bad shape directly. Closing that gap for
real means an EF-mapping change, tracked as a follow-up, not done here.
- **** A type-state builder is more ceremony than a constructor call for a one-off fixture.
Reach for it only where a lifecycle actually has ordered stages worth protecting — a flat
value type doesn't need one (see the `RemoteData` row above).
## Where this does **not** reach (deliberately out of scope)
- **E2E test isolation.** The three Playwright specs share one mutable backend and admit it in
their own comments ("restart the backend between CI runs"). The real fix is a dev-only seed
endpoint each test can call to build its own isolated citizen/aanvraag — a new
production-adjacent surface that needs its own security review, not a fixture-idiom change.
Tracked as a follow-up; not fixed here.
- **`RegistrationStatus`** (`Domain/Registrations/`) has the same class of gap as `Aanvraag`
a flat record with four always-present nullable fields, whose own doc-comment says only one
tag ever uses the deadline field — but is out of this ADR's scope (a separate WP).
- **`apps/behandelportal` e2e coverage** is currently zero; adding it is a coverage gap, not a
fixture-idiom question, and is a separate follow-up.
+2 -4
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { Actors, loginAs } from './support/actors';
// One flow through both Brief v2 axes on the real FE+backend (WP-19 conventions):
// content (drafter composes via the besluit panel → approver approves → sends) and
@@ -11,10 +12,7 @@ import { expect, test } from '@playwright/test';
// the org-template draft it edits (step 8) and never asserts an absolute version
// number — only that it increased by exactly one.
test('drafter composes → approver sends; admin republishes appearance', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456782');
await page.getByLabel('Wachtwoord').fill('demo');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await loginAs(page, Actors.zorgverlener);
await expect(page).toHaveURL(/\/dashboard$/);
// --- Compose (drafter) ---
+2 -3
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { Actors, loginAs } from './support/actors';
// The dev-only `?scenario=error` toggle forces the scenario.interceptor to fail
// the request WITHOUT ever reaching the real HTTP transport (it substitutes a
@@ -11,9 +12,7 @@ import { expect, test } from '@playwright/test';
// genuinely re-runs and genuinely fails the same way — that's what's asserted
// here: a real reload cycle, not a no-op button.
test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456782');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await loginAs(page, Actors.zorgverlener);
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto('/dashboard?scenario=error');
+6 -6
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { Actors, loginAs, SeedRefs } from './support/actors';
// One happy-path flow through the real FE+backend: log in, land on the real
// dashboard, run the registratie wizard's minimum required path (a DUO diploma
@@ -11,10 +12,7 @@ import { expect, test } from '@playwright/test';
// application on the dashboard, which this test doesn't assert against, but a
// stricter future test might.
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456782');
await page.getByLabel('Wachtwoord').fill('demo');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await loginAs(page, Actors.zorgverlener);
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { level: 1, name: 'Mijn overzicht' })).toBeVisible();
@@ -35,8 +33,10 @@ test('login → dashboard → registratie wizard → submitted', async ({ page }
// Step 2 — beroep: the first DUO diploma (Geneeskunde, non-English) carries zero
// policy questions, so the only required document is identiteit.
await expect(page.locator('#diploma-d1')).toBeVisible({ timeout: 10_000 });
await page.locator('label[for="diploma-d1"]').click();
await expect(page.locator(`#diploma-${SeedRefs.diplomaZonderPolicyVragen}`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`label[for="diploma-${SeedRefs.diplomaZonderPolicyVragen}"]`).click();
await expect(page.getByText('Beroep (afgeleid uit diploma)')).toBeVisible();
await page.locator('#identiteit-file').setInputFiles({
+48
View File
@@ -0,0 +1,48 @@
import type { Page } from '@playwright/test';
/**
* A demo identity the fake DigiD login accepts. Auth is faked (CLAUDE.md) the
* form only ever emits `bsn` on submit, so `wachtwoord` is never actually checked;
* it's filled in anyway because the field is marked required in the UI.
*/
export interface Actor {
readonly bsn: string;
readonly wachtwoord: string;
}
/**
* The demo identities seeded by the backend. Today there is exactly one seeded
* citizen (`backend/src/BigRegister.Api/Data/SeedData.cs`'s `Person`/`Registration`,
* whose BSN also matches `DocumentStore.DemoOwner`) named for the ROLE it plays
* in a spec, not its BSN, so a spec reads as "log in as the zorgverlener", not
* "log in as 123456782".
*/
export const Actors = {
zorgverlener: { bsn: '123456782', wachtwoord: 'demo' },
} as const satisfies Record<string, Actor>;
/** The shared DigiD-style mock login sequence every e2e spec starts from. */
export async function loginAs(page: Page, actor: Actor): Promise<void> {
await page.goto('/login');
await page.getByLabel('BSN').fill(actor.bsn);
await page.getByLabel('Wachtwoord').fill(actor.wachtwoord);
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
}
/**
* Seeded fixtures specs depend on by id, named by
* `backend/src/BigRegister.Api/Data/SeedData.cs`'s seed order reseeding that
* file in a different order silently breaks these with no compile error, so give
* the raw id a name instead of leaving it a bare literal in each spec.
*/
export const SeedRefs = {
/**
* The first DUO diploma (id "d1": Geneeskunde, Universiteit Leiden, non-English).
* Chosen deliberately, not arbitrarily: it's the one seeded diploma that carries
* zero policy questions, so it drives the wizard down its minimum required path
* the only required upload is identiteit.
*/
diplomaZonderPolicyVragen: 'd1',
/** The seeded registration's BIG-nummer (`SeedData.Registration`). */
bigNummer: '19012345601',
} as const;
+30 -1
View File
@@ -63,9 +63,38 @@ expect(parseBrpAddress(null).ok).toBe(false);
expect(parseBrpAddress({}).ok).toBe(false); // missing required field
```
Elm-style machines test the pure `reduce` with inline state fixtures — no Angular
Elm-style machines test the pure `reduce` — no Angular
(`registratie/domain/registratie-wizard.machine.spec.ts`).
## Fixtures: build test data through the production door
A fixture is not a shortcut around the domain — it's the domain's own construction path, run
once for the test. [ADR-0006](../../../docs/reference/architecture/0006-test-data-builders.md)
covers this in full (with a backend example too); the frontend idiom is one combinator,
`given` (`libs/shared/src/testing/machine.ts`):
```ts
export const given =
<S, M>(reduce: (s: S, m: M) => S, initial: S) =>
(...msgs: M[]): S =>
msgs.reduce(reduce, initial);
export const givenIntake = given(reduce, initial); // per-context wrapper, pure TS
```
A machine spec replays real `Msg`s instead of hand-writing a `State` literal — so a fixture
can only ever be a state the real reducer actually produces:
```ts
const atStep3 = givenIntake(Start(), SetUren('1200'), Next(), SetDiplomaHerkomst('NL'), Next());
```
The same rule extends to value objects (`unwrapOk(parseX(raw))` instead of a cast) and to
`RemoteData` (`loading()` / `success(v)` / `failure(e)` in
`libs/shared/src/testing/{value-object,remote-data}.ts` instead of a redefined-per-file
literal). **Never** a `.withX().withY()` builder over an open constructor — that just
re-opens whatever illegal state the domain closed.
## UI = Storybook, not heavy component tests
`@storybook/addon-a11y` runs the `wcag2a/2aa/21a/21aa` rule sets on **every** story;
@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest';
import { machineRemoteData } from './machine-remote-data';
import { loading, success } from '../testing/remote-data';
describe('machineRemoteData', () => {
it('maps loading → Loading', () => {
expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' });
expect(machineRemoteData({ tag: 'loading' })).toEqual(loading());
});
it('maps failed → Failure carrying an Error with the reason', () => {
@@ -14,6 +15,6 @@ describe('machineRemoteData', () => {
it('maps loaded → Success carrying the whole loaded state', () => {
const loaded = { tag: 'loaded', foo: 42 } as const;
expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded });
expect(machineRemoteData(loaded)).toEqual(success(loaded));
});
});
@@ -1,22 +1,23 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
import { loading, failure, success } from '../testing/remote-data';
const loading: RemoteData<string, number> = { tag: 'Loading' };
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
const loadingRd: RemoteData<string, number> = loading();
const failureRd: RemoteData<string, number> = failure('x');
const ok = (n: number): RemoteData<string, number> => success(n);
describe('RemoteData combinators', () => {
it('map only touches Success', () => {
const times10 = (n: number) => n * 10;
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
expect(map(loading, times10)).toEqual(loading);
expect(map(loadingRd, times10)).toEqual(loadingRd);
});
it('map2 precedence: Failure > Loading > Success', () => {
const add = (a: number, b: number) => a + b;
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
expect(map2(failureRd, ok(1), add)).toEqual(failureRd); // a failed
expect(map2(ok(1), failureRd, add)).toEqual(failureRd); // b failed
expect(map2(loadingRd, ok(1), add)).toEqual({ tag: 'Loading' });
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Test-only DSL for the Elm-store idiom (CLAUDE.md #3). A fixture is built by
* replaying real `Msg`s through the real `reduce` never by hand-assembling a
* state object field-by-field. That closes off illegal states the reducer would
* never actually produce: if a spec can't reach a state via messages, it can't
* assert on it either.
*
* `given(reduce, initial)` partially applies a machine's reducer + starting
* state; the result is a variadic replay function a spec calls with the exact
* message sequence a real user/flow would send.
*/
export const given =
<S, M>(reduce: (s: S, m: M) => S, initial: S) =>
(...msgs: M[]): S =>
msgs.reduce(reduce, initial);
+13
View File
@@ -0,0 +1,13 @@
import { RemoteData } from '@shared/application/remote-data';
/**
* Test-only constructors for `RemoteData` one line per variant, so a spec
* builds fixtures through the same tagged-union shape production code renders
* (`foldRemote`/`<app-async>`), never a hand-rolled literal that could drift
* from the real type.
*/
export const loading = <E = never, T = never>(): RemoteData<E, T> => ({ tag: 'Loading' });
export const success = <T, E = never>(value: T): RemoteData<E, T> => ({ tag: 'Success', value });
export const failure = <E, T = never>(error: E): RemoteData<E, T> => ({ tag: 'Failure', error });
+14
View File
@@ -0,0 +1,14 @@
import { Result } from '@shared/kernel/fp';
/**
* Unwrap a `Result` produced by a REAL `parse*` value-object parser, throwing
* if it isn't `ok`. This is the only sanctioned way for a spec to obtain a
* branded value-object type it closes off the `'garbage' as Postcode` cast
* route, since the only door to the branded type is the parser itself.
*/
export function unwrapOk<E, T>(result: Result<E, T>): T {
if (!result.ok) {
throw new Error(`unwrapOk: expected ok, got error: ${JSON.stringify(result.error)}`);
}
return result.value;
}