import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { initialUpload } from '@shared/upload/upload.machine'; import { Draft, RegistratieState, STEPS, initial, currentStep, next, back, gaNaarStap, kiesDiploma, kiesHandmatig, declareerBeroep, setAntwoord, setField, prefillAdres, submit, resolve, reduce, } from './registratie-wizard.machine'; const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload, }); const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const }; const validDraft: Partial = { ...validAdres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' }; describe('STEPS (fixed)', () => { it('always has the same three steps', () => { expect(STEPS).toEqual(['adres', 'beroep', 'controle']); }); }); describe('navigation', () => { it('Next is a no-op (sets errors) when the adres step is invalid', () => { const s = next(initial); 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(); }); 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'); }); 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); }); 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); }); 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'); }); 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 }); }); 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'); }); 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'); }); 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'); }); 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'); }); it('a manually entered address still submits (only manual diploma is gated)', () => { const s = submit(invullen({ straat: 'Kerkstraat 1', postcode: '1234 AB', woonplaats: 'Utrecht', correspondentie: 'post', adresHerkomst: 'handmatig', diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo' })); expect(s.tag).toBe('Indienen'); expect((s as any).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'); }); }); 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(); s = setAntwoord(s, 'nl-taalvaardigheid', 'ja'); expect((next(s) as any).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' }); }); }); 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); }); it('requires a declared beroep + all maximal questions before submit', () => { let s = kiesHandmatig(invullen(validAdres, 2), maxIds); expect(submit(s).tag).toBe('Invullen'); // no beroep declared 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'); }); }); describe('submit', () => { it('reaches Indienen ONLY with a complete, valid draft', () => { expect(submit(invullen(validAdres)).tag).toBe('Invullen'); // no diploma 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'); }); it('resolve maps Indienen to Ingediend (with referentie) / Mislukt', () => { const indienen = submit(invullen(validDraft)); expect(resolve(indienen, ok('BIG-2026-001')).tag).toBe('Ingediend'); expect((resolve(indienen, ok('BIG-2026-001')) as any).referentie).toBe('BIG-2026-001'); expect(resolve(indienen, err('boom')).tag).toBe('Mislukt'); }); }); describe('reduce (message-driven happy path)', () => { it('drives the full flow via messages', () => { let s: RegistratieState = initial; s = reduce(s, { tag: 'PrefillAdres', straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag' }); s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); 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' }); expect(s.tag).toBe('Indienen'); s = reduce(s, { tag: 'SubmitConfirmed', referentie: 'BIG-2026-001' }); expect(s.tag).toBe('Ingediend'); }); it('SubmitFailed then Retry returns to Indienen with the same data', () => { let s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); expect(s.tag).toBe('Mislukt'); s = reduce(s, { tag: 'Retry' }); expect(s.tag).toBe('Indienen'); expect((s as any).data.beroep).toBe('Arts'); }); }); describe('inline document upload (beroep step)', () => { const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true }; 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); }); it('blocks the beroep step until a required category is satisfied', () => { let s = reduce(invullen(validDraft, 1), { tag: 'Upload', 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(); // 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'); }); it('includes delivery refs in the submitted data', () => { let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } }); s = reduce(s, { 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' }]); }); });