Files
atomic-design-poc/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts
T
ehoandClaude Sonnet 5 b937e55ad3 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>
2026-08-18 20:24:53 +02:00

176 lines
6.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { expectTag } from '@shared/testing/expect-tag';
import {
Answers,
initial,
STEPS,
lageUren,
currentStep,
next,
back,
gaNaarStap,
submit,
resolve,
reduce,
IntakeState,
} from './intake.machine';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => {
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
});
it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
expect(
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
).toBeTruthy();
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
});
it('reveals the scholing question only when NL-hours are below the threshold', () => {
expect(lageUren({ uren: '500' })).toBe(true);
expect(lageUren({ uren: '4160' })).toBe(false);
});
it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => {
// Same hours, different threshold → different visibility. Proves de-hardcoding.
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit:
const lowThreshold = submit(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
);
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
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 = 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 = 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 = 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', () => {
expect(back(initial)).toBe(initial);
});
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
});
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
});
});
describe('submit', () => {
// High hours: no scholing question, so no punten is asked or collected.
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
).tag,
).toBe('Answering');
const good = expectTag(submit(answering(complete)), 'Submitting');
expect(good.data.uren).toBe(4160);
expect(good.data.punten).toBeUndefined(); // not collected without scholing
});
it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten.
const missing = expectTag(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
'Answering',
);
expect(missing.errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it.
expect(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
).toBe('Submitting');
});
it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
const withScholing = expectTag(
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
),
'Submitting',
);
expect(withScholing.data.aanvullendeScholing).toBe(true);
expect(withScholing.data.punten).toBe(200);
});
it('resolve maps Submitting to Submitted on a successful submit', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
});
it('resolve maps Submitting to Failed on a failed submit', () => {
const submitting = submit(answering(complete));
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
describe('reduce (message-driven happy path)', () => {
it('drives abroad branch end to end', () => {
let s: IntakeState = initial;
// Step 1: buitenland — the country/hours questions reveal inline (same step).
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
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(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(expectTag(s, 'Answering'))).toBe('review');
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
});