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>
This commit is contained in:
66
src/app/brief/infrastructure/brief.adapter.spec.ts
Normal file
66
src/app/brief/infrastructure/brief.adapter.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user