Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da385311d | ||
|
|
3652ff8d3f | ||
|
|
306d002221 | ||
|
|
28c0a250e7 | ||
|
|
b937e55ad3 |
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -11,6 +11,7 @@ package-lock.json
|
||||
documentation.json
|
||||
libs/shared/src/infrastructure/api-client.ts
|
||||
apps/ssp/src/app/showcase/snippets.generated.ts
|
||||
libs/shared/docs/behaviour-spec.mdx
|
||||
|
||||
# Vendored design system (CIBG Huisstijl)
|
||||
public/cibg-huisstijl/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -27,32 +27,49 @@ describe('statusLabel', () => {
|
||||
|
||||
describe('detailRows', () => {
|
||||
it('lists soort/status/referentie/eigenaar/ingediend', () => {
|
||||
// Given a case InBehandeling.
|
||||
// When its detail rows are derived...
|
||||
const rows = detailRows({
|
||||
...base,
|
||||
status: { tag: 'InBehandeling', referentie: 'R1', manual: false },
|
||||
});
|
||||
const values = rows.map((r) => r.value);
|
||||
|
||||
// Then the type label (via TYPE_LABELS, not a literal), the reference, and the
|
||||
// owner all appear as rows.
|
||||
expect(values).toContain(TYPE_LABELS.herregistratie);
|
||||
expect(values).toContain('R1');
|
||||
expect(values).toContain(base.owner);
|
||||
expect(rows.length).toBe(5);
|
||||
});
|
||||
|
||||
it('adds a reden row for Afgewezen and MeerInfoGevraagd only', () => {
|
||||
// One behaviour ("a reden row is added exactly for the two statuses that carry a
|
||||
// reden") checked as a truth table over three statuses — kept together per
|
||||
// bdd.mdx's "truth-table of one rule" exception, rather than split apart.
|
||||
//
|
||||
// `reden` itself is raw domain data (a free-text field on the status union, not an
|
||||
// enum), passed through `detailRows` unchanged and never wrapped by `$localize` —
|
||||
// there is no reason-code/tag to assert on instead; the value under test IS the
|
||||
// string the Given supplied, so checking it reappears in the Then is a
|
||||
// pass-through check, not a translated-copy assertion.
|
||||
it('a reden row is present only for Afgewezen and MeerInfoGevraagd', () => {
|
||||
// Given three cases: rejected, more-info-requested, and approved.
|
||||
// When their detail rows are derived...
|
||||
const afgewezen = detailRows({
|
||||
...base,
|
||||
status: { tag: 'Afgewezen', referentie: 'R1', reden: 'Onvoldoende uren' },
|
||||
});
|
||||
expect(afgewezen.length).toBe(6);
|
||||
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
|
||||
|
||||
const meerInfo = detailRows({
|
||||
...base,
|
||||
status: { tag: 'MeerInfoGevraagd', referentie: 'R1', reden: 'Diploma ontbreekt' },
|
||||
});
|
||||
expect(meerInfo.length).toBe(6);
|
||||
|
||||
const goedgekeurd = detailRows({ ...base, status: { tag: 'Goedgekeurd', referentie: 'R1' } });
|
||||
|
||||
// Then only the rejected and more-info-requested cases gain a reden row (carrying
|
||||
// the reason through unchanged); the approved case does not.
|
||||
expect(afgewezen.length).toBe(6);
|
||||
expect(afgewezen.map((r) => r.value)).toContain('Onvoldoende uren');
|
||||
expect(meerInfo.length).toBe(6);
|
||||
expect(goedgekeurd.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BesluitState, { tag: 'Editing' }>).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<BesluitState, { tag: 'Editing' }>).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<BesluitState, { tag: 'Editing' }>).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<BesluitState, { tag: 'Submitting' }>).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<BesluitState, { tag: 'Submitting' }>).data).toEqual({
|
||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||
besluit: 'Afwijzen',
|
||||
toelichting: 'niet erkend',
|
||||
});
|
||||
|
||||
@@ -31,7 +31,13 @@ describe('parseBeoordelingStatus', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a missing status, unknown tag, and wrong-typed fields', () => {
|
||||
// One behaviour ("rejects a malformed status") checked over several malformed
|
||||
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
|
||||
it('rejects a malformed status', () => {
|
||||
// Given a status that is missing entirely, has an unknown tag, or is missing a
|
||||
// required field for its tag.
|
||||
// When each is parsed...
|
||||
// Then all are rejected.
|
||||
expect(parseBeoordelingStatus(undefined).ok).toBe(false);
|
||||
expect(parseBeoordelingStatus({ tag: 'Concept' } as never).ok).toBe(false);
|
||||
expect(parseBeoordelingStatus({ tag: 'InBehandeling', referentie: 'BIG-1' }).ok).toBe(false);
|
||||
@@ -50,7 +56,13 @@ describe('parseBeoordelingView', () => {
|
||||
expect(r.value.canBesluiten).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing owner, bad type, missing decisions, and non-objects', () => {
|
||||
// One behaviour ("rejects a malformed view") checked over several malformed
|
||||
// shapes — a loop asserting one rule over many inputs, kept together per bdd.mdx.
|
||||
it('rejects a malformed view', () => {
|
||||
// Given a view that is a non-object, missing the owner, has an unknown aanvraag
|
||||
// type, or is missing decisions.
|
||||
// When each is parsed...
|
||||
// Then all are rejected.
|
||||
expect(parseBeoordelingView(null).ok).toBe(false);
|
||||
expect(
|
||||
parseBeoordelingView({ ...view, aanvraag: { ...view.aanvraag, owner: undefined } }).ok,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { Result } from '@shared/kernel/fp';
|
||||
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { BriefStore } from './brief.store';
|
||||
|
||||
@@ -164,25 +164,64 @@ async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore
|
||||
}
|
||||
|
||||
describe('BriefStore undo/redo history', () => {
|
||||
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
|
||||
it('starts with nothing to undo', async () => {
|
||||
// Given a freshly loaded brief.
|
||||
// When no edit has happened yet...
|
||||
const store = await loadedStore();
|
||||
expect(store.canUndo()).toBe(false);
|
||||
|
||||
// Then there is nothing to undo.
|
||||
expect(store.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
it('records an edit and makes it undoable', async () => {
|
||||
// Given a loaded brief with one block.
|
||||
const store = await loadedStore();
|
||||
|
||||
// When a block is removed...
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||
|
||||
// Then the block is gone and the edit becomes undoable.
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
expect(store.canUndo()).toBe(true);
|
||||
});
|
||||
|
||||
it('undo reverts the edit and enables redo', async () => {
|
||||
// Given a brief with one recorded edit (a removed block).
|
||||
const store = await loadedStore();
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||
|
||||
// When the edit is undone...
|
||||
store.undo();
|
||||
|
||||
// Then the block is back, and redo becomes available.
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
||||
expect(store.canRedo()).toBe(true);
|
||||
});
|
||||
|
||||
it('redo reapplies the undone edit', async () => {
|
||||
// Given an edit that was undone.
|
||||
const store = await loadedStore();
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||
store.undo();
|
||||
|
||||
// When it is redone...
|
||||
store.redo();
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
|
||||
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
|
||||
// Then the edit is reapplied.
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
});
|
||||
|
||||
it('a no-op edit does not clear the redo future', async () => {
|
||||
// Given an undone edit, with redo available.
|
||||
const store = await loadedStore();
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||
store.undo(); // back to 1 block, redo available
|
||||
|
||||
// When an edit that changes nothing (an unknown block) is applied...
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
||||
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
|
||||
|
||||
// Then the no-op leaves no dead history step — redo is still available.
|
||||
expect(store.canRedo()).toBe(true);
|
||||
});
|
||||
|
||||
it('a new edit clears the redo future', async () => {
|
||||
@@ -268,12 +307,12 @@ describe('BriefStore.previewLetter', () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'De voorvertoning kon niet worden geopend.',
|
||||
error: PREVIEW_FAILED,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
||||
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
|
||||
/** Exported so specs can assert against the same message id instead of retyping the
|
||||
Dutch sentence (see `brief.store.spec.ts`'s `previewLetter` failure test). */
|
||||
export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
|
||||
|
||||
/**
|
||||
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,15 +77,20 @@ describe('intake acceptance journeys', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('buitenland gewerkt requires land + hours abroad, and gaNaarStap corrects an earlier answer', () => {
|
||||
it('buitenland gewerkt requires land and hours abroad before advancing', () => {
|
||||
// Given a user who says they worked abroad.
|
||||
// When they try to advance without a country...
|
||||
const blocked = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
||||
{ tag: 'Next' },
|
||||
);
|
||||
|
||||
// Then they stay on the same step, blocked by a missing 'land' answer.
|
||||
expect(blocked.tag).toBe('Answering');
|
||||
expect(blocked.tag === 'Answering' && blocked.cursor).toBe(0);
|
||||
expect(blocked.tag === 'Answering' && blocked.errors.land).toBeTruthy();
|
||||
|
||||
// When land and hours abroad are supplied, and the rest of the journey answered...
|
||||
const reviewing = given(reduce, blocked)(
|
||||
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
|
||||
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
||||
@@ -93,16 +98,34 @@ describe('intake acceptance journeys', () => {
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
|
||||
{ tag: 'Next' }, // werk step valid, uren high enough to skip scholing -> review
|
||||
);
|
||||
|
||||
// Then the journey advances all the way to review.
|
||||
expect(reviewing.tag).toBe('Answering');
|
||||
expect(reviewing.tag === 'Answering' && reviewing.cursor).toBe(2); // review
|
||||
});
|
||||
|
||||
// Jump back from review to correct the country, without losing later answers.
|
||||
it('gaNaarStap corrects an earlier answer without losing later ones', () => {
|
||||
// Given a journey that reached review with a foreign-work answer.
|
||||
const reviewing = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
||||
{ tag: 'Next' },
|
||||
{ tag: 'SetAnswer', key: 'land', value: 'Duitsland' },
|
||||
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
||||
{ tag: 'Next' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1200' },
|
||||
{ tag: 'Next' },
|
||||
);
|
||||
|
||||
// When gaNaarStap jumps back to correct the country...
|
||||
const corrected: IntakeState = given(reduce, reviewing)(
|
||||
{ tag: 'GaNaarStap', cursor: 0 },
|
||||
{ tag: 'SetAnswer', key: 'land', value: 'België' },
|
||||
{ tag: 'Next' }, // buitenland step re-validated
|
||||
{ tag: 'Next' }, // werk step re-validated (earlier 'uren' answer preserved)
|
||||
);
|
||||
|
||||
// Then the correction lands back on review with the new answer, and the later
|
||||
// 'uren' answer is preserved rather than lost.
|
||||
expect(corrected.tag).toBe('Answering');
|
||||
expect(corrected.tag === 'Answering' && corrected.cursor).toBe(2);
|
||||
expect(corrected.tag === 'Answering' && corrected.answers.land).toBe('België');
|
||||
@@ -111,6 +134,7 @@ describe('intake acceptance journeys', () => {
|
||||
const noForwardJump = reduce(corrected, { tag: 'GaNaarStap', cursor: 2 });
|
||||
expect(noForwardJump).toBe(corrected);
|
||||
|
||||
// And the corrected journey still submits successfully with the corrected data.
|
||||
const done = given(reduce, corrected)({ tag: 'Submit' }, { tag: 'SubmitConfirmed' });
|
||||
expect(done.tag).toBe('Submitted');
|
||||
expect(done.tag === 'Submitted' && done.data).toEqual({
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -10,6 +10,14 @@ import { IntakeState } from '@herregistratie/domain/intake.machine';
|
||||
// wrapping is dropped, the inputs revert to bare white. The buitenland step with
|
||||
// buitenlandGewerkt='ja' has two groups (the question + the land/uren follow-up), so it
|
||||
// must render ≥2 fieldsets, each holding a form-group.
|
||||
//
|
||||
// SANCTIONED TestBed exception (CLAUDE.md "Testing": UI is normally exercised via Storybook,
|
||||
// not heavy component tests): this pins the *count and nesting* of rendered DOM nodes for a
|
||||
// specific machine state (2+ <fieldset> wrappers, each containing a .form-group), which is a
|
||||
// structural/visual regression, not an accessibility one — a Storybook a11y (axe) run checks
|
||||
// for accessibility violations on whatever markup is rendered, it does not assert that the
|
||||
// markup takes this particular shape, so it would not catch the grouping silently collapsing
|
||||
// back to bare inputs.
|
||||
const buitenlandJa: IntakeState = {
|
||||
tag: 'Answering',
|
||||
answers: { buitenlandGewerkt: 'ja' },
|
||||
|
||||
@@ -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<Aanvraag, 'status'> = {
|
||||
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);
|
||||
|
||||
@@ -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<ChangeRequestState, { tag: 'Editing' }>).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<ChangeRequestState, { tag: 'Editing' }>).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<ChangeRequestState, { tag: 'Submitting' }>).data.telefoon).toBe(
|
||||
'0612345678',
|
||||
);
|
||||
expect(expectTag(s, 'Submitting').data.telefoon).toBe('0612345678');
|
||||
});
|
||||
|
||||
it('SubmitConfirmed maps Submitting to Submitted with the referentie', () => {
|
||||
|
||||
@@ -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<Extract<RegistratieState, { tag: 'Invullen' }>>) => ({
|
||||
...(initial as Extract<RegistratieState, { tag: 'Invullen' }>),
|
||||
...over,
|
||||
});
|
||||
const wizard = given(reduce, initial);
|
||||
|
||||
describe('hasProgress', () => {
|
||||
it('is false for a fresh wizard', () => {
|
||||
expect(hasProgress(initial as Extract<RegistratieState, { tag: 'Invullen' }>)).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Registration } from './registration';
|
||||
import { isHerregistratieEligible, statusColor } from './registration.policy';
|
||||
import { Registration, RegistrationStatus } from './registration';
|
||||
import { isHerregistratieEligible, isStatusConsistent, statusColor } from './registration.policy';
|
||||
|
||||
const reg = (status: Registration['status']): Registration => ({
|
||||
bigNummer: '19012345601',
|
||||
@@ -38,4 +38,26 @@ describe('registration.policy', () => {
|
||||
expect(statusColor('Doorgehaald')).toContain('rood');
|
||||
expect(statusColor('Geschorst')).toContain('oranje');
|
||||
});
|
||||
|
||||
it('a well-formed status is always consistent', () => {
|
||||
expect(
|
||||
isStatusConsistent(reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' }).status),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStatusConsistent(
|
||||
reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }).status,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isStatusConsistent(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }).status),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('a Geregistreerd status without its herregistratieDatum is inconsistent', () => {
|
||||
// The union itself makes this unrepresentable through normal construction (every
|
||||
// Geregistreerd literal must carry a herregistratieDatum) — only reachable here by
|
||||
// bypassing the type system, the way malformed runtime/serialized data could.
|
||||
const malformed = { tag: 'Geregistreerd' } as unknown as RegistrationStatus;
|
||||
expect(isStatusConsistent(malformed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { tasksFromProfile } from './tasks';
|
||||
import { Registration } from './registration';
|
||||
|
||||
@@ -13,34 +14,56 @@ const base: Registration = {
|
||||
|
||||
describe('tasksFromProfile', () => {
|
||||
it('offers herregistratie when the server says eligible, with the formatted deadline', () => {
|
||||
// Given a registration whose deadline is 2026-12-31.
|
||||
// When the server says the professional is eligible for herregistratie...
|
||||
const tasks = tasksFromProfile(base, true);
|
||||
|
||||
// Then one task is offered, routed to herregistratie, whose copy carries the
|
||||
// deadline through the same date formatter the rest of the app uses (this test's
|
||||
// point IS the date formatting, so the expectation is derived from `formatDatumNl`
|
||||
// rather than a hardcoded Dutch date literal).
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].to).toBe('/herregistratie');
|
||||
expect(tasks[0].description).toContain('31 december 2026');
|
||||
expect(tasks[0].description).toContain(formatDatumNl('2026-12-31'));
|
||||
});
|
||||
|
||||
it('offers nothing when the server says not eligible', () => {
|
||||
// Given the same registration.
|
||||
// When the server says the professional is not eligible for herregistratie...
|
||||
// Then no task is offered.
|
||||
expect(tasksFromProfile(base, false)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces a notice for a suspended registration (independent of eligibility)', () => {
|
||||
// Given a suspended ("Geschorst") registration, ineligible for herregistratie.
|
||||
const reg: Registration = {
|
||||
...base,
|
||||
status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' },
|
||||
};
|
||||
|
||||
// When the tasks are derived...
|
||||
const tasks = tasksFromProfile(reg, false);
|
||||
|
||||
// Then exactly one notice is surfaced, routed to the registration page, carrying
|
||||
// the raw suspension reason through unchanged (not translated copy — `reden` is
|
||||
// domain data, passed through as-is).
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toContain('geschorst');
|
||||
expect(tasks[0].to).toBe('/registratie');
|
||||
expect(tasks[0].description).toBe('Onderzoek');
|
||||
});
|
||||
|
||||
it('surfaces a notice for a struck-off registration', () => {
|
||||
// Given a struck-off ("Doorgehaald") registration.
|
||||
const reg: Registration = {
|
||||
...base,
|
||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' },
|
||||
};
|
||||
|
||||
// When the tasks are derived...
|
||||
const tasks = tasksFromProfile(reg, false);
|
||||
|
||||
// Then exactly one notice is surfaced, routed to the registration page.
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].title).toContain('doorgehaald');
|
||||
expect(tasks[0].to).toBe('/registratie');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ApplicationDetailDto>())!;
|
||||
var created = await res.Content.ReadFromJsonAsync<ApplicationDetailDto>();
|
||||
Assert.NotNull(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private Task<List<ApplicationSummaryDto>?> 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<ApplicationDetailDto>($"/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<AanvraagStatusTag>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,20 +245,33 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
|
||||
public async Task Unknown_id_404s()
|
||||
{
|
||||
// Given no case exists with this id.
|
||||
// When a besluit is posted against it...
|
||||
var notFound = await PostBesluit("does-not-exist", new { besluit = "Goedkeuren" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
|
||||
|
||||
// Then the endpoint answers 404, not a decision.
|
||||
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Zorgverlener_is_forbidden_from_deciding()
|
||||
{
|
||||
// Given a decidable case.
|
||||
var (a, _) = await CreateManualCaseWithDocument();
|
||||
try
|
||||
{
|
||||
// When a zorgverlener (no X-Medewerker) posts a besluit against it...
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{a.Id}/besluit")
|
||||
{
|
||||
Content = JsonContent.Create(new { besluit = "Goedkeuren" }),
|
||||
};
|
||||
req.Headers.Add("X-Role", "admin"); // zorgverlener, no X-Medewerker
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
|
||||
var response = await _client.SendAsync(req);
|
||||
|
||||
// Then the request is forbidden — deciding is a behandelaar-only capability.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -32,7 +32,8 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
{
|
||||
BriefStore.Reset();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/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<BriefViewDto>("/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<BriefViewDto>("/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<RevealBigNummerResponse>();
|
||||
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<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/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<BriefViewDto>();
|
||||
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<BriefViewDto>())!.Brief.Status.Tag);
|
||||
var approved = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
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<BriefViewDto>();
|
||||
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<BriefViewDto>();
|
||||
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<BriefViewDto>();
|
||||
Assert.Equal("draft", reopened!.Brief.Status.Tag);
|
||||
var putRes = await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
var reopened = await putRes.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
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<BriefViewDto>())!.Brief.Status.Tag);
|
||||
var sent = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
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<BriefViewDto>("/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<BriefViewDto>();
|
||||
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<MeDto>("/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<MeDto>();
|
||||
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<BriefViewDto>();
|
||||
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);
|
||||
|
||||
@@ -52,8 +52,16 @@ public sealed class ConceptAanvraag
|
||||
}
|
||||
|
||||
/// The wizard's current position — step <paramref name="index"/> of <paramref name="of"/>.
|
||||
/// Guarded the same way a real cursor is (`STEPS[Math.min(cursor, STEPS.length - 1)]` on the
|
||||
/// frontend): <paramref name="of"/> must be at least 1, and <paramref name="index"/> must fall
|
||||
/// within <c>[0, of)</c> — <c>AtStep(9, 2)</c> 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;
|
||||
}
|
||||
|
||||
/// <summary>Registers this aanvraag's already-known OpenZaak zaak URL — mirrors
|
||||
/// <see cref="Api.Data.ApplicationStore.SetZaakUrl"/>, the one production writer of this
|
||||
/// field, so a fixture that needs a pre-existing zaak doesn't reach past <c>Build()</c> to
|
||||
/// mutate the result by hand.</summary>
|
||||
public SubmittedAanvraag WithZaakUrl(string zaakUrl)
|
||||
{
|
||||
_zaakUrl = zaakUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Records a behandelaar's decision — reusing <see cref="BeoordelingRules.RequiresToelichting"/>,
|
||||
/// the SAME rule production's besluit endpoint runs, rather than restating it here where it
|
||||
/// could quietly drift. Throws <see cref="ArgumentException"/> for an Afwijzen/MeerInfoOpvragen
|
||||
@@ -129,6 +148,7 @@ public sealed class SubmittedAanvraag
|
||||
SubmittedAt = _submittedAt,
|
||||
CreatedAt = _submittedAt,
|
||||
UpdatedAt = _submittedAt,
|
||||
ZaakUrl = _zaakUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using BigRegister.Domain.Applications;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class ApplicationRuleTests
|
||||
{
|
||||
// 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<AanvraagStatusTag>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Tests.Builders;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class BeoordelingRuleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AanvraagStatusTag.Ingediend, true)]
|
||||
[InlineData(AanvraagStatusTag.InBehandeling, true)]
|
||||
[InlineData(AanvraagStatusTag.MeerInfoGevraagd, true)]
|
||||
[InlineData(AanvraagStatusTag.Goedgekeurd, false)]
|
||||
[InlineData(AanvraagStatusTag.Afgewezen, false)]
|
||||
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
|
||||
|
||||
// WP-68 (F6): the toelichting rule, moved here from an inline endpoint check.
|
||||
[Theory]
|
||||
[InlineData(Besluit.Goedkeuren, false)]
|
||||
[InlineData(Besluit.Afwijzen, true)]
|
||||
[InlineData(Besluit.MeerInfoOpvragen, true)]
|
||||
public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit));
|
||||
|
||||
// WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag —
|
||||
// an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal
|
||||
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
|
||||
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
|
||||
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
|
||||
// WP-70: built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with
|
||||
// no toelichting simply couldn't compile as a fixture here.
|
||||
[Theory]
|
||||
[InlineData(Besluit.Goedkeuren)]
|
||||
[InlineData(Besluit.Afwijzen)]
|
||||
public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var toelichting = recorded == Besluit.Goedkeuren ? null : "toelichting";
|
||||
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(recorded, toelichting).Build();
|
||||
Assert.False(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(Besluit.MeerInfoOpvragen, "toelichting").Build();
|
||||
Assert.True(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using BigRegister.Domain.Diplomas;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class DiplomaRuleTests
|
||||
{
|
||||
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||
|
||||
[Theory]
|
||||
[InlineData("geneeskunde", "Arts")]
|
||||
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||
[InlineData("onbekend-programma", "Onbekend")]
|
||||
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||
|
||||
[Fact]
|
||||
public void English_diploma_requires_dutch_proficiency()
|
||||
{
|
||||
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||
Assert.Single(questions);
|
||||
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dutch_diploma_has_no_policy_questions() =>
|
||||
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_gets_maximal_set()
|
||||
{
|
||||
var questions = DiplomaRules.ManualQuestions();
|
||||
Assert.Equal(3, questions.Count);
|
||||
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||
questions.Select(q => q.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_professions_match_known_programs() =>
|
||||
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||
DiplomaRules.ManualProfessions());
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using BigRegister.Domain.Documents;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class DocumentRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rejects_unknown_category() =>
|
||||
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
|
||||
|
||||
[Fact]
|
||||
public void Rejects_disallowed_type()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_oversized_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_valid_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
|
||||
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
|
||||
|
||||
[Fact]
|
||||
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
|
||||
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_needs_a_diploma_upload() =>
|
||||
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_skips_diploma_upload() =>
|
||||
Assert.DoesNotContain("diploma", Ids("duo", null));
|
||||
|
||||
[Fact]
|
||||
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
|
||||
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
|
||||
|
||||
[Fact]
|
||||
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
|
||||
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
|
||||
|
||||
[Fact]
|
||||
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
|
||||
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Letters;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class OrgTemplateRuleTests
|
||||
{
|
||||
private static OrgTemplateDto Draft(
|
||||
string orgName = "BIG-register",
|
||||
string signatureName = "J. Jansen",
|
||||
MarginsDto? margins = null) =>
|
||||
new(
|
||||
SubOrgId: "registers", OrgName: orgName, ReturnAddress: "Postbus 1, Den Haag",
|
||||
LogoDocumentId: null, FooterContact: "info@example.nl", FooterLegal: "KvK 12345678",
|
||||
SignatureName: signatureName, SignatureRole: "Manager", SignatureClosing: "Met vriendelijke groet",
|
||||
Margins: margins ?? new MarginsDto(20, 20, 20, 20));
|
||||
|
||||
[Fact]
|
||||
public void Accepts_a_complete_draft_within_the_margin_bounds() =>
|
||||
Assert.Null(OrgTemplateRules.RejectDraft(Draft()));
|
||||
|
||||
[Fact]
|
||||
public void Rejects_a_missing_organisation_name() =>
|
||||
Assert.NotNull(OrgTemplateRules.RejectDraft(Draft(orgName: "")));
|
||||
|
||||
[Fact]
|
||||
public void Rejects_a_missing_signature_name() =>
|
||||
Assert.NotNull(OrgTemplateRules.RejectDraft(Draft(signatureName: " ")));
|
||||
|
||||
[Theory]
|
||||
[InlineData(9, 20, 20, 20)] // top just under the minimum
|
||||
[InlineData(20, 51, 20, 20)] // right just over the maximum
|
||||
[InlineData(20, 20, 9, 20)] // bottom under the minimum
|
||||
[InlineData(20, 20, 20, 51)] // left over the maximum
|
||||
public void Rejects_a_margin_outside_the_allowed_range(int top, int right, int bottom, int left) =>
|
||||
Assert.NotNull(OrgTemplateRules.RejectDraft(Draft(margins: new MarginsDto(top, right, bottom, left))));
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 10, 10, 10)] // the minimum, inclusive
|
||||
[InlineData(50, 50, 50, 50)] // the maximum, inclusive
|
||||
public void Accepts_margins_on_the_boundary(int top, int right, int bottom, int left) =>
|
||||
Assert.Null(OrgTemplateRules.RejectDraft(Draft(margins: new MarginsDto(top, right, bottom, left))));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using BigRegister.Domain.Registrations;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class HerregistratieRuleTests
|
||||
{
|
||||
private static Registration Active(DateOnly deadline) => new(
|
||||
"19012345601", "Test", "Arts",
|
||||
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||
|
||||
[Fact]
|
||||
public void Eligible_within_window()
|
||||
{
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||
Assert.True(eligible);
|
||||
Assert.Contains("12 maanden", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Not_eligible_before_window()
|
||||
{
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eligible_on_window_boundary()
|
||||
{
|
||||
// window opens exactly 12 months before the deadline
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||
Assert.True(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspended_is_not_eligible()
|
||||
{
|
||||
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||
{
|
||||
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||
};
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_consistency_invariant()
|
||||
{
|
||||
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using BigRegister.Domain.Submissions;
|
||||
|
||||
namespace BigRegister.Tests.Domain;
|
||||
|
||||
public class SubmissionRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Manual_diploma_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_is_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||
|
||||
[Fact]
|
||||
public void Zero_hours_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||
|
||||
[Fact]
|
||||
public void Worked_hours_are_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||
|
||||
[Theory]
|
||||
[InlineData("0612345678", null)] // valid mobile
|
||||
[InlineData("070 123 45 67", null)] // valid landline, formatting stripped
|
||||
[InlineData("nope", "Voer een geldig telefoonnummer in, bijv. 0612345678.")]
|
||||
[InlineData("12345", "Voer een geldig telefoonnummer in, bijv. 0612345678.")]
|
||||
public void Phone_change_is_validated(string telefoon, string? expected) =>
|
||||
Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon));
|
||||
}
|
||||
@@ -27,15 +27,18 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
public async Task Notes_returns_seeded_aantekeningen()
|
||||
{
|
||||
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/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<BrpAddressDto>("/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<Te
|
||||
public async Task IntakePolicy_returns_scholing_threshold()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/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<Te
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
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<Te
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
||||
var contentType = res.Content.Headers.ContentType;
|
||||
Assert.NotNull(contentType);
|
||||
Assert.Contains("application/problem+json", contentType.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -107,7 +114,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
new { telefoon = "0612345678" });
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
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<Te
|
||||
{
|
||||
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
|
||||
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
||||
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
||||
var uploaded = await res.Content.ReadFromJsonAsync<UploadResponse>();
|
||||
Assert.NotNull(uploaded);
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -167,7 +177,8 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
{
|
||||
// A manual diploma requires a diploma upload; identiteit is always required.
|
||||
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/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<Te
|
||||
var localId = Guid.NewGuid().ToString();
|
||||
var doc = await Upload(localId);
|
||||
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/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<Te
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
|
||||
var contentType = res.Content.Headers.ContentType;
|
||||
Assert.NotNull(contentType);
|
||||
Assert.Equal("application/pdf", contentType.MediaType);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
|
||||
// pdf/image → inline (no attachment disposition) so the browser previews it
|
||||
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
|
||||
|
||||
@@ -213,8 +213,10 @@ public class OpenZaakZaakSourceTests
|
||||
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
|
||||
};
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
|
||||
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
|
||||
var aanvraag = Given.Concept(type: "registratie", owner: "111222333")
|
||||
.Submitted()
|
||||
.WithZaakUrl($"{ZrcBase}/zaken/uuid-existing")
|
||||
.Build();
|
||||
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||
|
||||
source.RecordBesluit(aanvraag, Besluit.Afwijzen, "onvolledig", DateTimeOffset.UtcNow, caller);
|
||||
@@ -258,8 +260,10 @@ public class OpenZaakZaakSourceTests
|
||||
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
|
||||
};
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
|
||||
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
|
||||
var aanvraag = Given.Concept(type: "registratie", owner: "111222333")
|
||||
.Submitted()
|
||||
.WithZaakUrl($"{ZrcBase}/zaken/uuid-existing")
|
||||
.Build();
|
||||
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||
|
||||
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
|
||||
@@ -295,8 +299,10 @@ public class OpenZaakZaakSourceTests
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var handler = new ZgwStubHandler(url => 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<InvalidOperationException>(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller));
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
using BigRegister.Domain.Applications;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using BigRegister.Tests.Builders;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class DocumentRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rejects_unknown_category() =>
|
||||
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
|
||||
|
||||
[Fact]
|
||||
public void Rejects_disallowed_type()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_oversized_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepts_valid_file()
|
||||
{
|
||||
var c = DocumentRules.Find("registratie", "diploma");
|
||||
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
|
||||
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
|
||||
|
||||
[Fact]
|
||||
public void First_load_has_no_diploma_upload() => // no diploma chosen yet
|
||||
Assert.Equal(new[] { "identiteit" }, Ids(null, null));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_needs_a_diploma_upload() =>
|
||||
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_skips_diploma_upload() =>
|
||||
Assert.DoesNotContain("diploma", Ids("duo", null));
|
||||
|
||||
[Fact]
|
||||
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
|
||||
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
|
||||
|
||||
[Fact]
|
||||
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
|
||||
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
|
||||
|
||||
[Fact]
|
||||
public void Find_resolves_taalvaardigheid_for_upload_validation() =>
|
||||
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
|
||||
}
|
||||
|
||||
public class HerregistratieRuleTests
|
||||
{
|
||||
private static Registration Active(DateOnly deadline) => new(
|
||||
"19012345601", "Test", "Arts",
|
||||
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||
|
||||
[Fact]
|
||||
public void Eligible_within_window()
|
||||
{
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||
Assert.True(eligible);
|
||||
Assert.Contains("12 maanden", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Not_eligible_before_window()
|
||||
{
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eligible_on_window_boundary()
|
||||
{
|
||||
// window opens exactly 12 months before the deadline
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||
Assert.True(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspended_is_not_eligible()
|
||||
{
|
||||
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||
{
|
||||
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||
};
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_consistency_invariant()
|
||||
{
|
||||
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||
}
|
||||
}
|
||||
|
||||
public class DiplomaRuleTests
|
||||
{
|
||||
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||
|
||||
[Theory]
|
||||
[InlineData("geneeskunde", "Arts")]
|
||||
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||
[InlineData("onbekend-programma", "Onbekend")]
|
||||
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||
|
||||
[Fact]
|
||||
public void English_diploma_requires_dutch_proficiency()
|
||||
{
|
||||
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||
Assert.Single(questions);
|
||||
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dutch_diploma_has_no_policy_questions() =>
|
||||
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_gets_maximal_set()
|
||||
{
|
||||
var questions = DiplomaRules.ManualQuestions();
|
||||
Assert.Equal(3, questions.Count);
|
||||
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||
questions.Select(q => q.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_professions_match_known_programs() =>
|
||||
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||
DiplomaRules.ManualProfessions());
|
||||
}
|
||||
|
||||
public class SubmissionRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Manual_diploma_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_is_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||
|
||||
[Fact]
|
||||
public void Zero_hours_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||
|
||||
[Fact]
|
||||
public void Worked_hours_are_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||
|
||||
[Theory]
|
||||
[InlineData("0612345678", null)] // valid mobile
|
||||
[InlineData("070 123 45 67", null)] // valid landline, formatting stripped
|
||||
[InlineData("nope", "Voer een geldig telefoonnummer in, bijv. 0612345678.")]
|
||||
[InlineData("12345", "Voer een geldig telefoonnummer in, bijv. 0612345678.")]
|
||||
public void Phone_change_is_validated(string telefoon, string? expected) =>
|
||||
Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon));
|
||||
}
|
||||
|
||||
public class BeoordelingRuleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AanvraagStatusTag.Ingediend, true)]
|
||||
[InlineData(AanvraagStatusTag.InBehandeling, true)]
|
||||
[InlineData(AanvraagStatusTag.MeerInfoGevraagd, true)]
|
||||
[InlineData(AanvraagStatusTag.Goedgekeurd, false)]
|
||||
[InlineData(AanvraagStatusTag.Afgewezen, false)]
|
||||
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
|
||||
|
||||
// WP-68 (F6): the toelichting rule, moved here from an inline endpoint check.
|
||||
[Theory]
|
||||
[InlineData(Besluit.Goedkeuren, false)]
|
||||
[InlineData(Besluit.Afwijzen, true)]
|
||||
[InlineData(Besluit.MeerInfoOpvragen, true)]
|
||||
public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit));
|
||||
|
||||
// WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag —
|
||||
// an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal
|
||||
// StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the
|
||||
// domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the
|
||||
// endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit.
|
||||
// WP-70: built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with
|
||||
// no toelichting simply couldn't compile as a fixture here.
|
||||
[Theory]
|
||||
[InlineData(Besluit.Goedkeuren)]
|
||||
[InlineData(Besluit.Afwijzen)]
|
||||
public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var toelichting = recorded == Besluit.Goedkeuren ? null : "toelichting";
|
||||
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(recorded, toelichting).Build();
|
||||
Assert.False(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(Besluit.MeerInfoOpvragen, "toelichting").Build();
|
||||
Assert.True(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-68](WP-68-ddd-aggregate-hardening.md) | Aggregate invariants + status modelling (architecture review) | 12 · DDD hardening | done |
|
||||
| [WP-69](WP-69-intake-scholing-threshold-enforcement.md) | Enforce the scholing threshold server-side | 12 · DDD hardening | todo |
|
||||
| [WP-70](WP-70-test-data-builders.md) | Test-data builders: illegal fixtures unrepresentable (ADR-0006) | 12 · DDD hardening | done |
|
||||
| [WP-71](WP-71-test-framework-coherence.md) | Test framework coherence: BDD/DDD alignment, close the escape hatches | 12 · DDD hardening | done |
|
||||
|
||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# WP-71 — Test framework coherence: BDD/DDD alignment + closing the illegal-state escape hatches
|
||||
|
||||
Status: done (b937e55..3652ff8)
|
||||
Phase: 12 — DDD hardening
|
||||
|
||||
## Why
|
||||
|
||||
WP-70 shipped test-data builders and ADR-0006, then a three-angle audit (BDD conventions,
|
||||
DDD alignment, type-safety of test code) asked whether the framework as a whole is "the best
|
||||
way to set up testing." It was not — and the gap was not where WP-70 looked.
|
||||
|
||||
**WP-70 built the door but left the walls open.** `unwrapOk` had zero call sites; `given()`
|
||||
was adopted in 4 specs. Meanwhile 76 `as any` casts survived in the three biggest wizard
|
||||
specs, and the reason was systemic: `eslint.config.mjs` blanket-exempted every `*.spec.ts`
|
||||
from the `any` ban, and no gate anywhere ran `tsc --noEmit` over spec files, so a wrong cast
|
||||
could never fail the build. One assertion
|
||||
(`org-template.machine.spec.ts`, `s.tag === 'loaded' && s.dirty`) passed vacuously whenever
|
||||
the tag was wrong.
|
||||
|
||||
Alongside that: the documented "never assert on `$localize` copy" ban was broken in 5 files;
|
||||
`bdd.mdx` mis-cited its own exemplar as "one transition per test"; `layers.mdx` still taught
|
||||
the pre-WP-67 six-context structure with no `apps/`+`libs/` split; backend tests were
|
||||
organised by technical concern (`RuleTests.cs` held 5 aggregates as nested classes) rather
|
||||
than by aggregate; and duplicated FE/BE rules (the scholing threshold, the phone-format regex)
|
||||
had no test spanning the seam, so they could silently diverge.
|
||||
|
||||
## Read first
|
||||
|
||||
- `docs/reference/architecture/0006-test-data-builders.md` (WP-70's ADR).
|
||||
- `libs/shared/docs/bdd.mdx`, `libs/shared/docs/layers.mdx` (both rewritten by this WP).
|
||||
- `backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs` — the canonical G/W/T
|
||||
shape both docs now point at.
|
||||
|
||||
## Decisions (pre-made, don't relitigate)
|
||||
|
||||
1. **No Gherkin/Cucumber.** Feature files bind steps by runtime string matching, which
|
||||
directly undoes the compile-time guarantees WP-70 added, and need two frameworks
|
||||
(.NET + TS) for an audience of developers, not scenario-co-authoring stakeholders. Instead:
|
||||
generate a business-readable behaviour spec FROM the test names (`gen-behaviour-spec.mjs`,
|
||||
modeled on the existing `gen-snippets.mjs`), gated for drift in CI. Test names stay the
|
||||
single source of truth.
|
||||
2. **Playwright stays** — no change to the e2e framework.
|
||||
3. **Given/When/Then becomes the default structure for ALL tests** (user override of the
|
||||
audit's initial recommendation). `bdd.mdx`'s old "no G/W/T ceremony" clause is removed and
|
||||
inverted; ADR-0006 already matched. Present-tense declarative naming is unchanged. The
|
||||
_retrofit_ in this WP covers the acceptance specs, the canonical exemplars, and every file
|
||||
the other tracks already opened — not a mechanical sweep of all ~600 tests (tracked as a
|
||||
follow-up).
|
||||
4. **Hardening = one helper + four gates.** `expectTag(state, tag)` replaces every unsafe
|
||||
narrowing cast; the ESLint spec exemption is removed; `npm run typecheck` is added; a
|
||||
dependency-cruiser rule keeps test helpers out of production.
|
||||
5. **FE/BE seam: document + one worked pattern**, not full seam coverage. `check-seam.sh`
|
||||
catches the scholing-threshold literal drift; the other three divergences (phone regex,
|
||||
disjoint eligibility fixtures, toelichting rule) are documented, not fixed. Scholing
|
||||
_enforcement_ stays WP-69's job.
|
||||
|
||||
## Files
|
||||
|
||||
| Track | Representative paths |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A · hardening | `libs/shared/src/testing/expect-tag.ts` (new); the three wizard machine specs + `*has-progress.spec.ts` + `aanvraag-view.spec.ts`; `eslint.config.mjs`, `.dependency-cruiser.base.js`, `package.json`, CI; `Builders/AanvraagBuilder.cs`, endpoint test files |
|
||||
| B · BDD | `libs/shared/docs/bdd.mdx`, ADR-0006; the 5 copy-assertion files; the multi-behaviour title splits |
|
||||
| C · DDD | `libs/shared/docs/layers.mdx`; `RuleTests.cs` → `Domain/*RuleTests.cs`; new specs for `isStatusConsistent`, `OrgTemplateRules.RejectDraft`, both apps' `session.ts` |
|
||||
| D+E · living docs + seam | `scripts/gen-behaviour-spec.mjs`, `libs/shared/docs/behaviour-spec.mdx` (generated), `scripts/check-seam.sh` |
|
||||
|
||||
## Steps
|
||||
|
||||
Executed as four tracks: A/C/D+E ran file-disjoint in parallel first; B ran after, since its
|
||||
doc rewrite needed to reflect what A/C actually converted. Each track ended its own layer
|
||||
green; a combined gate followed; then per-track commits.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `expectTag` replaces all 76 `as any` + 12 `as Extract<>` state-narrowing casts across
|
||||
the three biggest wizard specs and the `*has-progress`/`besluit`/`change-request` specs.
|
||||
Zero tests legitimately started failing — every wrong-variant read the casts were hiding
|
||||
turned out to already be correct.
|
||||
- [x] The vacuous assertion in `org-template.machine.spec.ts` (and, on inspection, every
|
||||
sibling instance of the same pattern in that file) is fixed.
|
||||
- [x] Four new gates proven to actually fail before being trusted: `npm run lint` fails on a
|
||||
planted `any`; a deliberately-wrong `expectTag` call fails with a named
|
||||
"expected X, got Y" error, not `undefined`; `npm run dep:check` fails on a planted
|
||||
production import of `libs/shared/src/testing`; `npm run typecheck` fails on a planted
|
||||
type error in a spec.
|
||||
- [x] `check-seam.sh` proven to fail when the two scholing-threshold literals are set to
|
||||
different values, with both file paths and values named in the error.
|
||||
- [x] Backend test count: 220 (WP-70 baseline) → 230 (+9 `OrgTemplateRuleTests`, +1 from the
|
||||
`Unknown_id_404s_and_zorgverlener_is_forbidden` split). File count in
|
||||
`RuleTests.cs`'s place: 0 (deleted) → 7 files under `Domain/`, same total test count
|
||||
moved (plus the new file).
|
||||
- [x] Frontend test count grew only from legitimate title splits (no assertion dropped) and
|
||||
the new session/isStatusConsistent specs — before/after counts reported per file by the
|
||||
owning track.
|
||||
- [x] `layers.mdx` reflects the actual WP-67 monorepo structure (`apps/`+`libs/`,
|
||||
dependency-cruiser as the real enforcement mechanism, not ESLint).
|
||||
- [x] `bdd.mdx`'s "one transition per test" citation of `registratie-wizard.machine.spec.ts`
|
||||
is true again (the cited test was split).
|
||||
- [x] Every one of the 5 documented `$localize`-copy-assertion violations is fixed or
|
||||
explicitly justified as the doc's own escape hatch (a `reden` free-text passthrough with
|
||||
no backing tag — `aanvraag-view.spec.ts`/`beoordeling-view.spec.ts` — left alone with an
|
||||
inline comment explaining why, rather than forcing a fake enum into production code).
|
||||
- [x] `npm run ci` green (lint, typecheck, dep:check, format, check:tokens, check:seam, all
|
||||
four test projects, both localized builds, audit, backend format+test, snippet drift,
|
||||
behaviour-spec drift, api-client drift). `npm run build-storybook` green (the new/edited
|
||||
MDX pages build without error).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm run lint && npm run dep:check && npm run check:seam
|
||||
npm run ci # green (2026-08-18)
|
||||
npm run build-storybook # green
|
||||
cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" # 230/230
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Universal Given/When/Then sweep across all ~600 tests (staged instead — retrofit covers the
|
||||
files this WP already touched; the rest adopt it as they're next edited).
|
||||
- Scholing threshold _enforcement_ — WP-69 owns it.
|
||||
- Reconciling the phone-format regex divergence in production code (documented, not fixed;
|
||||
verified not a live bug — the FE normalises `+31`→`0` before the wire).
|
||||
- Making `Aanvraag` genuinely immutable (EF refactor, inherited from WP-70).
|
||||
- E2E test isolation via a dev-only seed endpoint (inherited from WP-70).
|
||||
- `RegistrationStatus`'s flat-record gap on the backend (inherited from WP-70).
|
||||
|
||||
## Risks
|
||||
|
||||
- `expectTag`'s runtime throw only fires when a spec actually calls it with the wrong tag —
|
||||
it does not retroactively audit every state a reducer can reach. A future variant added to
|
||||
a union still needs its own test coverage; the helper only makes existing coverage honest.
|
||||
- `check-seam.sh` covers exactly one FE/BE literal pair (the scholing threshold). The other
|
||||
three documented divergences (phone regex, eligibility fixtures, toelichting) have no
|
||||
automated guard — a future edit to either side can still silently diverge undetected.
|
||||
@@ -133,6 +133,17 @@ boundary row is the deliberate exception, not a contradiction: there the entire
|
||||
test is to exercise what happens when the input _isn't_ valid, so the fixture must be able to
|
||||
represent the invalid shape a builder would refuse to construct.
|
||||
|
||||
## A note on Given/When/Then and `bdd.mdx`
|
||||
|
||||
This ADR's "Given.Concept()...Build()" builder chain and the acceptance-test row above
|
||||
("composed into one Given→When→Then read") already used Given/When/Then before it was the
|
||||
repo-wide default. `libs/shared/docs/bdd.mdx` has since made G/W/T structure — a `// Given` /
|
||||
`// When` / `// Then` comment (or, in TypeScript, the equivalent unlabelled ordering) inside
|
||||
every test body, acceptance or not — the documented convention for **all** tests, not only
|
||||
acceptance ones (reversing its own earlier "no G/W/T ceremony" rule). The two documents now
|
||||
agree everywhere: this ADR's `Given` builder is the fixture idiom; `bdd.mdx` rule 1 is the
|
||||
structural convention every test using that fixture (and every other test besides) follows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **+** An illegal backend fixture (e.g. a decided-but-not-submitted `Aanvraag`) is now a
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,22 +12,53 @@ test, by layer_); BDD owns _how each test is phrased and scoped_.
|
||||
|
||||
## Three rules
|
||||
|
||||
### 1. `describe` = the subject, `it` = one observable behaviour
|
||||
### 1. `describe` = the subject, `it` = one observable behaviour, structured Given → When → Then
|
||||
|
||||
The `describe()` block names the unit under test; each `it()` states a single behaviour in
|
||||
**declarative present tense** — the implicit subject is "it". No `should`, no
|
||||
Given/When/Then ceremony: present-tense declaration already reads as a spec.
|
||||
**declarative present tense** — the implicit subject is "it" (no `should`). Present-tense
|
||||
naming and Given/When/Then structure are not in tension — the _title_ stays a declarative
|
||||
one-liner; the _body_ is what's organised as Given → When → Then:
|
||||
|
||||
```ts
|
||||
describe('parsePostcode', () => {
|
||||
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => { … });
|
||||
it('rejects malformed input', () => { … });
|
||||
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => {
|
||||
// Given a postcode with mixed case, extra whitespace, and no gap before the letters.
|
||||
// When it is parsed...
|
||||
const result = parsePostcode(' 1234ab ');
|
||||
// Then it comes back normalised.
|
||||
expect(result).toEqual(ok('1234 AB'));
|
||||
});
|
||||
|
||||
it('rejects malformed input', () => {
|
||||
// (no Given — the input itself IS the setup) When a non-postcode string is parsed...
|
||||
// Then it is rejected.
|
||||
expect(parsePostcode('nope').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Read top-to-bottom it _is_ the spec: "parsePostcode — normalises to 1234 AB; rejects
|
||||
malformed input."
|
||||
|
||||
**A genuinely empty phase is omitted, not faked with an empty comment.** The rejection test
|
||||
above has no Given worth writing — the malformed literal passed to `parsePostcode` already
|
||||
is the setup — so it degenerates straight to When/Then. Never write `// Given (nothing)` to
|
||||
keep three comments lined up; an omitted phase is the correct, honest shape for a test that
|
||||
doesn't need it. The three phases stay in order (Given before When before Then) whichever
|
||||
of them are present.
|
||||
|
||||
**This reverses this doc's earlier advice** ("No … Given/When/Then ceremony") — the team
|
||||
decided explicit G/W/T structure earns its keep as the default for every test, not just
|
||||
acceptance tests. What doesn't change: no `should`, present-tense titles, one behaviour per
|
||||
test, ubiquitous-language naming (rules 2–3 below).
|
||||
|
||||
**The Elm-machine naming style is a sanctioned form of rule-1 naming, not an exception to
|
||||
it.** A store/reducer spec titled after the `Msg` tag it drives —
|
||||
`it('BriefLoaded moves loading to loaded', …)` — names the domain event the same way the
|
||||
reducer's own `switch (msg.tag)` does; the tag IS ubiquitous language for a state machine,
|
||||
so this reads as a present-tense behaviour statement exactly like `'rejects malformed
|
||||
input'` does, not as a violation of rule 3.
|
||||
|
||||
### 2. One behaviour per test
|
||||
|
||||
A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the
|
||||
@@ -66,10 +97,51 @@ it('confirmed dutch proficiency requires taalvaardigheid proof', …);
|
||||
richest specs; the wire boundary is tested as "rejects malformed input", the UI as
|
||||
Storybook stories.
|
||||
|
||||
## C#/xUnit shape
|
||||
|
||||
The three rules above are language-agnostic; xUnit follows them with its own idiom rather
|
||||
than Vitest's `describe`/`it` nesting:
|
||||
|
||||
- **The method name is the title, in `PascalCase_snake_sentence`** — the same present-tense,
|
||||
ubiquitous-language behaviour statement as a `describe`+`it`, folded into one identifier
|
||||
because xUnit has no nested-description syntax: `Only_open_statuses_are_decidable`,
|
||||
`Afwijzen_requires_a_toelichting`, `A_terminal_besluit_is_frozen`.
|
||||
- **`// Given` / `// When` / `// Then` comments mark the three phases inside the test body** —
|
||||
the same structure as rule 1, made explicit because C# has no BDD framework layered on
|
||||
xUnit here (see ADR-0006 — the language's own test framework plus the builder is enough,
|
||||
deliberately not a Gherkin runner). As in TypeScript, an empty phase is omitted rather than
|
||||
commented for its own sake.
|
||||
- **Fixtures go through the `Given` type-state builder** (ADR-0006 §1), never a field-by-field
|
||||
object initializer — keeping the Given phase itself honest about which states are
|
||||
reachable.
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void A_terminal_besluit_is_frozen()
|
||||
{
|
||||
// Given a case already decided Goedgekeurd — terminal, per BeoordelingRules.CanDecide.
|
||||
var aanvraag = Given.Concept(type: "registratie").Submitted().Decided(Besluit.Goedkeuren).Build();
|
||||
Persist(aanvraag);
|
||||
|
||||
// When a behandelaar tries to record a further besluit on it...
|
||||
var (outcome, updated) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Afwijzen, "te laat", DateTimeOffset.UtcNow);
|
||||
|
||||
// Then the write is refused, and the original decision still stands.
|
||||
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Conflict, outcome);
|
||||
Assert.Null(updated);
|
||||
}
|
||||
```
|
||||
|
||||
See `Acceptance/BesluitLifecycleTests.cs` for the canonical shape (it already does this) and
|
||||
`AuthzTests.cs` for the truth-table naming convention this predates — a `[Theory]` row set
|
||||
stays one behaviour (rule 2's "loop asserting one rule over many inputs"), so it doesn't need
|
||||
per-row G/W/T comments, just one clear method name.
|
||||
|
||||
## Where to look
|
||||
|
||||
Canonical behaviour specs in the repo: `registratie/domain/value-objects/postcode.spec.ts`
|
||||
(parser behaviour), `registratie/domain/registratie-wizard.machine.spec.ts` (one transition
|
||||
per test), and backend `AuthzTests.cs` (rule truth-tables). The
|
||||
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) page maps which layer
|
||||
gets which kind of test.
|
||||
(parser behaviour), `registratie/domain/registratie-wizard.machine.spec.ts` (the
|
||||
message-driven `describe` block — one reducer transition per test), and backend
|
||||
`Acceptance/BesluitLifecycleTests.cs` (G/W/T-commented behaviour tests) and `AuthzTests.cs`
|
||||
(rule truth-tables). The [Testing strategy](?path=/docs/foundations-testing-strategy--docs)
|
||||
page maps which layer gets which kind of test.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+51
-22
@@ -8,30 +8,47 @@ This project is **domain-driven**: the code is organised first by **bounded cont
|
||||
(a business capability with its own language) and then by **layer** inside each context,
|
||||
with dependencies pointing inward. The Storybook sidebar is laid out to **be** that
|
||||
architecture, not just document it: **Foundations** (this curriculum) → **Design System**
|
||||
(reusable, domain-free) → **Domein** (the six DDD contexts). If a component lives under a context's `ui/`, it's in Domein; everything else
|
||||
in `shared/ui`/`shared/layout` is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
|
||||
(reusable, domain-free) → **Domein** (the app-local DDD contexts). If a component lives
|
||||
under a context's `ui/`, it's in Domein; everything else in `libs/shared/ui`/`layout`
|
||||
(or `libs/beheer/ui`) is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
|
||||
for the Atoms → Molecules → Organisms → Templates ladder inside Design System.
|
||||
|
||||
## Six contexts, one direction
|
||||
## Two apps, two shared libraries
|
||||
|
||||
This is a **monorepo**: two Angular projects share one backend and one shared library.
|
||||
|
||||
```
|
||||
src/app/<context>/<layer>/
|
||||
apps/<app>/src/app/<context>/<layer>/ — app-local bounded context
|
||||
libs/<lib>/src/<layer>/ — cross-app library
|
||||
```
|
||||
|
||||
Contexts: `shared` (the base layer — depends on nothing), `auth`, `registratie`,
|
||||
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching page,
|
||||
sanctioned to read every context — nothing imports it).
|
||||
- `apps/ssp` — the Zorgverlener self-service portal. Contexts: `auth`, `registratie`,
|
||||
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
|
||||
page, sanctioned to read every context in its own app — nothing imports it).
|
||||
- `apps/behandelportal` — the Behandelaar backoffice (ADR-0002). Contexts: `auth`,
|
||||
`behandeling`.
|
||||
- `libs/shared` — the design system + kernel + generated API client. No business logic.
|
||||
The base layer: depends on nothing app- or context-specific.
|
||||
- `libs/beheer` — the admin/stamdata context, used identically by both apps.
|
||||
|
||||
**Dependencies only point inward and in one declared direction between contexts:**
|
||||
`auth` is deliberately **not** shared even though today it's near-identical in both
|
||||
apps — ADR-0002 models Zorgverlener/Medewerker as different `Principal` variants with
|
||||
different login flows, so the two copies are expected to diverge.
|
||||
|
||||
**Dependencies only point inward, in one declared direction between contexts:**
|
||||
|
||||
```
|
||||
herregistratie → registratie → shared
|
||||
auth → shared
|
||||
brief → shared
|
||||
herregistratie → registratie → libs/shared|beheer (ssp)
|
||||
auth → libs/shared|beheer (ssp)
|
||||
brief → libs/shared|beheer (ssp)
|
||||
behandeling → libs/shared|beheer (behandelportal)
|
||||
auth → libs/shared|beheer (behandelportal)
|
||||
```
|
||||
|
||||
Never the other way — `registratie` may not import `herregistratie`, and no context but
|
||||
`shared` is imported by everyone.
|
||||
Never the other way — `registratie` may not import `herregistratie`, no context but
|
||||
`libs/shared`/`libs/beheer` is imported by everyone, an app may not import the other
|
||||
app's source, and `libs/shared` may not depend on `libs/beheer` (shared stays the base,
|
||||
beheer a peer leaf).
|
||||
|
||||
## Five layers, one direction
|
||||
|
||||
@@ -48,13 +65,20 @@ reach data through an application store or command.
|
||||
|
||||
## This is enforced, not just written down
|
||||
|
||||
`eslint.config.mjs` fails the build on every rule above:
|
||||
`npm run dep:check` (dependency-cruiser) fails the build on every rule above. One shared
|
||||
rule _factory_ (`.dependency-cruiser.base.js`) is instantiated once per app — each app is
|
||||
cruised separately against its own `tsconfig.json`, since `apps/ssp` and
|
||||
`apps/behandelportal` each declare `@auth/*` pointing at a different physical directory
|
||||
and a single merged tsconfig can't resolve both at once:
|
||||
|
||||
- `domain/` importing `@angular/*` at all (any context).
|
||||
- `shared/` importing a feature context (`@auth/*`, `@registratie/*`, `@herregistratie/*`,
|
||||
`@brief/*`) — the base layer depends on nothing.
|
||||
- `domain/` importing `@angular/*` at all (any context, either app).
|
||||
- `libs/shared` importing an app feature context, or `libs/beheer` — the base layer
|
||||
depends on nothing.
|
||||
- `libs/beheer` importing an app feature context.
|
||||
- an app importing the other app's source directly.
|
||||
- `registratie/` importing `@herregistratie/*`/`@brief/*`, `auth/`/`brief/` importing a
|
||||
sibling context — the cross-context direction above.
|
||||
sibling context — the cross-context direction above, one `.dependency-cruiser.<app>.js`
|
||||
per app.
|
||||
- `contracts/**` importing **anything** — not Angular, not an alias, not even a relative
|
||||
path (ADR-0001's wire seam has to stay a pure DTO shape).
|
||||
- `ui/**`/`layout/**` importing `*/infrastructure/*` — the anti-corruption boundary
|
||||
@@ -63,10 +87,15 @@ reach data through an application store or command.
|
||||
- The generated `ApiClient` imported as a value outside an `infrastructure/` adapter
|
||||
(type-only DTO imports are exempt — they grant no network access).
|
||||
|
||||
Two components get a documented exemption from the "nothing reaches across" rule:
|
||||
`shared/ui/debug-state` (reads every root store, for the dev-only state panel) and
|
||||
`showcase/` (reads every context, for side-by-side teaching pages). Both exemptions live
|
||||
next to the rule they break, in `eslint.config.mjs`, so they can't rot silently.
|
||||
`showcase` gets a documented exemption from the "nothing reaches across" rule
|
||||
(`showcase: null` in `.dependency-cruiser.ssp.js`) — it reads every context in its own
|
||||
app, for side-by-side teaching pages. The dev-only state panel (`apps/ssp/src/app/shell/debug-state`)
|
||||
reads every root store too, but needs no such exemption: it lives outside any enumerated
|
||||
context, so the per-context scoping rule never applies to it in the first place.
|
||||
|
||||
`npm run lint` (`eslint.config.mjs`) is a separate gate — mainly the `any`-free rule —
|
||||
and no longer carries the import-boundary rules above (moved to dependency-cruiser,
|
||||
WP-38/WP-67, so they don't have to be hand-copied per context).
|
||||
|
||||
## The English/Dutch seam
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Assert-and-narrow a tagged-union state to one specific variant, replacing the
|
||||
* `(state as any).field` / `state as Extract<S, { tag: 'X' }>` 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 extends { tag: string }, T extends S['tag']>(
|
||||
s: S,
|
||||
tag: T,
|
||||
): Extract<S, { tag: T }> => {
|
||||
if (s.tag !== tag) throw new Error(`expected state '${tag}', got '${s.tag}'`);
|
||||
return s as Extract<S, { tag: T }>;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# WP-71 (Track E): fail if the backend's scholing-threshold policy default and the frontend's
|
||||
# offline fallback default drift apart. ADR-0001's "config value" shape means the backend is
|
||||
# the authority (GET /intake/policy) and the FE only keeps SCHOLING_THRESHOLD_DEFAULT as an
|
||||
# offline/first-paint fallback (intake.machine.ts) — but the two literals are otherwise
|
||||
# unlinked, so nothing stops them silently diverging. This is a cheap grep-based tripwire, not
|
||||
# a build-time link between the two languages.
|
||||
set -uo pipefail
|
||||
|
||||
BACKEND_FILE='backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs'
|
||||
FRONTEND_FILE='apps/ssp/src/app/herregistratie/domain/intake.machine.ts'
|
||||
|
||||
backend_value=$(grep -oE 'ScholingThreshold\s*=\s*[0-9]+' "$BACKEND_FILE" | grep -oE '[0-9]+$')
|
||||
frontend_value=$(grep -oE 'SCHOLING_THRESHOLD_DEFAULT\s*=\s*[0-9]+' "$FRONTEND_FILE" | grep -oE '[0-9]+$')
|
||||
|
||||
if [ -z "$backend_value" ]; then
|
||||
echo "FAIL: could not find IntakePolicy.ScholingThreshold in $BACKEND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$frontend_value" ]; then
|
||||
echo "FAIL: could not find SCHOLING_THRESHOLD_DEFAULT in $FRONTEND_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$backend_value" != "$frontend_value" ]; then
|
||||
echo "FAIL: FE/BE seam drift on the scholing threshold default"
|
||||
echo " $BACKEND_FILE: ScholingThreshold = $backend_value"
|
||||
echo " $FRONTEND_FILE: SCHOLING_THRESHOLD_DEFAULT = $frontend_value"
|
||||
echo 'Both literals represent the same intake policy default (ADR-0001 config value) and must match.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: scholing threshold default matches on both sides ($backend_value)"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
// Generate a business-readable "behaviour spec" page FROM real test names (WP-71, Track D).
|
||||
// The team considered Cucumber/Gherkin for BDD scenarios and rejected it (runtime string
|
||||
// matching undoes the compile-time guarantees WP-70 just bought, and needs two frameworks for
|
||||
// .NET+TS). Instead: test names ARE the spec — this script only extracts and formats them, so
|
||||
// the page can never drift from the suite. Mirrors the gen-snippets.mjs pattern (pure Node,
|
||||
// reads real source files, writes ONE generated file, checked for drift in CI the same way).
|
||||
// Run: `npm run gen:behaviour-spec`.
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'coverage', 'bin', 'obj', '.git']);
|
||||
|
||||
/** Recursively collect files under `dir` matching `pattern`, skipping excluded directories. */
|
||||
function walk(dir, pattern) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (EXCLUDED_DIRS.has(entry)) continue;
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full, pattern));
|
||||
else if (pattern.test(entry)) out.push(full);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontend: apps/**/*.spec.ts + libs/**/*.spec.ts — describe()/it() pairs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const QUOTED = `(?:'([^']*)'|"([^"]*)"|` + '`([^`]*)`)';
|
||||
const DESCRIBE_RE = new RegExp(`\\bdescribe(?:\\.\\w+)?\\(\\s*${QUOTED}`);
|
||||
const IT_RE = new RegExp(`\\bit(?:\\.\\w+)?\\(\\s*${QUOTED}`);
|
||||
|
||||
/** Which app/context folder a spec file belongs to, for grouping (registratie, brief, …). */
|
||||
function feContextFor(path) {
|
||||
const norm = path.split(sep).join('/');
|
||||
const appMatch = norm.match(/^apps\/(?:ssp|behandelportal)\/src\/app\/([^/]+)\//);
|
||||
if (appMatch) return appMatch[1];
|
||||
const libMatch = norm.match(/^libs\/([^/]+)\/src\//);
|
||||
if (libMatch) return libMatch[1];
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract { describePath: string[], text: string } for every `it(...)` in a spec file, using
|
||||
* a brace-depth stack to track nested `describe(...)` blocks (a line-scan, not a TS parser —
|
||||
* this repo's spec files are one describe/it call per line, same precedent as gen-snippets.mjs).
|
||||
*/
|
||||
function extractSpecBehaviours(source) {
|
||||
const lines = source.split('\n');
|
||||
let depth = 0;
|
||||
const stack = []; // { name, depth }
|
||||
const results = [];
|
||||
for (const line of lines) {
|
||||
if (/^\s*\/\//.test(line)) continue; // skip commented-out lines
|
||||
const dm = line.match(DESCRIBE_RE);
|
||||
const im = !dm && line.match(IT_RE);
|
||||
if (dm) {
|
||||
stack.push({ name: dm[1] ?? dm[2] ?? dm[3], depth });
|
||||
} else if (im) {
|
||||
results.push({ describePath: stack.map((s) => s.name), text: im[1] ?? im[2] ?? im[3] });
|
||||
}
|
||||
const open = (line.match(/{/g) || []).length;
|
||||
const close = (line.match(/}/g) || []).length;
|
||||
depth += open - close;
|
||||
while (stack.length && depth <= stack[stack.length - 1].depth) stack.pop();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const feSpecFiles = [...walk('apps', /\.spec\.ts$/), ...walk('libs', /\.spec\.ts$/)];
|
||||
|
||||
/** @type {Map<string, Map<string, string[]>>} context -> describe-block label -> it() texts */
|
||||
const feBehaviour = new Map();
|
||||
for (const file of feSpecFiles) {
|
||||
const context = feContextFor(file);
|
||||
const relPath = relative('.', file).split(sep).join('/');
|
||||
const behaviours = extractSpecBehaviours(readFileSync(file, 'utf8'));
|
||||
for (const { describePath, text } of behaviours) {
|
||||
const label = describePath.length ? describePath.join(' › ') : `(${relPath})`;
|
||||
if (!feBehaviour.has(context)) feBehaviour.set(context, new Map());
|
||||
const byLabel = feBehaviour.get(context);
|
||||
if (!byLabel.has(label)) byLabel.set(label, []);
|
||||
byLabel.get(label).push(text);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: backend/tests/BigRegister.Tests/**/*.cs — [Fact]/[Theory] methods.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLASS_RE = /^\s*(?:public|internal)\s+(?:sealed\s+)?class\s+(\w+)/;
|
||||
const FACT_OR_THEORY_RE = /^\s*\[(?:Fact|Theory)\b/;
|
||||
const METHOD_RE = /\b(?:void|Task(?:<[^>]*>)?)\s+(\w+)\s*\(/;
|
||||
|
||||
/** PascalCase_snake_sentence method name -> readable sentence (just spaces for underscores). */
|
||||
function toSentence(methodName) {
|
||||
return methodName.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/** Extract { className, sentence } for every [Fact]/[Theory]-attributed method in a .cs file. */
|
||||
function extractCsBehaviours(source) {
|
||||
const lines = source.split('\n');
|
||||
let currentClass = null;
|
||||
const results = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const cm = lines[i].match(CLASS_RE);
|
||||
if (cm) {
|
||||
currentClass = cm[1];
|
||||
continue;
|
||||
}
|
||||
if (!FACT_OR_THEORY_RE.test(lines[i])) continue;
|
||||
// Skip any further attribute lines (e.g. [InlineData(...)] rows on a [Theory]) and blank
|
||||
// lines to reach the method declaration itself.
|
||||
let j = i + 1;
|
||||
while (j < lines.length && (/^\s*\[/.test(lines[j]) || /^\s*$/.test(lines[j]))) j++;
|
||||
const mm = lines[j] && lines[j].match(METHOD_RE);
|
||||
if (mm && currentClass) results.push({ className: currentClass, sentence: toSentence(mm[1]) });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const csFiles = walk('backend/tests/BigRegister.Tests', /\.cs$/);
|
||||
|
||||
/** @type {Map<string, string[]>} class name -> sentences */
|
||||
const beBehaviour = new Map();
|
||||
for (const file of csFiles) {
|
||||
for (const { className, sentence } of extractCsBehaviours(readFileSync(file, 'utf8'))) {
|
||||
if (!beBehaviour.has(className)) beBehaviour.set(className, []);
|
||||
beBehaviour.get(className).push(sentence);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Emit libs/shared/docs/behaviour-spec.mdx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// MDX parses markdown as JSX-in-Markdown: a bare `<tag>`/`{expr}` in test-name text (e.g.
|
||||
// "renders each field group as its own grey <fieldset>") would otherwise be read as JSX and
|
||||
// fail the build. Test names are data, not markup — escape them before embedding.
|
||||
function mdxEscape(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\{/g, '{')
|
||||
.replace(/\}/g, '}');
|
||||
}
|
||||
|
||||
function renderFeSection(context) {
|
||||
const byLabel = feBehaviour.get(context);
|
||||
const labels = [...byLabel.keys()].sort();
|
||||
const blocks = labels.map((label) => {
|
||||
const items = byLabel
|
||||
.get(label)
|
||||
.map((t) => `- ${mdxEscape(t)}`)
|
||||
.join('\n');
|
||||
return `#### ${mdxEscape(label)}\n\n${items}`;
|
||||
});
|
||||
return `### ${mdxEscape(context)}\n\n${blocks.join('\n\n')}`;
|
||||
}
|
||||
|
||||
function renderBeSection(className) {
|
||||
const items = beBehaviour
|
||||
.get(className)
|
||||
.map((t) => `- ${mdxEscape(t)}`)
|
||||
.join('\n');
|
||||
return `### ${mdxEscape(className)}\n\n${items}`;
|
||||
}
|
||||
|
||||
const feContexts = [...feBehaviour.keys()].sort();
|
||||
const feCount = feContexts.reduce((n, c) => n + [...feBehaviour.get(c).values()].flat().length, 0);
|
||||
const beClasses = [...beBehaviour.keys()].sort();
|
||||
const beCount = beClasses.reduce((n, c) => n + beBehaviour.get(c).length, 0);
|
||||
|
||||
const feSections = feContexts.map(renderFeSection).join('\n\n');
|
||||
const beSections = beClasses.map(renderBeSection).join('\n\n');
|
||||
|
||||
const mdx = `{/* GENERATED by \`npm run gen:behaviour-spec\` (scripts/gen-behaviour-spec.mjs) — do not
|
||||
edit. Every bullet below is a real \`it()\` title or backend test method name, extracted
|
||||
verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string
|
||||
matching undoes the compile-time guarantees WP-70 bought, and needs two frameworks for
|
||||
.NET+TS) — this page is the replacement: business-readable documentation generated FROM test
|
||||
names, so it can never drift from what the suite actually asserts. A test name changing (or a
|
||||
test being added/removed) is the only way this page changes; hand-editing it is pointless,
|
||||
the next \`npm run gen:behaviour-spec\` overwrites it. */}
|
||||
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Behaviour spec" />
|
||||
|
||||
# Behaviour spec
|
||||
|
||||
_Generated by \`npm run gen:behaviour-spec\` — do not hand-edit; the next generation
|
||||
overwrites this page. See [BDD](?path=/docs/foundations-bdd--docs) for how these names are
|
||||
written, and [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for what gets
|
||||
tested where._
|
||||
|
||||
Every bullet below is a real test name from the suite — an \`it()\` title (frontend) or a test
|
||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||
**is** the suite, reshaped for a business reader. ${feCount} frontend behaviours across
|
||||
${feContexts.length} contexts; ${beCount} backend behaviours across ${beClasses.length} test
|
||||
classes.
|
||||
|
||||
## Frontend (by context)
|
||||
|
||||
${feSections}
|
||||
|
||||
## Backend (by test class)
|
||||
|
||||
${beSections}
|
||||
`;
|
||||
|
||||
writeFileSync('libs/shared/docs/behaviour-spec.mdx', mdx);
|
||||
console.log(
|
||||
`wrote libs/shared/docs/behaviour-spec.mdx (${feCount} frontend behaviours in ${feContexts.length} contexts, ${beCount} backend behaviours in ${beClasses.length} classes)`,
|
||||
);
|
||||
Reference in New Issue
Block a user