Files
atomic-design-poc/src/app/brief/infrastructure/brief.adapter.spec.ts
Edwin van den Houdt 053160c5c9 feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver
approval workflow, built as a teaching vertical slice on the repo's existing
FP + Elm + atomic-design patterns (see plan in ~/.claude/plans).

Domain (pure):
- Rich text as a serialisable value tree (placeholders are first-class nodes),
  moved to @shared/kernel/rich-text.ts so the shared editor can use it.
- lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored.
- brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot =
  deep value copy; derived diagnostics/editability. Full specs.

Backend (.NET stub):
- BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints,
  role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards,
  audit logging. Regenerated typed client via gen:api. +6 backend tests.

Seam:
- brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the
  parse boundary (+ spec).

UI (atomic):
- shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep
  contenteditable, DOM<->RichTextBlock round-trip tested).
- brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments,
  letter-section, letter-composer, letter-preview, brief.page + /brief route.
- Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link.

Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared).

Also included (same session):
- Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap.
- src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI).
- .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully
  broken — $localize unresolved — dev + build).

Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green,
Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:32:22 +02:00

67 lines
2.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { BriefViewDto } from '@shared/infrastructure/api-client';
import { parseBrief, parseBriefView, parseNode, 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 },
],
};
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 });
});
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('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);
});
});