Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
257 lines
7.9 KiB
TypeScript
257 lines
7.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
|
import {
|
|
parseBrief,
|
|
parseBriefView,
|
|
parseNode,
|
|
parseOrgTemplate,
|
|
parseStatus,
|
|
} from './brief.adapter';
|
|
|
|
const view: BriefViewDto = {
|
|
brief: {
|
|
briefId: 'b1',
|
|
beroep: 'arts',
|
|
templateId: 't1',
|
|
drafterId: 'demo-drafter',
|
|
status: { tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' },
|
|
placeholders: [
|
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
|
{ key: 'code', label: 'Code', autoResolvable: true, fillable: false },
|
|
],
|
|
sections: [
|
|
{
|
|
sectionKey: 'aanhef',
|
|
title: 'Aanhef',
|
|
required: true,
|
|
blocks: [
|
|
{
|
|
type: 'passage',
|
|
blockId: 'local-1',
|
|
content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] },
|
|
sourcePassageId: 'p1',
|
|
sourceVersion: 2,
|
|
edited: true,
|
|
},
|
|
{
|
|
type: 'freeText',
|
|
blockId: 'local-2',
|
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
availablePassages: [
|
|
{
|
|
passageId: 'p1',
|
|
scope: 'global',
|
|
sectionKey: 'aanhef',
|
|
label: 'Aanhef',
|
|
content: { paragraphs: [{ nodes: [] }] },
|
|
version: 1,
|
|
},
|
|
{
|
|
passageId: 'p-neg-scholing',
|
|
scope: 'global',
|
|
sectionKey: 'kern',
|
|
label: 'Onvoldoende scholing',
|
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
|
|
version: 1,
|
|
besluit: 'negatief',
|
|
reason: 'onvoldoende_scholing',
|
|
},
|
|
],
|
|
decisions: {
|
|
canEdit: false,
|
|
canApprove: true,
|
|
canReject: true,
|
|
canSend: false,
|
|
canRevealBigNummer: false,
|
|
},
|
|
orgTemplate: {
|
|
subOrgId: 'cibg-registers',
|
|
orgName: 'CIBG — Registers',
|
|
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
|
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
|
footerLegal: 'KvK 00000000',
|
|
signatureName: 'A. de Vries',
|
|
signatureRole: 'Hoofd Registratie',
|
|
signatureClosing: 'Met vriendelijke groet,',
|
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
|
version: 1,
|
|
},
|
|
caseContext: {
|
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
|
bigNummer: '19012345601',
|
|
beroep: 'arts',
|
|
aanvraagReferentie: 'HER-2026-000842',
|
|
},
|
|
};
|
|
|
|
describe('brief.adapter parse boundary', () => {
|
|
it('parses a well-formed view into the domain unions', () => {
|
|
const r = parseBriefView(view);
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.value.brief.status).toEqual({
|
|
tag: 'submitted',
|
|
submittedBy: 'demo-drafter',
|
|
submittedAt: '2026-07-01',
|
|
});
|
|
const [passage, free] = r.value.brief.sections[0].blocks;
|
|
expect(passage.type === 'passage' && passage.edited).toBe(true);
|
|
expect(free.type).toBe('freeText');
|
|
expect(r.value.brief.placeholders[1]).toEqual({
|
|
key: 'code',
|
|
label: 'Code',
|
|
autoResolvable: true,
|
|
fillable: false,
|
|
});
|
|
expect(r.value.decisions).toEqual({
|
|
canEdit: false,
|
|
canApprove: true,
|
|
canReject: true,
|
|
canSend: false,
|
|
canRevealBigNummer: false,
|
|
});
|
|
// Guided-drafting tags survive the boundary; the untagged passage has neither.
|
|
expect(r.value.availablePassages[0].besluit).toBeUndefined();
|
|
expect(r.value.availablePassages[1]).toMatchObject({
|
|
besluit: 'negatief',
|
|
reason: 'onvoldoende_scholing',
|
|
});
|
|
expect(r.value.caseContext).toEqual({
|
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
|
bigNummer: '19012345601',
|
|
beroep: 'arts',
|
|
aanvraagReferentie: 'HER-2026-000842',
|
|
});
|
|
});
|
|
|
|
it('rejects a view whose case context is missing or malformed', () => {
|
|
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
|
|
expect(
|
|
parseBriefView({
|
|
...view,
|
|
caseContext: { ...view.caseContext!, bigNummer: undefined as never },
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('parses the org template and drops a null logoDocumentId', () => {
|
|
const r = parseOrgTemplate(view.orgTemplate);
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
expect(r.value.orgName).toBe('CIBG — Registers');
|
|
expect(r.value.margins).toEqual({ topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 });
|
|
expect('logoDocumentId' in r.value).toBe(false);
|
|
});
|
|
|
|
it('rejects a view whose org template is missing or malformed', () => {
|
|
expect(parseBriefView({ ...view, orgTemplate: undefined }).ok).toBe(false);
|
|
expect(parseOrgTemplate({ ...view.orgTemplate, signatureName: undefined }).ok).toBe(false);
|
|
expect(
|
|
parseOrgTemplate({
|
|
...view.orgTemplate,
|
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25 },
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('rejects a view whose decisions are missing or malformed', () => {
|
|
expect(parseBriefView({ ...view, decisions: undefined as never }).ok).toBe(false);
|
|
expect(
|
|
parseBriefView({ ...view, decisions: { ...view.decisions, canSend: 'yes' as never } }).ok,
|
|
).toBe(false);
|
|
// The PII-reveal flag (PRD-0002 §5c) is required at the boundary too.
|
|
expect(
|
|
parseBriefView({
|
|
...view,
|
|
decisions: { ...view.decisions, canRevealBigNummer: undefined as never },
|
|
}).ok,
|
|
).toBe(false);
|
|
});
|
|
|
|
it('narrows node variants and rejects unknown ones', () => {
|
|
expect(parseNode({ type: 'text', text: 'x' })).toEqual({
|
|
ok: true,
|
|
value: { type: 'text', text: 'x' },
|
|
});
|
|
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({
|
|
ok: true,
|
|
value: { type: 'placeholder', key: 'k' },
|
|
});
|
|
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
|
|
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
|
|
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
|
|
});
|
|
|
|
it('rejects a status DTO missing its required fields', () => {
|
|
expect(parseStatus({ tag: 'submitted' }).ok).toBe(false); // no submittedBy/At
|
|
expect(parseStatus({ tag: 'rejected', rejectedBy: 'x', rejectedAt: 't' }).ok).toBe(false); // no comments
|
|
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
|
});
|
|
|
|
it('reads section.locked (default false) and paragraph.list', () => {
|
|
const r = parseBrief({
|
|
...view.brief,
|
|
sections: [
|
|
{
|
|
sectionKey: 'aanhef',
|
|
title: 'Aanhef',
|
|
required: true,
|
|
locked: true,
|
|
blocks: [
|
|
{
|
|
type: 'freeText',
|
|
blockId: 'b1',
|
|
content: {
|
|
paragraphs: [
|
|
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
|
{ nodes: [{ type: 'text', text: 'twee' }], list: 'number' },
|
|
{ nodes: [{ type: 'text', text: 'plat' }] },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{ sectionKey: 'kern', title: 'Kern', required: true, blocks: [] }, // no `locked` → false
|
|
],
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
if (!r.ok) return;
|
|
const [aanhef, kern] = r.value.sections;
|
|
expect(aanhef.locked).toBe(true);
|
|
expect(kern.locked).toBe(false);
|
|
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual([
|
|
'bullet',
|
|
'number',
|
|
undefined,
|
|
]);
|
|
});
|
|
|
|
it('rejects a library passage with an unknown scope', () => {
|
|
const r = parseBriefView({
|
|
...view,
|
|
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it('rejects a passage block missing provenance', () => {
|
|
const r = parseBrief({
|
|
...view.brief,
|
|
sections: [
|
|
{
|
|
sectionKey: 's',
|
|
title: 'S',
|
|
required: false,
|
|
blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }],
|
|
},
|
|
],
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|