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:
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user