test: close illegal-state escape hatches in spec type-safety (WP-71)

ESLint blanket-exempted every *.spec.ts from the any ban, and no gate
type-checked spec files at all (ng test is transpile-only), so a wrong
cast in a test could never fail the build. 76 `as any` + 12 `as
Extract<>` state-narrowing casts in the three biggest wizard specs read
one variant's fields off a whole-union value: if the reducer returned
the wrong variant, the assertion silently read undefined instead of
failing.

expectTag(state, tag) (libs/shared/src/testing/expect-tag.ts) asserts
and narrows in one call, replacing every one of those casts. Removes
the spec-file any exemption, adds `npm run typecheck` (tsc --noEmit
over each project's tsconfig.spec.json) to CI, and forbids production
code from importing libs/shared/src/testing via dependency-cruiser.
Backend: AanvraagBuilder now models ZaakUrl (closing the last
post-Build() mutation) and guards AtStep; null-forgiving `!` on
endpoint assertions replaced with Assert.NotNull so a null DTO fails by
name, not NullReferenceException.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-18 20:24:53 +02:00
co-authored by Claude Sonnet 5
parent 66224b1644
commit b937e55ad3
21 changed files with 536 additions and 286 deletions
@@ -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<WizardState, { tag: 'Editing' }>;
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);
});
});
@@ -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', () => {
@@ -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<IntakeState, { tag: 'Answering' }>;
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);
});
});
@@ -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' });