diff --git a/.dependency-cruiser.base.js b/.dependency-cruiser.base.js index 93fb3f3..ff945cd 100644 --- a/.dependency-cruiser.base.js +++ b/.dependency-cruiser.base.js @@ -109,6 +109,17 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) }, }, + { + name: 'no-testing-in-production', + comment: + 'Test-only fixture helpers (libs/shared/src/testing/** and any *.testing.ts) are reached from specs/stories only — production code gets its data through the real domain/application doors (ADR-0006), never the test escape hatch.', + severity: 'error', + from: { + pathNot: '\\.(spec|stories)\\.ts$|\\.testing\\.ts$|^libs/shared/src/testing/', + }, + to: { path: '^libs/shared/src/testing/|\\.testing\\.ts$' }, + }, + // --- Hygiene (cheap wins a graph makes obvious) --- { name: 'no-circular', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca53ff..011aab4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,10 +75,14 @@ jobs: if: needs.changes.outputs.frontend == 'true' && steps.node-modules-cache.outputs.cache-hit != 'true' - run: npm run lint if: needs.changes.outputs.frontend == 'true' + - run: npm run typecheck + if: needs.changes.outputs.frontend == 'true' - run: npm run format:check if: needs.changes.outputs.frontend == 'true' - run: npm run check:tokens if: needs.changes.outputs.frontend == 'true' + - run: npm run check:seam + if: needs.changes.outputs.frontend == 'true' frontend: needs: changes @@ -107,6 +111,9 @@ jobs: # Showcase snippets must match their real source regions (WP-39, no drift). - run: npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts if: needs.changes.outputs.frontend == 'true' + # Behaviour spec must match the real test names it's generated from (WP-71, no drift). + - run: npm run gen:behaviour-spec && git diff --exit-code libs/shared/docs/behaviour-spec.mdx + if: needs.changes.outputs.frontend == 'true' # Runs the full suite (both apps + both shared libraries, WP-67) AND reports coverage # (WP-46, report-only — no thresholds, so it can't fail on coverage; it still fails on # a failing test, like `npm test` did). 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 77a068a..b9d2f09 100644 --- a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts @@ -1,4 +1,5 @@ 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 => ({ @@ -10,26 +11,22 @@ const editingWith = (besluit: string, toelichting = ''): BesluitState => ({ describe('besluit reduce', () => { it('SetField updates the draft while editing', () => { const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }); - expect(s.tag).toBe('Editing'); - expect((s as Extract).draft.besluit).toBe('Goedkeuren'); + expect(expectTag(s, 'Editing').draft.besluit).toBe('Goedkeuren'); }); it('Submit with no besluit chosen stays Editing and reports a field error', () => { const s = reduce(editingWith(''), { tag: 'Submit' }); - expect(s.tag).toBe('Editing'); - expect((s as Extract).errors.besluit).toBeTruthy(); + 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' }); - expect(s.tag).toBe('Editing'); - expect((s as Extract).errors.toelichting).toBeTruthy(); + 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' }); - expect(s.tag).toBe('Submitting'); - expect((s as Extract).data).toEqual({ + expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Goedkeuren', toelichting: undefined, }); @@ -37,8 +34,7 @@ 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' }); - expect(s.tag).toBe('Submitting'); - expect((s as Extract).data).toEqual({ + expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Afwijzen', toelichting: 'niet erkend', }); diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index 24b2604..fb223fa 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { expectTag } from '@shared/testing/expect-tag'; import { OrgTemplate, OrgTemplateAdminView } from './org-template'; import { OrgTemplateState, reduce } from './org-template.machine'; import { DocumentCategory } from '@shared/upload/upload.machine'; @@ -40,9 +41,7 @@ const logoCategory: DocumentCategory = { describe('org-template.machine', () => { it('DraftLoaded moves to loaded with the draft, clean', () => { - const s = loaded(); - expect(s.tag).toBe('loaded'); - if (s.tag !== 'loaded') return; + const s = expectTag(loaded(), 'loaded'); expect(s.draft.orgName).toBe('CIBG'); expect(s.subOrgId).toBe('cibg-registers'); expect(s.unsentBriefs).toBe(2); @@ -55,33 +54,44 @@ describe('org-template.machine', () => { }); it('FieldEdited edits the draft and marks dirty', () => { - const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }); - expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw'); - expect(s.tag === 'loaded' && s.dirty).toBe(true); + const s = expectTag( + reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }), + 'loaded', + ); + expect(s.draft.orgName).toBe('CIBG Nieuw'); + expect(s.dirty).toBe(true); }); it('MarginEdited edits one edge and marks dirty', () => { - const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }); - expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40); - expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20); - expect(s.tag === 'loaded' && s.dirty).toBe(true); + const s = expectTag( + reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }), + 'loaded', + ); + expect(s.draft.margins.topMm).toBe(40); + expect(s.draft.margins.leftMm).toBe(20); + expect(s.dirty).toBe(true); }); it('DraftSaved clears dirty when the saved draft is the current one', () => { - const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }); - const savedDraft = edited.tag === 'loaded' ? edited.draft : template; - const s = reduce(edited, { tag: 'DraftSaved', savedDraft }); - expect(s.tag === 'loaded' && s.dirty).toBe(false); - expect(s.tag === 'loaded' && s.draft.orgName).toBe('X'); + const edited = expectTag( + reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), + 'loaded', + ); + const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded'); + expect(s.dirty).toBe(false); + expect(s.draft.orgName).toBe('X'); }); it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => { - const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }); - const savedDraft = editing.tag === 'loaded' ? editing.draft : template; + const editing = expectTag( + reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }), + 'loaded', + ); + const savedDraft = editing.draft; // a further edit changes the draft reference before the save resolves const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' }); - const s = reduce(raced, { tag: 'DraftSaved', savedDraft }); - expect(s.tag === 'loaded' && s.dirty).toBe(true); + const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded'); + expect(s.dirty).toBe(true); }); it('edits are no-ops in non-loaded states', () => { @@ -107,12 +117,15 @@ describe('org-template.machine', () => { fileSizeMb: 0.1, }, }); - const done = reduce(selected, { - tag: 'Upload', - msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, - }); - expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1'); - expect(done.tag === 'loaded' && done.dirty).toBe(true); + const done = expectTag( + reduce(selected, { + tag: 'Upload', + msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, + }), + 'loaded', + ); + expect(done.draft.logoDocumentId).toBe('doc-1'); + expect(done.dirty).toBe(true); }); it('removing the logo clears logoDocumentId + dirty', () => { @@ -120,12 +133,15 @@ describe('org-template.machine', () => { tag: 'Upload', msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' }, }); - const removed = reduce(withLogo, { - tag: 'Upload', - msg: { type: 'UploadRemoved', localId: 'a' }, - }); - expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined(); - expect(removed.tag === 'loaded' && removed.dirty).toBe(true); + const removed = expectTag( + reduce(withLogo, { + tag: 'Upload', + msg: { type: 'UploadRemoved', localId: 'a' }, + }), + 'loaded', + ); + expect(removed.draft.logoDocumentId).toBeUndefined(); + expect(removed.dirty).toBe(true); }); it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => { @@ -133,12 +149,15 @@ describe('org-template.machine', () => { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [logoCategory] }, }); - const switched = reduce(withCat, { - tag: 'DraftLoaded', - view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }), - }); - expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1); - expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0); - expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid'); + const switched = expectTag( + reduce(withCat, { + tag: 'DraftLoaded', + view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }), + }), + 'loaded', + ); + expect(switched.upload.categories).toHaveLength(1); + expect(switched.upload.uploads).toHaveLength(0); + expect(switched.subOrgId).toBe('cibg-vakbekwaamheid'); }); }); diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie-has-progress.spec.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie-has-progress.spec.ts index 74a4156..e271d8f 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie-has-progress.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie-has-progress.spec.ts @@ -1,15 +1,27 @@ import { describe, it, expect } from 'vitest'; -import { hasProgress, initial, WizardState } from './herregistratie.machine'; +import { given } from '@shared/testing/machine'; +import { expectTag } from '@shared/testing/expect-tag'; +import { hasProgress, initial, reduce } from './herregistratie.machine'; -const editing = initial as Extract; +const wizard = given(reduce, initial); describe('herregistratie hasProgress', () => { it('is false for a fresh form', () => { - expect(hasProgress(editing)).toBe(false); + expect(hasProgress(expectTag(initial, 'Editing'))).toBe(false); }); it('is true once a field is filled or the user advances', () => { - expect(hasProgress({ ...editing, draft: { uren: '40', jaren: '', punten: '' } })).toBe(true); - expect(hasProgress({ ...editing, step: 2 })).toBe(true); + const filled = expectTag(wizard({ tag: 'SetField', key: 'uren', value: '40' }), 'Editing'); + expect(hasProgress(filled)).toBe(true); + + const advanced = expectTag( + wizard( + { tag: 'SetField', key: 'uren', value: '4160' }, + { tag: 'SetField', key: 'jaren', value: '5' }, + { tag: 'Next' }, + ), + 'Editing', + ); + expect(hasProgress(advanced)).toBe(true); }); }); diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts index 28e1b86..d90f0ad 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { given } from '@shared/testing/machine'; +import { expectTag } from '@shared/testing/expect-tag'; import { initial, next, @@ -43,45 +44,56 @@ const toStep3 = (uren: string, punten: string, jaren = '5'): WizardState => 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(toStep1('4160')) as any).step).toBe(2); + expect(expectTag(next(initial), 'Editing').errors.uren).toBeTruthy(); + expect(expectTag(next(toStep1('4160')), 'Editing').step).toBe(2); }); it('next advances step 2 → 3 only when punten parses', () => { - 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); + expect(expectTag(next(toStep2('4160', 'x')), 'Editing').step).toBe(2); // invalid punten -> stays + expect(expectTag(next(toStep2('4160', 'x')), 'Editing').errors.punten).toBeTruthy(); + expect(expectTag(next(toStep2('4160', '200')), 'Editing').step).toBe(3); }); it('submit reaches Submitting ONLY from step 3 with fully valid data', () => { 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: [] }); + const good = expectTag(submit(toStep3('4160', '200')), 'Submitting'); + expect(good.data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); }); it('next requires BOTH step-1 fields (uren and jaren)', () => { - 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 + expect(expectTag(next(toStep1('4160', '')), 'Editing').errors.jaren).toBeTruthy(); // jaren empty -> stays + expect(expectTag(next(toStep1('4160', '')), 'Editing').step).toBe(1); + expect(expectTag(next(toStep1('4160', '5')), 'Editing').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(toStep3('1', '2')) as any).step).toBe(2); - expect((back(toStep2('1', '2')) as any).step).toBe(1); + expect(expectTag(back(toStep3('1', '2')), 'Editing').step).toBe(2); + expect(expectTag(back(toStep2('1', '2')), 'Editing').step).toBe(1); expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting }); - it('resolve maps Submitting to Submitted / Failed', () => { + it('resolve maps a successful Submitting to Submitted', () => { + // Given a wizard mid-submit. const submitting = submit(toStep3('4160', '200')); + + // When the submission resolves ok... + // Then the wizard reaches Submitted. expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); + }); + + it('resolve maps a failing Submitting to Failed', () => { + // Given a wizard mid-submit. + const submitting = submit(toStep3('4160', '200')); + + // When the submission resolves with an error... + // Then the wizard reaches Failed. expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); it('gaNaarStap jumps back to an earlier step, clearing errors', () => { - expect((gaNaarStap(toStep3('4160', '200'), 1) as any).step).toBe(1); + expect(expectTag(gaNaarStap(toStep3('4160', '200'), 1), 'Editing').step).toBe(1); }); it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => { @@ -125,14 +137,16 @@ describe('reduce (message-driven)', () => { }); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Editing'); - expect((s as any).errors.documenten).toBeTruthy(); + expect(expectTag(s, 'Editing').errors.documenten).toBeTruthy(); s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' }, }); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Submitting'); - expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]); + expect(expectTag(s, 'Submitting').data.documents).toEqual([ + { categoryId: 'bewijs', channel: 'post' }, + ]); }); it('SubmitFailed then Retry returns to Submitting with the same data', () => { @@ -143,7 +157,12 @@ describe('reduce (message-driven)', () => { expect(s.tag).toBe('Failed'); s = reduce(s, { tag: 'Retry' }); expect(s.tag).toBe('Submitting'); - expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] }); + expect(expectTag(s, 'Submitting').data).toEqual({ + uren: 4160, + jaren: 5, + punten: 200, + documents: [], + }); }); it('Seed mounts an arbitrary state', () => { diff --git a/apps/ssp/src/app/herregistratie/domain/intake-has-progress.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake-has-progress.spec.ts index ca0aa6c..0f7efd5 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake-has-progress.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake-has-progress.spec.ts @@ -1,15 +1,26 @@ import { describe, it, expect } from 'vitest'; -import { hasProgress, initial, IntakeState } from './intake.machine'; +import { given } from '@shared/testing/machine'; +import { expectTag } from '@shared/testing/expect-tag'; +import { hasProgress, initial, reduce } from './intake.machine'; -const answering = initial as Extract; +const intake = given(reduce, initial); describe('intake hasProgress', () => { it('is false for a fresh questionnaire', () => { - expect(hasProgress(answering)).toBe(false); + expect(hasProgress(expectTag(initial, 'Answering'))).toBe(false); }); it('is true once an answer is given or the user advances', () => { - expect(hasProgress({ ...answering, answers: { buitenlandGewerkt: 'ja' } })).toBe(true); - expect(hasProgress({ ...answering, cursor: 1 })).toBe(true); + const answered = expectTag( + intake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }), + 'Answering', + ); + expect(hasProgress(answered)).toBe(true); + + const advanced = expectTag( + intake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, { tag: 'Next' }), + 'Answering', + ); + expect(hasProgress(advanced)).toBe(true); }); }); 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 86c4be9..a248ce7 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; +import { expectTag } from '@shared/testing/expect-tag'; import { Answers, initial, @@ -31,9 +32,11 @@ 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((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy(); + expect( + expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land, + ).toBeTruthy(); expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) - expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1); + expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1); }); it('reveals the scholing question only when NL-hours are below the threshold', () => { @@ -50,31 +53,33 @@ describe('STEPS (fixed) and inline questions', () => { answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000), ); expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked - expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy(); + expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy(); }); }); describe('navigation', () => { it('Next is a no-op (sets an error) when the current step is invalid', () => { - const s = next(initial); // buitenland unanswered - expect(s.tag).toBe('Answering'); - expect((s as any).cursor).toBe(0); - expect((s as any).errors.buitenlandGewerkt).toBeTruthy(); + const s = expectTag(next(initial), 'Answering'); // buitenland unanswered + expect(s.cursor).toBe(0); + expect(s.errors.buitenlandGewerkt).toBeTruthy(); }); it('Next advances once the step is valid', () => { - const s = next(answering({ buitenlandGewerkt: 'nee' })); - expect((s as any).cursor).toBe(1); - expect(currentStep(s as any)).toBe('werk'); + const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering'); + expect(s.cursor).toBe(1); + expect(currentStep(s)).toBe('werk'); }); it('editing an answer leaves the cursor fixed (steps never collapse)', () => { - const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { - tag: 'SetAnswer', - key: 'buitenlandGewerkt', - value: 'nee', - }); - expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change + const edited = expectTag( + reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { + tag: 'SetAnswer', + key: 'buitenlandGewerkt', + value: 'nee', + }), + 'Answering', + ); + expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change }); it('Back never goes below the first step', () => { @@ -83,7 +88,7 @@ describe('navigation', () => { it('gaNaarStap jumps back to an earlier step, clearing errors', () => { const s = answering({ buitenlandGewerkt: 'nee' }, 2); - expect((gaNaarStap(s, 0) as any).cursor).toBe(0); + expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0); }); it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => { @@ -106,19 +111,18 @@ describe('submit', () => { answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }), ).tag, ).toBe('Answering'); - const good = submit(answering(complete)); - expect(good.tag).toBe('Submitting'); - expect((good as any).data.uren).toBe(4160); - expect((good as any).data.punten).toBeUndefined(); // not collected without scholing + const good = expectTag(submit(answering(complete)), '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 = submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }), + const missing = expectTag( + submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })), + 'Answering', ); - expect(missing.tag).toBe('Answering'); - expect((missing as any).errors.punten).toBeTruthy(); + expect(missing.errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. expect( submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag, @@ -128,12 +132,14 @@ describe('submit', () => { 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 = submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }), + const withScholing = expectTag( + submit( + answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }), + ), + 'Submitting', ); - expect(withScholing.tag).toBe('Submitting'); - expect((withScholing as any).data.aanvullendeScholing).toBe(true); - expect((withScholing as any).data.punten).toBe(200); + expect(withScholing.data.aanvullendeScholing).toBe(true); + expect(withScholing.data.punten).toBe(200); }); it('resolve maps Submitting to Submitted on a successful submit', () => { @@ -155,12 +161,12 @@ describe('reduce (message-driven happy path)', () => { s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' }); s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' }); s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('werk'); + expect(currentStep(expectTag(s, 'Answering'))).toBe('werk'); // Step 2: werk — uren + punten (no inline scholing, hours are high). s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' }); s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' }); s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('review'); + expect(currentStep(expectTag(s, 'Answering'))).toBe('review'); s = reduce(s, { tag: 'Submit' }); expect(s.tag).toBe('Submitting'); s = reduce(s, { tag: 'SubmitConfirmed' }); diff --git a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts index 8a7d3e9..17a3234 100644 --- a/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts +++ b/apps/ssp/src/app/registratie/domain/aanvraag-view.spec.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'; import { submittedRow, detailRows, purposeLabel, statusLabel, TYPE_LABELS } from './aanvraag-view'; import { Aanvraag } from './aanvraag'; -const base = { +const base: Omit = { id: '1', - type: 'herregistratie' as const, + type: 'herregistratie', documentIds: [], createdAt: '', updatedAt: '', @@ -13,19 +13,27 @@ const base = { describe('submittedRow', () => { it('heading is the type, subtitle is the purpose', () => { + // Given a submitted herregistratie aanvraag. + // When its row is derived... const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false }, - } as Aanvraag); + }); + // Then the heading/subtitle come from the same label functions the view uses — + // never a hardcoded Dutch literal here. expect(row.heading).toBe(TYPE_LABELS.herregistratie); expect(row.subtitle).toBe(purposeLabel('herregistratie')); }); it('status line carries the status label, reference and submit date', () => { + // Given an aanvraag InBehandeling, submitted 2024-05-12, referentie R1. + // When its row is derived... const row = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: false }, - } as Aanvraag); + }); + // Then the status line contains the label (via statusLabel(), not a literal), + // the reference, and the formatted submit date. expect(row.status).toContain( statusLabel({ tag: 'InBehandeling', referentie: 'R1', manual: false }), ); @@ -33,35 +41,58 @@ describe('submittedRow', () => { expect(row.status).toContain('12 mei 2024'); }); - it('manual review adds a note; rejection adds its reason', () => { + it('manual review adds a note', () => { + // Given an aanvraag InBehandeling with manual review flagged. + // When its row is derived... const manual = submittedRow({ ...base, status: { tag: 'InBehandeling', referentie: 'R1', manual: true }, - } as Aanvraag); + }); + // Then the status line notes the manual review. expect(manual.status).toContain('handmatig'); + }); + + // `reden` (rejection.status.reden / meerInfo.status.reden) is raw domain data — a + // free-text field (`Aanvraag`'s status union types it `string`, not an enum), passed + // through `submittedRow` unchanged and unwrapped by `$localize`. There is no + // reason-code/tag backing it to assert on instead: the value under test IS the exact + // string the Given supplied, so asserting it reappears in the Then is checking + // pass-through, not translated copy. + it('rejection adds its reason', () => { + // Given a rejected aanvraag with a rejection reason. + // When its row is derived... const rejected = submittedRow({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' }, - } as Aanvraag); + }); + // Then the reason passes through into the status line unchanged. expect(rejected.status).toContain('Onvoldoende uren'); }); it('meer-info-gevraagd adds its reason, like a rejection', () => { + // Given an aanvraag with more information requested, with a reason. + // When its row is derived... const row = submittedRow({ ...base, status: { tag: 'MeerInfoGevraagd', referentie: 'R3', reden: 'Diploma ontbreekt' }, - } as Aanvraag); + }); + // Then the reason passes through into the status line unchanged (see note above). expect(row.status).toContain('Diploma ontbreekt'); }); }); describe('detailRows', () => { it('lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected', () => { + // Given a rejected aanvraag with a rejection reason. + // When its detail rows are derived... const rows = detailRows({ ...base, status: { tag: 'Afgewezen', referentie: 'R2', reden: 'Onvoldoende uren' }, - } as Aanvraag); + }); const values = rows.map((r) => r.value); + + // Then the type label (via TYPE_LABELS, not a literal), the reference, and the + // reason (raw pass-through, see note above) all appear, and a reason row is added. expect(values).toContain(TYPE_LABELS.herregistratie); expect(values).toContain('R2'); expect(values).toContain('Onvoldoende uren'); @@ -69,11 +100,15 @@ describe('detailRows', () => { }); it('reference falls back to em dash for a Concept', () => { + // Given a Concept (not yet submitted, no reference assigned). + // When its detail rows are derived... const rows = detailRows({ ...base, submittedAt: undefined, status: { tag: 'Concept', stepIndex: 0, stepCount: 3 }, - } as Aanvraag); + }); + + // Then the reference row falls back to an em dash, and no reason row is added. const ref = rows.find((r) => r.value === '—'); expect(ref).toBeTruthy(); expect(rows.length).toBe(5); diff --git a/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts b/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts index 0081d0a..69f2e77 100644 --- a/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/change-request.machine.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { given } from '@shared/testing/machine'; +import { expectTag } from '@shared/testing/expect-tag'; import { ChangeRequestState, reduce, initial } from './change-request.machine'; const givenChangeRequest = given(reduce, initial); @@ -10,25 +11,17 @@ const editingWith = (telefoon: string): ChangeRequestState => describe('change-request reduce', () => { it('SetField updates the draft while editing', () => { const s = reduce(initial, { tag: 'SetField', key: 'telefoon', value: '0612345678' }); - expect(s.tag).toBe('Editing'); - expect((s as Extract).draft.telefoon).toBe( - '0612345678', - ); + expect(expectTag(s, 'Editing').draft.telefoon).toBe('0612345678'); }); it('Submit with an invalid draft stays Editing and reports field errors', () => { const s = reduce(editingWith('nope'), { tag: 'Submit' }); - expect(s.tag).toBe('Editing'); - const errors = (s as Extract).errors; - expect(errors.telefoon).toBeTruthy(); + expect(expectTag(s, 'Editing').errors.telefoon).toBeTruthy(); }); it('Submit with a valid draft moves to Submitting with parsed (normalised) data', () => { const s = reduce(editingWith('06 12 34 56 78'), { tag: 'Submit' }); - expect(s.tag).toBe('Submitting'); - expect((s as Extract).data.telefoon).toBe( - '0612345678', - ); + expect(expectTag(s, 'Submitting').data.telefoon).toBe('0612345678'); }); it('SubmitConfirmed maps Submitting to Submitted with the referentie', () => { diff --git a/apps/ssp/src/app/registratie/domain/has-progress.spec.ts b/apps/ssp/src/app/registratie/domain/has-progress.spec.ts index ba51809..f968b60 100644 --- a/apps/ssp/src/app/registratie/domain/has-progress.spec.ts +++ b/apps/ssp/src/app/registratie/domain/has-progress.spec.ts @@ -1,34 +1,51 @@ import { describe, it, expect } from 'vitest'; -import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine'; +import { given } from '@shared/testing/machine'; +import { expectTag } from '@shared/testing/expect-tag'; +import { hasProgress, initial, reduce } from './registratie-wizard.machine'; -const invullen = (over: Partial>) => ({ - ...(initial as Extract), - ...over, -}); +const wizard = given(reduce, initial); describe('hasProgress', () => { it('is false for a fresh wizard', () => { - expect(hasProgress(initial as Extract)).toBe(false); + expect(hasProgress(expectTag(initial, 'Invullen'))).toBe(false); }); it('ignores an auto-prefilled BRP address at step 0', () => { - const s = invullen({ - draft: { + const s = expectTag( + wizard({ + tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', - adresHerkomst: 'brp', - antwoorden: {}, - }, - }); + }), + 'Invullen', + ); expect(hasProgress(s)).toBe(false); }); it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => { - expect(hasProgress(invullen({ cursor: 1 }))).toBe(true); - expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe( - true, + const advanced = expectTag( + wizard( + { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' }, + { tag: 'SetField', key: 'postcode', value: '2514 EA' }, + { tag: 'SetField', key: 'woonplaats', value: 'Den Haag' }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'Next' }, + ), + 'Invullen', ); - expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true); + expect(hasProgress(advanced)).toBe(true); + + const withCorrespondentie = expectTag( + wizard({ tag: 'SetCorrespondentie', value: 'post' }), + 'Invullen', + ); + expect(hasProgress(withCorrespondentie)).toBe(true); + + const withDiploma = expectTag( + wizard({ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }), + 'Invullen', + ); + expect(hasProgress(withDiploma)).toBe(true); }); }); 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 a7a2209..695b314 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,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { initialUpload } from '@shared/upload/upload.machine'; +import { expectTag } from '@shared/testing/expect-tag'; import { Draft, RegistratieState, @@ -51,69 +52,75 @@ describe('STEPS (fixed)', () => { describe('navigation', () => { it('Next is a no-op (sets errors) when the adres step is invalid', () => { - const s = next(initial); + const s = expectTag(next(initial), 'Invullen'); expect(s.tag).toBe('Invullen'); - expect((s as any).cursor).toBe(0); - expect((s as any).errors.straat).toBeTruthy(); - expect((s as any).errors.correspondentie).toBeTruthy(); + expect(s.cursor).toBe(0); + expect(s.errors.straat).toBeTruthy(); + expect(s.errors.correspondentie).toBeTruthy(); }); it('Next advances once the adres step is valid', () => { - const s = next(invullen(validAdres)); - expect((s as any).cursor).toBe(1); - expect(currentStep(s as any)).toBe('beroep'); + const s = expectTag(next(invullen(validAdres)), '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 = next(invullen({ ...validAdres, correspondentie: 'email' })); - expect((bad as any).errors.email).toBeTruthy(); - const good = next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })); - expect((good as any).cursor).toBe(1); + const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen'); + expect(bad.errors.email).toBeTruthy(); + const good = expectTag( + next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })), + 'Invullen', + ); + expect(good.cursor).toBe(1); }); it('beroep step requires a chosen diploma', () => { - const noDiploma = next(invullen(validAdres, 1)); - expect((noDiploma as any).cursor).toBe(1); - expect((noDiploma as any).errors.diploma).toBeTruthy(); - const withDiploma = next(invullen(validDraft, 1)); - expect((withDiploma as any).cursor).toBe(2); + const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen'); + expect(noDiploma.cursor).toBe(1); + expect(noDiploma.errors.diploma).toBeTruthy(); + const withDiploma = expectTag(next(invullen(validDraft, 1)), '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 = back(invullen(validDraft, 2)); - expect((s as any).cursor).toBe(1); - expect((s as any).draft.beroep).toBe('Arts'); + const s = expectTag(back(invullen(validDraft, 2)), 'Invullen'); + expect(s.cursor).toBe(1); + expect(s.draft.beroep).toBe('Arts'); }); it('GaNaarStap only jumps backwards', () => { - expect((gaNaarStap(invullen(validDraft, 2), 0) as any).cursor).toBe(0); - expect((gaNaarStap(invullen(validDraft, 1), 2) as any).cursor).toBe(1); // forward jump rejected + 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 }); }); describe('adres origin (BRP vs handmatig)', () => { it('prefillAdres flags origin brp', () => { - const s = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); - expect((s as any).draft.adresHerkomst).toBe('brp'); - expect((s as any).draft.straat).toBe('Lange Voorhout 9'); + const s = expectTag( + prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'), + 'Invullen', + ); + expect(s.draft.adresHerkomst).toBe('brp'); + expect(s.draft.straat).toBe('Lange Voorhout 9'); }); it('editing a prefilled address field flips origin to handmatig', () => { const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); - const edited = setField(prefilled, 'woonplaats', 'Rotterdam'); - expect((edited as any).draft.adresHerkomst).toBe('handmatig'); + 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 = setField(invullen({}), 'straat', 'Kerkstraat 1'); - expect((s as any).draft.adresHerkomst).toBe('handmatig'); + const s = expectTag(setField(invullen({}), '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 edited = setField(prefilled, 'email', 'a@b.nl'); - expect((edited as any).draft.adresHerkomst).toBe('brp'); + 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)', () => { @@ -129,37 +136,36 @@ describe('adres origin (BRP vs handmatig)', () => { diplomaHerkomst: 'duo', }), ); - expect(s.tag).toBe('Indienen'); - expect((s as any).data.adresHerkomst).toBe('handmatig'); + const indienen = expectTag(s, 'Indienen'); + expect(indienen.data.adresHerkomst).toBe('handmatig'); }); }); describe('kiesDiploma', () => { it('derives the beroep from the chosen diploma and flags origin duo', () => { - const s = kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []); - expect((s as any).draft.diplomaId).toBe('d9'); - expect((s as any).draft.beroep).toBe('Verpleegkundige'); - expect((s as any).draft.diplomaHerkomst).toBe('duo'); + const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen'); + expect(s.draft.diplomaId).toBe('d9'); + expect(s.draft.beroep).toBe('Verpleegkundige'); + expect(s.draft.diplomaHerkomst).toBe('duo'); }); }); 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']); - const blocked = next(s); - expect((blocked as any).cursor).toBe(1); - expect((blocked as any).errors.antwoorden['nl-taalvaardigheid']).toBeTruthy(); + const blocked = expectTag(next(s), 'Invullen'); + expect(blocked.cursor).toBe(1); + expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy(); s = setAntwoord(s, 'nl-taalvaardigheid', 'ja'); - expect((next(s) as any).cursor).toBe(2); + expect(expectTag(next(s), 'Invullen').cursor).toBe(2); }); it('validateAll keeps only the answers to the questions that applied', () => { let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']); s = setAntwoord(s, 'nl-taalvaardigheid', 'ja'); s = setAntwoord(s, 'stale', 'x'); // not in vraagIds - const done = submit(s); - expect(done.tag).toBe('Indienen'); - expect((done as any).data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' }); + const done = expectTag(submit(s), 'Indienen'); + expect(done.data.antwoorden).toEqual({ 'nl-taalvaardigheid': 'ja' }); }); }); @@ -167,10 +173,10 @@ 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 = kiesHandmatig(invullen(validAdres, 1), maxIds); - expect((s as any).draft.diplomaHerkomst).toBe('handmatig'); - expect((s as any).draft.beroep).toBeUndefined(); - expect((s as any).draft.vraagIds).toEqual(maxIds); + const s = expectTag(kiesHandmatig(invullen(validAdres, 1), 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', () => { @@ -179,10 +185,9 @@ describe('manual diploma fallback', () => { s = declareerBeroep(s, 'Fysiotherapeut'); expect(submit(s).tag).toBe('Invullen'); // questions unanswered for (const id of maxIds) s = setAntwoord(s, id, 'ja'); - const done = submit(s); - expect(done.tag).toBe('Indienen'); - expect((done as any).data.diplomaHerkomst).toBe('handmatig'); - expect((done as any).data.beroep).toBe('Fysiotherapeut'); + const done = expectTag(submit(s), 'Indienen'); + expect(done.data.diplomaHerkomst).toBe('handmatig'); + expect(done.data.beroep).toBe('Fysiotherapeut'); }); }); @@ -192,17 +197,18 @@ describe('submit', () => { }); it('reaches Indienen with a complete, valid draft, carrying its data', () => { - const good = submit(invullen(validDraft)); - expect(good.tag).toBe('Indienen'); - expect((good as any).data.beroep).toBe('Arts'); - expect((good as any).data.adres.postcode).toBe('2514 EA'); - expect((good as any).data.adresHerkomst).toBe('brp'); + const good = expectTag(submit(invullen(validDraft)), 'Indienen'); + expect(good.data.beroep).toBe('Arts'); + expect(good.data.adres.postcode).toBe('2514 EA'); + expect(good.data.adresHerkomst).toBe('brp'); }); it('resolve maps Indienen to Ingediend with the referentie', () => { - const ingediend = resolve(submit(invullen(validDraft)), ok('BIG-2026-001')); - expect(ingediend.tag).toBe('Ingediend'); - expect((ingediend as any).referentie).toBe('BIG-2026-001'); + const ingediend = expectTag( + resolve(submit(invullen(validDraft)), ok('BIG-2026-001')), + 'Ingediend', + ); + expect(ingediend.referentie).toBe('BIG-2026-001'); }); it('resolve maps Indienen to Mislukt on a failed submit', () => { @@ -211,7 +217,10 @@ describe('submit', () => { }); describe('reduce (message-driven happy path)', () => { - it('drives the full flow via messages', () => { + // 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', @@ -220,14 +229,52 @@ describe('reduce (message-driven happy path)', () => { woonplaats: 'Den Haag', }); s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); - s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('beroep'); - s = reduce(s, { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }); - s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('controle'); - s = reduce(s, { tag: 'Submit' }); + 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... + const s = toBeroepStep(); + + // Then the wizard advances to the beroep step. + expect(currentStep(expectTag(s, 'Invullen'))).toBe('beroep'); + }); + + it('diploma chosen, Next advances from beroep to controle', () => { + // Given a wizard on the beroep step. + // When a diploma is chosen and Next dispatched... + const s = toControleStep(); + + // Then the wizard advances to the controle step. + expect(currentStep(expectTag(s, 'Invullen'))).toBe('controle'); + }); + + it('Submit moves a complete Invullen draft to Indienen', () => { + // Given a wizard on the controle step with a complete, valid draft. + // When Submit is dispatched... + const s = toIndienen(); + + // Then the wizard moves to Indienen. expect(s.tag).toBe('Indienen'); - s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' }); + }); + + it('SubmitConfirmed moves Indienen to Ingediend', () => { + // Given a wizard mid-submit (Indienen). + // When SubmitConfirmed arrives with a referentie... + const s = reduce(toIndienen(), { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' }); + + // Then the wizard reaches Ingediend. expect(s.tag).toBe('Ingediend'); }); @@ -244,9 +291,8 @@ describe('reduce (message-driven happy path)', () => { tag: 'SubmitFailed', error: 'boom', }); - const s = reduce(mislukt, { tag: 'Retry' }); - expect(s.tag).toBe('Indienen'); - expect((s as any).data.beroep).toBe('Arts'); + const s = expectTag(reduce(mislukt, { tag: 'Retry' }), 'Indienen'); + expect(s.data.beroep).toBe('Arts'); }); }); @@ -263,11 +309,14 @@ describe('inline document upload (beroep step)', () => { }; it('routes Upload messages through the upload reducer', () => { - const s = reduce(invullen(validDraft), { - tag: 'Upload', - msg: { type: 'CategoriesLoaded', categories: [cat] }, - }); - expect((s as any).upload.categories).toHaveLength(1); + const s = expectTag( + reduce(invullen(validDraft), { + tag: 'Upload', + msg: { type: 'CategoriesLoaded', categories: [cat] }, + }), + 'Invullen', + ); + expect(s.upload.categories).toHaveLength(1); }); it('blocks the beroep step until a required category is satisfied', () => { @@ -276,15 +325,17 @@ describe('inline document upload (beroep step)', () => { msg: { type: 'CategoriesLoaded', categories: [cat] }, }); s = reduce(s, { tag: 'Next' }); // beroep → controle blocked - expect(currentStep(s as any)).toBe('beroep'); - expect((s as any).errors.documenten).toBeTruthy(); + let invullenState = expectTag(s, 'Invullen'); + expect(currentStep(invullenState)).toBe('beroep'); + expect(invullenState.errors.documenten).toBeTruthy(); // choosing post delivery satisfies the requirement s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' }, }); s = reduce(s, { tag: 'Next' }); - expect(currentStep(s as any)).toBe('controle'); + invullenState = expectTag(s, 'Invullen'); + expect(currentStep(invullenState)).toBe('controle'); }); it('includes delivery refs in the submitted data', () => { @@ -296,8 +347,7 @@ describe('inline document upload (beroep step)', () => { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' }, }); - const done = submit(s as any); - expect(done.tag).toBe('Indienen'); - expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]); + const done = expectTag(submit(s), 'Indienen'); + expect(done.data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]); }); }); diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs index 5ffc9e0..5aedd71 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -15,11 +15,15 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture { // WP-35: one Concept per type is now server-enforced, and these tests share one DB // (IClassFixture). Clear any leftover Concept so each test starts from a clean slate. - foreach (var s in (await List())!.Where(x => x.Status.Tag == "Concept")) + var existing = await List(); + Assert.NotNull(existing); + foreach (var s in existing.Where(x => x.Status.Tag == "Concept")) await _client.DeleteAsync($"/api/v1/applications/{s.Id}"); var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); Assert.Equal(HttpStatusCode.Created, res.StatusCode); - return (await res.Content.ReadFromJsonAsync())!; + var created = await res.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + return created; } private Task?> List() => @@ -34,7 +38,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 }); - var mine = (await List())!.Single(x => x.Id == a.Id); + var list = await List(); + Assert.NotNull(list); + var mine = list.Single(x => x.Id == a.Id); Assert.Equal("Concept", mine.Status.Tag); Assert.Equal(1, mine.Status.StepIndex); Assert.Equal(4, mine.Status.StepCount); @@ -48,8 +54,9 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 }); var detail = await _client.GetFromJsonAsync($"/api/v1/applications/{a.Id}"); - Assert.NotNull(detail!.Draft); - Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString()); + Assert.NotNull(detail); + Assert.NotNull(detail.Draft); + Assert.Equal("verpleegkundige", detail.Draft.Value.GetProperty("beroep").GetString()); } [Fact] @@ -237,7 +244,8 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public void AutoApprovable_flips_to_goedgekeurd_after_the_window() { var a = Accepted(autoApprovable: true); - var t0 = a.SubmittedAt!.Value; + Assert.NotNull(a.SubmittedAt); + var t0 = a.SubmittedAt.Value; Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag); Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag); } @@ -246,20 +254,10 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public void Manual_case_never_auto_advances() { var a = Accepted(autoApprovable: false); - var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); + Assert.NotNull(a.SubmittedAt); + var far = a.SubmittedAt.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); var status = a.ToStatusDto(far); Assert.Equal("InBehandeling", status.Tag); Assert.True(status.Manual); } - - // WP-63: the published lifecycle (ADR-0002) must name exactly these five tags, in this - // order — ToStatusDto's string literals must keep matching Enum.ToString(), and Ingediend/ - // MeerInfoGevraagd (unreachable until WP-65 adds the behandelaar transition) stay defined. - [Fact] - public void AanvraagStatusTag_covers_the_published_lifecycle() - { - Assert.Equal( - new[] { "Ingediend", "InBehandeling", "MeerInfoGevraagd", "Goedgekeurd", "Afgewezen" }, - Enum.GetNames()); - } } diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs index 8b46c25..d38973c 100644 --- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs @@ -32,7 +32,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu { BriefStore.Reset(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); - return view!.Brief; + Assert.NotNull(view); + return view.Brief; } private HttpRequestMessage Post(string path, string? role = null, object? body = null) @@ -64,8 +65,9 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu { await Get(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); + Assert.NotNull(view); // global passages + the arts-scoped one; no other-beroep passages leak in. - Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts"); + Assert.Contains(view.AvailablePassages, p => p.PassageId == "p-kern-arts"); Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts")); // Guided-drafting tags (WP-brief-v3): positief + negatief + reason-specific negatief. @@ -78,9 +80,10 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu { await Get(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); + Assert.NotNull(view); // Case context is joined onto the screen DTO for the behandel scherm header. // The BIG-nummer ships MASKED by default (PRD-0002 §5c) — reveal is a separate call. - Assert.Equal("********601", view!.CaseContext.BigNummer); + Assert.Equal("********601", view.CaseContext.BigNummer); Assert.Equal("arts", view.CaseContext.Beroep); Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.ZorgverlenerNaam)); Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.AanvraagReferentie)); @@ -98,7 +101,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu Assert.Equal(HttpStatusCode.OK, res.StatusCode); var body = await res.Content.ReadFromJsonAsync(); - Assert.Equal("19012345601", body!.BigNummer); + Assert.NotNull(body); + Assert.Equal("19012345601", body.BigNummer); } [Fact] @@ -147,13 +151,15 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu public async Task Submit_succeeds_when_required_sections_filled() { await Get(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; - await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); + var view = await _client.GetFromJsonAsync("/api/v1/brief"); + Assert.NotNull(view); + await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief)); var res = await _client.SendAsync(Post("/api/v1/brief/submit")); res.EnsureSuccessStatusCode(); var submitted = await res.Content.ReadFromJsonAsync(); - Assert.Equal("submitted", submitted!.Brief.Status.Tag); + Assert.NotNull(submitted); + Assert.Equal("submitted", submitted.Brief.Status.Tag); } [Fact] @@ -168,7 +174,9 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); res.EnsureSuccessStatusCode(); - Assert.Equal("approved", (await res.Content.ReadFromJsonAsync())!.Brief.Status.Tag); + var approved = await res.Content.ReadFromJsonAsync(); + Assert.NotNull(approved); + Assert.Equal("approved", approved.Brief.Status.Tag); } [Fact] @@ -178,9 +186,11 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); - var rejected = await (await _client.SendAsync( - Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync(); - Assert.Equal("rejected", rejected!.Brief.Status.Tag); + var rejectRes = await _client.SendAsync( + Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen."))); + var rejected = await rejectRes.Content.ReadFromJsonAsync(); + Assert.NotNull(rejected); + Assert.Equal("rejected", rejected.Brief.Status.Tag); Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments); } @@ -194,8 +204,10 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen."))); // A drafter save on a rejected letter reopens it to draft. - var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync(); - Assert.Equal("draft", reopened!.Brief.Status.Tag); + var putRes = await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); + var reopened = await putRes.Content.ReadFromJsonAsync(); + Assert.NotNull(reopened); + Assert.Equal("draft", reopened.Brief.Status.Tag); } [Fact] @@ -211,7 +223,9 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); var res = await _client.SendAsync(Post("/api/v1/brief/send")); res.EnsureSuccessStatusCode(); - Assert.Equal("sent", (await res.Content.ReadFromJsonAsync())!.Brief.Status.Tag); + var sent = await res.Content.ReadFromJsonAsync(); + Assert.NotNull(sent); + Assert.Equal("sent", sent.Brief.Status.Tag); } [Fact] @@ -219,7 +233,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu { var brief = await Get(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); - Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status + Assert.NotNull(view); + Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status Assert.False(view.Decisions.CanApprove); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); @@ -228,7 +243,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu var asApprover = await _client.SendAsync( new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } }); var approverView = await asApprover.Content.ReadFromJsonAsync(); - Assert.True(approverView!.Decisions.CanApprove); + Assert.NotNull(approverView); + Assert.True(approverView.Decisions.CanApprove); Assert.False(approverView.Decisions.CanEdit); // approver never edits } @@ -236,11 +252,13 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu public async Task Me_returns_no_capabilities_for_drafter_and_the_brief_set_for_approver() { var asDrafter = await _client.GetFromJsonAsync("/api/v1/me"); - Assert.Empty(asDrafter!.Capabilities); + Assert.NotNull(asDrafter); + Assert.Empty(asDrafter.Capabilities); var res = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/v1/me") { Headers = { { "X-Role", "approver" } } }); var asApprover = await res.Content.ReadFromJsonAsync(); - Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities); + Assert.NotNull(asApprover); + Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover.Capabilities); } [Fact] @@ -254,7 +272,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu var res = await _client.SendAsync(Post("/api/v1/brief/reset")); res.EnsureSuccessStatusCode(); var view = await res.Content.ReadFromJsonAsync(); - Assert.Equal("draft", view!.Brief.Status.Tag); + Assert.NotNull(view); + Assert.Equal("draft", view.Brief.Status.Tag); var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef"); Assert.True(aanhef.Locked); Assert.NotEmpty(aanhef.Blocks); diff --git a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs index 5759672..ed54c3f 100644 --- a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs +++ b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs @@ -52,8 +52,16 @@ public sealed class ConceptAanvraag } /// The wizard's current position — step of . + /// Guarded the same way a real cursor is (`STEPS[Math.min(cursor, STEPS.length - 1)]` on the + /// frontend): must be at least 1, and must fall + /// within [0, of)AtStep(9, 2) is not a position any real wizard can reach, so + /// the builder refuses it instead of silently building an impossible fixture. public ConceptAanvraag AtStep(int index, int of) { + if (of < 1) + throw new ArgumentOutOfRangeException(nameof(of), of, "Step count must be at least 1."); + if (index < 0 || index >= of) + throw new ArgumentOutOfRangeException(nameof(index), index, $"Step index must be within [0, {of})."); _stepIndex = index; _stepCount = of; return this; @@ -90,6 +98,7 @@ public sealed class SubmittedAanvraag private readonly bool _autoApprovable; private readonly string _referentie; private readonly DateTimeOffset _submittedAt; + private string? _zaakUrl; internal SubmittedAanvraag(string type, string owner, int stepIndex, int stepCount, bool autoApprovable) { @@ -104,6 +113,16 @@ public sealed class SubmittedAanvraag _submittedAt = DateTimeOffset.UtcNow; } + /// Registers this aanvraag's already-known OpenZaak zaak URL — mirrors + /// , the one production writer of this + /// field, so a fixture that needs a pre-existing zaak doesn't reach past Build() to + /// mutate the result by hand. + public SubmittedAanvraag WithZaakUrl(string zaakUrl) + { + _zaakUrl = zaakUrl; + return this; + } + /// Records a behandelaar's decision — reusing , /// the SAME rule production's besluit endpoint runs, rather than restating it here where it /// could quietly drift. Throws for an Afwijzen/MeerInfoOpvragen @@ -129,6 +148,7 @@ public sealed class SubmittedAanvraag SubmittedAt = _submittedAt, CreatedAt = _submittedAt, UpdatedAt = _submittedAt, + ZaakUrl = _zaakUrl, }; } diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index fa8653c..d3a4774 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -27,15 +27,18 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture>("/api/v1/notes"); - Assert.Equal(3, notes!.Count); + Assert.NotNull(notes); + Assert.Equal(3, notes.Count); } [Fact] public async Task Brp_returns_address() { var dto = await _client.GetFromJsonAsync("/api/v1/brp/address"); - Assert.True(dto!.Gevonden); - Assert.Equal("2514 EA", dto.Adres!.Postcode); + Assert.NotNull(dto); + Assert.True(dto.Gevonden); + Assert.NotNull(dto.Adres); + Assert.Equal("2514 EA", dto.Adres.Postcode); } [Fact] @@ -62,7 +65,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture("/api/v1/intake/policy"); - Assert.Equal(1000, dto!.ScholingThreshold); + Assert.NotNull(dto); + Assert.Equal(1000, dto.ScholingThreshold); } [Fact] @@ -71,7 +75,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(); - Assert.StartsWith("BIG-2026-", body!.Referentie); + Assert.NotNull(body); + Assert.StartsWith("BIG-2026-", body.Referentie); } [Fact] @@ -79,7 +84,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(); - Assert.StartsWith("BIG-2026-", body!.Referentie); + Assert.NotNull(body); + Assert.StartsWith("BIG-2026-", body.Referentie); } [Fact] @@ -159,7 +167,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture())!; + var uploaded = await res.Content.ReadFromJsonAsync(); + Assert.NotNull(uploaded); + return uploaded; } [Fact] @@ -167,7 +177,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig"); - Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); + Assert.NotNull(dto); + Assert.Contains(dto.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery); } @@ -177,7 +188,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture($"/api/v1/uploads/status?localIds={localId},onbekend"); - Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId); + Assert.NotNull(status); + Assert.Contains(status.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId); Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown"); } @@ -187,7 +199,9 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture throw new InvalidOperationException($"no HTTP call expected, got {url}")); var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); - var aanvraag = Given.Concept(type: "unknown-type", owner: "111222333").Submitted().Build(); - aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing"; + var aanvraag = Given.Concept(type: "unknown-type", owner: "111222333") + .Submitted() + .WithZaakUrl($"{ZrcBase}/zaken/uuid-existing") + .Build(); var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter); Assert.Throws(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller)); diff --git a/eslint.config.mjs b/eslint.config.mjs index e275799..80ffb5c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,10 +48,4 @@ export default [ ...c, files: ['{apps,libs}/**/*.html'], })), - - // Tests legitimately use `any` to feed invalid messages/states into reducers. - { - files: ['{apps,libs}/**/*.spec.ts'], - rules: { '@typescript-eslint/no-explicit-any': 'off' }, - }, ]; diff --git a/libs/shared/src/testing/expect-tag.ts b/libs/shared/src/testing/expect-tag.ts new file mode 100644 index 0000000..475593e --- /dev/null +++ b/libs/shared/src/testing/expect-tag.ts @@ -0,0 +1,17 @@ +/** + * Assert-and-narrow a tagged-union state to one specific variant, replacing the + * `(state as any).field` / `state as Extract` casts specs used + * to reach into a machine's state. A cast only *tells* the type checker the + * variant — it performs no runtime check, so a spec written against the wrong + * variant silently reads `undefined` off a field that doesn't exist on the + * actual state and, depending on the assertion, can still pass. `expectTag` + * throws immediately if the tag doesn't match, so a wrong-variant read fails + * loudly at the point of the mistake instead of surviving as a green test. + */ +export const expectTag = ( + s: S, + tag: T, +): Extract => { + if (s.tag !== tag) throw new Error(`expected state '${tag}', got '${s.tag}'`); + return s as Extract; +}; diff --git a/package.json b/package.json index f32d896..41c60b2 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "ng": "ng", "lint": "eslint .", + "typecheck": "tsc --noEmit -p apps/ssp/tsconfig.spec.json --rootDir . && tsc --noEmit -p apps/behandelportal/tsconfig.spec.json --rootDir . && tsc --noEmit -p libs/shared/tsconfig.spec.json --rootDir . && tsc --noEmit -p libs/beheer/tsconfig.spec.json --rootDir .", "format:check": "prettier --check .", "format": "prettier --write .", "start": "ng serve ssp", @@ -22,9 +23,11 @@ "test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --config-dir .storybook-ssp --url http://127.0.0.1:6006 --maxWorkers=2\"", "test-storybook:ci:behandelportal": "concurrently -k -s first -n sb,axe \"http-server storybook-static-behandelportal -p 6007 --silent\" \"wait-on tcp:127.0.0.1:6007 && test-storybook --config-dir .storybook-behandelportal --url http://127.0.0.1:6007 --maxWorkers=2\"", "check:tokens": "bash scripts/check-tokens.sh", + "check:seam": "bash scripts/check-seam.sh", "dep:check": "depcruise apps/ssp/src libs --config .dependency-cruiser.ssp.js && depcruise apps/behandelportal/src libs --config .dependency-cruiser.behandelportal.js", "dep:graph": "bash scripts/dep-graph.sh", "gen:snippets": "node scripts/gen-snippets.mjs", + "gen:behaviour-spec": "node scripts/gen-behaviour-spec.mjs", "gen": "plop", "gen:value-object": "plop value-object", "gen:form-machine": "plop form-machine", diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 3621dde..63ecd0c 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -12,14 +12,17 @@ cd "$(dirname "$0")/.." step() { printf '\n\033[1;36m▶ %s\033[0m\n' "$1"; } step "lint"; npm run lint +step "typecheck (spec files)"; npm run typecheck step "dependency boundaries"; npm run dep:check step "format:check (prettier)"; npm run format:check step "check:tokens"; npm run check:tokens +step "check:seam"; npm run check:seam step "test (vitest + coverage)"; npm run test:coverage step "build --localize (nl+en)"; npx ng build ssp --localize && npx ng build behandelportal --localize step "npm audit (shipped deps)"; npm audit --omit=dev step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration" ) step "showcase snippets drift"; npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts +step "behaviour spec drift"; npm run gen:behaviour-spec && git diff --exit-code libs/shared/docs/behaviour-spec.mdx step "api-client drift"; npm run gen:api && git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json if [[ "${1:-}" == "--full" ]]; then