diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts index b9d2f09..a7fff87 100644 --- a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts @@ -1,12 +1,7 @@ import { describe, it, expect } from 'vitest'; import { expectTag } from '@shared/testing/expect-tag'; -import { BesluitState, reduce, initial } from './besluit.machine'; - -const editingWith = (besluit: string, toelichting = ''): BesluitState => ({ - tag: 'Editing', - draft: { besluit, toelichting }, - errors: {}, -}); +import { reduce, initial } from './besluit.machine'; +import { givenBesluit } from './besluit.testing'; describe('besluit reduce', () => { it('SetField updates the draft while editing', () => { @@ -15,17 +10,23 @@ describe('besluit reduce', () => { }); it('Submit with no besluit chosen stays Editing and reports a field error', () => { - const s = reduce(editingWith(''), { tag: 'Submit' }); + const s = reduce(initial, { tag: 'Submit' }); expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy(); }); it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => { - const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' }); + const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' }); + const s = reduce(editingAfwijzen, { tag: 'Submit' }); expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy(); }); it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => { - const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const editingGoedkeuren = givenBesluit({ + tag: 'SetField', + key: 'besluit', + value: 'Goedkeuren', + }); + const s = reduce(editingGoedkeuren, { tag: 'Submit' }); expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Goedkeuren', toelichting: undefined, @@ -33,7 +34,11 @@ describe('besluit reduce', () => { }); it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => { - const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' }); + const editingAfwijzenWithToelichting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Afwijzen' }, + { tag: 'SetField', key: 'toelichting', value: ' niet erkend ' }, + ); + const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' }); expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Afwijzen', toelichting: 'niet erkend', @@ -41,24 +46,36 @@ describe('besluit reduce', () => { }); it('SubmitConfirmed maps Submitting to Submitted', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted'); }); it('SubmitFailed maps Submitting to Failed with the error', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' }); }); it('Retry re-submits a failure', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting'); }); it('Reset returns to the initial editing state', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial); }); }); diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts new file mode 100644 index 0000000..e268c38 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './besluit.machine'; + +/** Replay real `BesluitMsg`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 givenBesluit = given(reduce, initial); diff --git a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts index e37c46b..5ce6a1d 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts @@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b import { RichTextBlock } from '@shared/kernel/rich-text'; import { PlaceholderDef } from './placeholders'; import { BriefState, reduce } from './brief.machine'; +import { givenBrief } from './brief.testing'; const placeholders: PlaceholderDef[] = [ { key: 'naam', label: 'Naam', autoResolvable: true }, @@ -64,15 +65,15 @@ const decisions: BriefDecisions = { canRevealBigNummer: true, }; -const loaded = ( - status: BriefStatus = { tag: 'draft' }, - sections?: Brief['sections'], -): BriefState => ({ - tag: 'loaded', - brief: briefWith(status, sections), - availablePassages: lib, - decisions, -}); +// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2) +// instead of hand-assembling the 'loaded' state directly. +const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => + givenBrief({ + tag: 'BriefLoaded', + brief: briefWith(status, sections), + availablePassages: lib, + decisions, + }); const sectionBlocks = (s: BriefState, key: string) => s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; @@ -128,12 +129,12 @@ describe('brief.machine reduce', () => { it('BesluitSelected deep-copies content — later library mutation does not leak in', () => { const passage = libPassage('intro', 'kern'); // shared → offered for any besluit - const st: BriefState = { - tag: 'loaded', + const st = givenBrief({ + tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [passage], decisions, - }; + }); const s = reduce(st, besluit('positief')); // Mutate the source passage object after composition. (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; diff --git a/apps/ssp/src/app/brief/domain/brief.testing.ts b/apps/ssp/src/app/brief/domain/brief.testing.ts new file mode 100644 index 0000000..56af8ca --- /dev/null +++ b/apps/ssp/src/app/brief/domain/brief.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './brief.machine'; + +/** Replay real `BriefMsg`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 givenBrief = given(reduce, initial); diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts index c5360f3..9987b85 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { expectTag } from '@shared/testing/expect-tag'; import { - Answers, initial, STEPS, lageUren, @@ -15,14 +14,7 @@ import { reduce, IntakeState, } from './intake.machine'; - -const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ - tag: 'Answering', - answers, - cursor, - errors: {}, - scholingThreshold, -}); +import { givenIntake } from './intake.testing'; describe('STEPS (fixed) and inline questions', () => { it('always has the same three steps', () => { @@ -31,12 +23,12 @@ describe('STEPS (fixed) and inline questions', () => { it('reveals the buitenland detail questions inline only when worked abroad', () => { // No new step; instead these fields become required within the buitenland step. - expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked - expect( - expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land, - ).toBeTruthy(); - expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) - expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1); + const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); + expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked + expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy(); + const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves) + expect(expectTag(next(domestic), 'Answering').cursor).toBe(1); }); it('reveals the scholing question only when NL-hours are below the threshold', () => { @@ -49,9 +41,13 @@ describe('STEPS (fixed) and inline questions', () => { expect(lageUren({ uren: '1500' }, 1000)).toBe(false); expect(lageUren({ uren: '1500' }, 2000)).toBe(true); // And the threshold from state flows through submit: - const lowThreshold = submit( - answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000), + const lowThresholdState = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'punten', value: '200' }, + { tag: 'SetPolicy', scholingThreshold: 2000 }, ); + const lowThreshold = submit(lowThresholdState); expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy(); }); @@ -65,18 +61,21 @@ describe('navigation', () => { }); it('Next advances once the step is valid', () => { - const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering'); + const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + const s = expectTag(next(domestic), 'Answering'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('werk'); }); it('editing an answer leaves the cursor fixed (steps never collapse)', () => { + const atWerk = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }, + { tag: 'SetAnswer', key: 'land', value: 'België' }, + { tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' }, + { tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1 + ); const edited = expectTag( - reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { - tag: 'SetAnswer', - key: 'buitenlandGewerkt', - value: 'nee', - }), + reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }), 'Answering', ); expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change @@ -87,57 +86,86 @@ describe('navigation', () => { }); it('gaNaarStap jumps back to an earlier step, clearing errors', () => { - const s = answering({ buitenlandGewerkt: 'nee' }, 2); + const s = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + { tag: 'Next' }, // cursor 1 -> 2 + ); expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0); }); it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => { - const s = answering({ buitenlandGewerkt: 'nee' }, 1); + const s = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + ); expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op - const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2)); + const atReview = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + { tag: 'Next' }, // cursor 1 -> 2 + ); + const submitting = submit(atReview); expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op }); }); describe('submit', () => { // High hours: no scholing question, so no punten is asked or collected. - const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' }; + const highUren = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + ); it('reaches Submitting ONLY with valid answers', () => { // Bad punten only blocks when scholing was followed (otherwise punten is ignored). - expect( - submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }), - ).tag, - ).toBe('Answering'); - const good = expectTag(submit(answering(complete)), 'Submitting'); + const badPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: 'x' }, + ); + expect(submit(badPunten).tag).toBe('Answering'); + const good = expectTag(submit(highUren), 'Submitting'); expect(good.data.uren).toBe(4160); expect(good.data.punten).toBeUndefined(); // not collected without scholing }); it('punten is required only when aanvullende scholing was gevolgd', () => { // scholing = ja but punten missing -> blocked on punten. - const missing = expectTag( - submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })), - 'Answering', + const scholingJaNoPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); + const missing = expectTag(submit(scholingJaNoPunten), 'Answering'); expect(missing.errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. - expect( - submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag, - ).toBe('Submitting'); + const scholingNee = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' }, + ); + expect(submit(scholingNee).tag).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { - const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); - expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered - const withScholing = expectTag( - submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }), - ), - 'Submitting', + const lowUrenNoScholing = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, ); + const noScholing = submit(lowUrenNoScholing); + expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered + const lowUrenWithScholing = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: '200' }, + ); + const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting'); expect(withScholing.data.aanvullendeScholing).toBe(true); expect(withScholing.data.punten).toBe(200); }); @@ -145,38 +173,36 @@ describe('submit', () => { it('does not require punten for a hidden question (WP-69 §6)', () => { // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // threshold — the template hides the question, so punten must not be required either. - const good = expectTag( - submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })), - 'Submitting', + const staleScholingNoPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); + const good = expectTag(submit(staleScholingNoPunten), 'Submitting'); expect(good.data.aanvullendeScholing).toBeUndefined(); }); it('drops punten when raising uren hides the question (WP-69 §6)', () => { // Same stale answer, but this time punten was also filled in while uren was low. - const good = expectTag( - submit( - answering({ - buitenlandGewerkt: 'nee', - uren: '1500', - scholingGevolgd: 'ja', - punten: '150', - }), - ), - 'Submitting', + const staleScholingWithPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: '150' }, ); + const good = expectTag(submit(staleScholingWithPunten), 'Submitting'); // ValidIntake stays honest: neither the stale 'ja' nor its punten leak through. expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.punten).toBeUndefined(); }); it('resolve maps Submitting to Submitted on a successful submit', () => { - const submitting = submit(answering(complete)); + const submitting = submit(highUren); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); }); it('resolve maps Submitting to Failed on a failed submit', () => { - const submitting = submit(answering(complete)); + const submitting = submit(highUren); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); }); diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 30aac1a..f137d23 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,9 +1,8 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; -import { initialUpload } from '@shared/domain/upload.machine'; +import { given } from '@shared/testing/machine'; import { expectTag } from '@shared/testing/expect-tag'; import { - Draft, RegistratieState, STEPS, initial, @@ -21,28 +20,50 @@ import { resolve, reduce, } from './registratie-wizard.machine'; +import { givenRegistratieWizard } from './registratie-wizard.testing'; -const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ - tag: 'Invullen', - draft: { antwoorden: {}, ...draft }, - cursor, - errors: {}, - upload: initialUpload, -}); +/** + * Every fixture below is built by replaying real `RegistratieMsg`s through the + * real `reduce` (ADR-0006 §2) — never a hand-assembled `RegistratieState` + * literal. Each helper reaches a named point in the wizard one transition at a + * time, so a spec can only assert on a state the reducer can actually produce. + */ +const toAdresValid = (): RegistratieState => + givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'post' }, + ); -const validAdres = { - straat: 'Lange Voorhout 9', - postcode: '2514 EA', - woonplaats: 'Den Haag', - correspondentie: 'post' as const, - adresHerkomst: 'brp' as const, -}; -const validDraft: Partial = { - ...validAdres, - diplomaId: 'd1', - beroep: 'Arts', - diplomaHerkomst: 'duo', -}; +const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet + +const toBeroepStepWithDiploma = (): RegistratieState => + reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }); + +const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2 + +const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' }); + +// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a +// field or choosing a diploma is never gated by cursor position, so this is a +// real, reachable 'Invullen' state at cursor 0 — matching what `submit()` +// (which validates the whole draft regardless of cursor) is exercised against +// in the tests below. +const toFullDraftAtCursor0 = (): RegistratieState => + givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }, + ); describe('STEPS (fixed)', () => { it('always has the same three steps', () => { @@ -60,46 +81,55 @@ describe('navigation', () => { }); it('Next advances once the adres step is valid', () => { - const s = expectTag(next(invullen(validAdres)), 'Invullen'); + const s = expectTag(next(toAdresValid()), 'Invullen'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('beroep'); }); it('requires a valid e-mail only when the channel is email', () => { - const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen'); + const withEmailChannel = givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'email' }, + ); + const bad = expectTag(next(withEmailChannel), 'Invullen'); expect(bad.errors.email).toBeTruthy(); const good = expectTag( - next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })), + next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })), 'Invullen', ); expect(good.cursor).toBe(1); }); it('beroep step requires a chosen diploma', () => { - const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen'); + const noDiploma = expectTag(next(toBeroepStep()), 'Invullen'); expect(noDiploma.cursor).toBe(1); expect(noDiploma.errors.diploma).toBeTruthy(); - const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen'); + const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen'); expect(withDiploma.cursor).toBe(2); }); it('Back never goes below the first step and preserves the draft', () => { expect(back(initial)).toBe(initial); - const s = expectTag(back(invullen(validDraft, 2)), 'Invullen'); + const s = expectTag(back(toControleStep()), 'Invullen'); expect(s.cursor).toBe(1); expect(s.draft.beroep).toBe('Arts'); }); it('GaNaarStap only jumps backwards', () => { - expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0); - expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected + expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0); + expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected }); }); describe('adres origin (BRP vs handmatig)', () => { it('prefillAdres flags origin brp', () => { const s = expectTag( - prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'), + prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'), 'Invullen', ); expect(s.draft.adresHerkomst).toBe('brp'); @@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => { }); it('editing a prefilled address field flips origin to handmatig', () => { - const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); + const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'); const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen'); expect(edited.draft.adresHerkomst).toBe('handmatig'); }); it('typing an address with no BRP prefill yields handmatig', () => { - const s = expectTag(setField(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen'); + const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen'); expect(s.draft.adresHerkomst).toBe('handmatig'); }); it('editing the e-mail field does not change the address origin', () => { - const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); + const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'); const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen'); expect(edited.draft.adresHerkomst).toBe('brp'); }); it('a manually entered address still submits (only manual diploma is gated)', () => { - const s = submit( - invullen({ - straat: 'Kerkstraat 1', - postcode: '1234 AB', - woonplaats: 'Utrecht', - correspondentie: 'post', - adresHerkomst: 'handmatig', - diplomaId: 'd1', - beroep: 'Arts', - diplomaHerkomst: 'duo', - }), + const manualAdres = givenRegistratieWizard( + { tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' }, + { tag: 'SetField', key: 'postcode', value: '1234 AB' }, + { tag: 'SetField', key: 'woonplaats', value: 'Utrecht' }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }, ); - const indienen = expectTag(s, 'Indienen'); + const indienen = expectTag(submit(manualAdres), 'Indienen'); expect(indienen.data.adresHerkomst).toBe('handmatig'); }); }); describe('kiesDiploma', () => { it('derives the beroep from the chosen diploma and flags origin duo', () => { - const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen'); + const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen'); expect(s.draft.diplomaId).toBe('d9'); expect(s.draft.beroep).toBe('Verpleegkundige'); expect(s.draft.diplomaHerkomst).toBe('duo'); @@ -152,7 +177,7 @@ describe('kiesDiploma', () => { describe('policy questions (geldigheidsvragen)', () => { it('a diploma with questions blocks Next until they are answered', () => { - let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']); + let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']); const blocked = expectTag(next(s), 'Invullen'); expect(blocked.cursor).toBe(1); expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy(); @@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => { }); it('validateAll keeps only the answers to the questions that applied', () => { - let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']); + // DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any + // diploma was chosen. That combination cannot occur in the real reducer — + // advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be + // set. Replayed here at cursor 1 instead; submit() validates the whole draft + // regardless of cursor, so the assertion below is unaffected. + let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']); s = setAntwoord(s, 'nl-taalvaardigheid', 'ja'); s = setAntwoord(s, 'stale', 'x'); // not in vraagIds const done = expectTag(submit(s), 'Indienen'); @@ -173,14 +203,16 @@ describe('manual diploma fallback', () => { const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting']; it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => { - const s = expectTag(kiesHandmatig(invullen(validAdres, 1), maxIds), 'Invullen'); + const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen'); expect(s.draft.diplomaHerkomst).toBe('handmatig'); expect(s.draft.beroep).toBeUndefined(); expect(s.draft.vraagIds).toEqual(maxIds); }); it('requires a declared beroep + all maximal questions before submit', () => { - let s = kiesHandmatig(invullen(validAdres, 2), maxIds); + // DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination + // as above. Replayed at cursor 1; submit() is cursor-agnostic. + let s = kiesHandmatig(toBeroepStep(), maxIds); expect(submit(s).tag).toBe('Invullen'); // no beroep declared s = declareerBeroep(s, 'Fysiotherapeut'); expect(submit(s).tag).toBe('Invullen'); // questions unanswered @@ -193,11 +225,11 @@ describe('manual diploma fallback', () => { describe('submit', () => { it('stays in Invullen when the draft is incomplete (no diploma)', () => { - expect(submit(invullen(validAdres)).tag).toBe('Invullen'); + expect(submit(toAdresValid()).tag).toBe('Invullen'); }); it('reaches Indienen with a complete, valid draft, carrying its data', () => { - const good = expectTag(submit(invullen(validDraft)), 'Indienen'); + const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen'); expect(good.data.beroep).toBe('Arts'); expect(good.data.adres.postcode).toBe('2514 EA'); expect(good.data.adresHerkomst).toBe('brp'); @@ -205,43 +237,18 @@ describe('submit', () => { it('resolve maps Indienen to Ingediend with the referentie', () => { const ingediend = expectTag( - resolve(submit(invullen(validDraft)), ok('BIG-2026-001')), + resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')), 'Ingediend', ); expect(ingediend.referentie).toBe('BIG-2026-001'); }); it('resolve maps Indienen to Mislukt on a failed submit', () => { - expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt'); + expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt'); }); }); describe('reduce (message-driven happy path)', () => { - // Each helper replays real messages through the real reducer up to the named - // point — no hand-assembled state literal — so each test below Givens its own - // starting point independently, one transition at a time. - const toBeroepStep = (): RegistratieState => { - let s: RegistratieState = initial; - s = reduce(s, { - tag: 'PrefillAdres', - straat: 'Lange Voorhout 9', - postcode: '2514 EA', - woonplaats: 'Den Haag', - }); - s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); - return reduce(s, { tag: 'Next' }); - }; - const toControleStep = (): RegistratieState => { - const s = reduce(toBeroepStep(), { - tag: 'KiesDiploma', - diplomaId: 'd1', - beroep: 'Arts', - vraagIds: [], - }); - return reduce(s, { tag: 'Next' }); - }; - const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' }); - it('adres and correspondentie set, Next advances from adres to beroep', () => { // Given the initial wizard. // When the adres is prefilled, correspondentie chosen, and Next dispatched... @@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => { }); it('SubmitFailed moves Indienen to Mislukt', () => { - const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { + const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => { }); it('Retry returns Mislukt to Indienen with the same data', () => { - const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { + const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => { it('routes Upload messages through the upload reducer', () => { const s = expectTag( - reduce(invullen(validDraft), { + reduce(toFullDraftAtCursor0(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }), @@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => { }); it('blocks the beroep step until a required category is satisfied', () => { - let s = reduce(invullen(validDraft, 1), { + let s = reduce(toBeroepStepWithDiploma(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); @@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => { }); it('includes delivery refs in the submitted data', () => { - let s = reduce(invullen(validDraft), { + let s = reduce(toFullDraftAtCursor0(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts new file mode 100644 index 0000000..2949ccf --- /dev/null +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './registratie-wizard.machine'; + +/** Replay real `RegistratieMsg`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 givenRegistratieWizard = given(reduce, initial); diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index 13b661c..c619ec3 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | --------------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | **implemented** | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md new file mode 100644 index 0000000..704ced1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md @@ -0,0 +1,155 @@ +# RB-31 — replay real messages in the four hand-rolling machine specs + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-010 · +`99-backlog.md` RB-31 + +RB-31 replaces four hand-rolled state-literal fixtures with `given(reduce, initial)` +replays, per ADR-0006 §2 ("no object is built directly; a fixture is the result of +running real `Msg`s through the real `reduce`"). This is a fixture-construction change +only. No `*.machine.ts` production file was touched. + +## What was wrong + +Four machine specs built their starting `Answering`/`Invullen`/`Editing`/`loaded` state +with a local object-literal helper instead of replaying messages: + +- `intake.machine.spec.ts` — `answering(answers, cursor, scholingThreshold)` hardcoded + `errors: {}`. `intake.testing.ts` (exporting `givenIntake`) already existed next to + it and was already correct, but was imported only by `intake.acceptance.spec.ts`. +- `registratie-wizard.machine.spec.ts` — `invullen(draft, cursor)` hardcoded `errors: {}` + and `upload: initialUpload`. +- `besluit.machine.spec.ts` — `editingWith(besluit, toelichting)` hardcoded `errors: {}`. +- `brief.machine.spec.ts` — `loaded(status, sections)` built the `'loaded'` tag object + directly (no `errors` field on this union, so this one did not hardcode `errors: {}`, + but it still skipped the reducer). + +## What changed + +| File | Change | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts` | Removed the local `answering(...)` helper. Every fixture is now built with the existing `givenIntake` (imported from `intake.testing.ts`), matching `intake.acceptance.spec.ts`'s own style. | +| `apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts` | New. One-liner: `export const givenRegistratieWizard = given(reduce, initial)`. | +| `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` | Removed the local `invullen(...)` helper and the now-unused `validAdres`/`validDraft`/`Draft`/`initialUpload` fixtures. Added module-level replay helpers (`toAdresValid`, `toBeroepStep`, `toBeroepStepWithDiploma`, `toControleStep`, `toIndienen`, `toFullDraftAtCursor0`) built from `givenRegistratieWizard` + `reduce`, reused across every `describe` block (the file's pre-existing `reduce (message-driven happy path)` block already had three of these, scoped locally; they are now module-level and shared, removing the duplication). | +| `apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts` | New. One-liner: `export const givenBesluit = given(reduce, initial)`. | +| `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts` | Removed the local `editingWith(...)` helper. Every fixture is now built with `givenBesluit` (or, for the empty-draft case, the machine's own `initial` — see below). | +| `apps/ssp/src/app/brief/domain/brief.testing.ts` | New. One-liner: `export const givenBrief = given(reduce, initial)`. | +| `apps/ssp/src/app/brief/domain/brief.machine.spec.ts` | Rewrote the `loaded(...)` helper to replay a real `BriefLoaded` message through `givenBrief` instead of building the `'loaded'` tag object directly. Also converted one further inline `BriefState` literal in the "deep-copies content" test to the same replay (same anti-pattern, same file, not named individually by the finding's evidence list but visibly the same shape — see "Beyond the letter of the finding" below). | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-31's status cell: `open` → `implemented`. | + +## Message sequence used per machine + +### `intake.machine.spec.ts` + +Every fixture in this file has `cursor` 0, 1, or 2 and the default or an overridden +`scholingThreshold`. All are built as direct sequences of `SetAnswer`/`Next`/`SetPolicy` +through `givenIntake`, mirroring `intake.acceptance.spec.ts`'s own explicit style (no new +generic wrapper was added — the finding's own resolution is "wire the spec to +`givenIntake`", not "invent a second helper"). Representative sequences: + +- Cursor 0, plain answers (most tests): `givenIntake({SetAnswer buitenlandGewerkt}, {SetAnswer uren}, ...)`. +- Cursor 0 with an overridden threshold: adds a trailing `{tag:'SetPolicy', scholingThreshold: N}`. +- Cursor 1 ("editing an answer leaves the cursor fixed"): `SetAnswer buitenlandGewerkt=ja`, + `SetAnswer land`, `SetAnswer buitenlandseUren`, `Next` (buitenland step now valid -> + cursor 1), then the edit under test. +- Cursor 2 ("gaNaarStap jumps back..."): the above sequence continued with + `SetAnswer uren=4160`, `Next` (werk step valid -> cursor 2). + +No drift found here: `intake.testing.ts` already existed correctly, and every one of the +nine cursor/threshold combinations the old literal used turned out to be reachable by a +real message sequence. + +### `registratie-wizard.machine.spec.ts` + +- `toAdresValid()` = `PrefillAdres(straat, postcode, woonplaats)`, `SetCorrespondentie('post')` + — cursor 0, matches the old `invullen(validAdres)`. +- `toBeroepStep()` = `reduce(toAdresValid(), Next)` — cursor 0 -> 1, no diploma. Matches + `invullen(validAdres, 1)`. +- `toBeroepStepWithDiploma()` = `reduce(toBeroepStep(), KiesDiploma('d1','Arts',[]))` — + cursor 1, diploma set. Matches `invullen(validDraft, 1)`. +- `toControleStep()` = `reduce(toBeroepStepWithDiploma(), Next)` — cursor 1 -> 2. Matches + `invullen(validDraft, 2)`. +- `toFullDraftAtCursor0()` = `PrefillAdres`, `SetCorrespondentie('post')`, `KiesDiploma(...)`, + never advancing the cursor — matches `invullen(validDraft)` (cursor 0). `SetField`/ + `SetCorrespondentie`/`KiesDiploma` carry no cursor gate, so setting every field before + ever pressing `Next` is a genuinely reachable cursor-0 state with a complete draft. +- `invullen({})` (five call sites) is exactly the machine's own `initial` value + (`{tag:'Invullen', draft:{antwoorden:{}}, cursor:0, errors:{}, upload:initialUpload}`) + — replaced with `initial` directly, no message needed. + +### `besluit.machine.spec.ts` + +- `editingWith('')` is exactly `initial` (`draft:{besluit:'',toelichting:''}`) — replaced + with `initial` directly. +- `editingWith('Afwijzen')` / `editingWith('Goedkeuren')` = `givenBesluit({SetField besluit})`. +- `editingWith('Afwijzen', ' niet erkend ')` = `givenBesluit({SetField besluit=Afwijzen}, {SetField toelichting=' niet erkend '})`. +- Every `Submitting`/`Failed` fixture is now `givenBesluit({SetField besluit}, {Submit})` + composed further with `reduce(..., {SubmitFailed}/{Retry}/{Reset})`. + +### `brief.machine.spec.ts` + +- `loaded(status, sections)` = `givenBrief({tag:'BriefLoaded', brief: briefWith(status, sections), availablePassages: lib, decisions})`. + This is a 1:1 replacement: the `'BriefLoaded'` reducer case sets exactly + `{tag:'loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions}` + — the same three fields the old literal built by hand, with the same values. No drift. + +## Drift found + +Two tests in `registratie-wizard.machine.spec.ts` asserted against a cursor value the +real reducer cannot reach: + +- `'validateAll keeps only the answers to the questions that applied'` built + `invullen(validAdres, 2)` then called `kiesDiploma(...)` on it — i.e. a wizard already + at cursor 2 (`controle`) with **no diploma chosen yet**. That is impossible by replay: + advancing past `beroep` (cursor 1 -> 2) requires `validateStep('beroep', ...)` to pass, + which requires `diplomaId` and `beroep` to already be set. The literal encoded a state + the reducer can never produce. +- `'requires a declared beroep + all maximal questions before submit'` had the same + problem: `invullen(validAdres, 2)` then `kiesHandmatig(...)`, which leaves `beroep` + `undefined` — again a cursor-2 state that could never have been reached via `Next`. + +In both cases the cursor value is not actually load-bearing for the test: `submit()` +calls `validateAll(s.draft, s.upload)`, which validates every step regardless of +`s.cursor`. Both tests were re-pointed at the reachable **cursor-1** equivalent +(`toBeroepStep()` then `kiesDiploma`/`kiesHandmatig`), with an inline `// DRIFT (see +rb-31.md)` comment at each site. No assertion changed — both tests still check the same +`submit(...)` outcome on the same field values; only the now-irrelevant cursor number +in the starting fixture moved from an unreachable 2 to a reachable 1. + +No other named state, across any of the four machines, turned out to be unreachable. + +## Beyond the letter of the finding + +`brief.machine.spec.ts`'s `'BesluitSelected deep-copies content...'` test built a second, +separate `BriefState` literal inline (not through the `loaded(...)` helper the finding +cited) — same anti-pattern, same file, not itself named in ADR-C-010's evidence list. +Since it sits inside one of the four files already being brought into line, and the fix +is the identical one-line `BriefLoaded` replay, it was converted too rather than left as +a residual violation in a file this ticket otherwise fixed. No other spec, in any other +file, was touched. + +## `intake.acceptance.spec.ts` — confirmed unaffected + +`intake.testing.ts` and its `givenIntake` export were not modified. The acceptance spec +still imports and uses `givenIntake` exactly as before; it was not read or edited by +this ticket beyond confirming (by running it) that it still passes. + +## Verification + +- `npx ng test ssp`: 44 test files, 276 tests, all passing (includes + `intake.machine.spec.ts`, `intake.acceptance.spec.ts`, + `registratie-wizard.machine.spec.ts`, `brief.machine.spec.ts`, and every other ssp + spec, unmodified ones included). +- `npx ng test behandelportal`: 6 test files, 37 tests, all passing (includes + `besluit.machine.spec.ts`). +- `npx eslint` on all seven touched/added files: clean. +- `npx prettier --check` on all seven touched/added files: clean (one file needed + `--write` once, then verified clean). +- `npm run ci`: see the commit message / session report for the exit code and step + count. + +## What this ticket did not touch + +No `*.machine.ts` reducer or production domain file was changed — every fixture change +is confined to the four `*.spec.ts` files and the three new `*.testing.ts` files listed +above. No other machine spec (including `change-request.machine.spec.ts`, which the +finding notes already honours the idiom inline) was touched.