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>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
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 { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
|
import { BriefStore } from './brief.store';
|
|
|
|
const decisions: BriefDecisions = {
|
|
canEdit: true,
|
|
canApprove: true,
|
|
canReject: true,
|
|
canSend: true,
|
|
canRevealBigNummer: true,
|
|
};
|
|
|
|
const brief: Brief = {
|
|
briefId: 'b1',
|
|
beroep: 'arts',
|
|
templateId: 't1',
|
|
placeholders: [],
|
|
sections: [],
|
|
status: { tag: 'draft' },
|
|
drafterId: 'u1',
|
|
};
|
|
|
|
const orgTemplate: OrgTemplate = {
|
|
subOrgId: 'cibg-registers',
|
|
orgName: 'CIBG — Registers',
|
|
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
|
footerContact: 'info@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,
|
|
};
|
|
|
|
const caseContext: CaseContext = {
|
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
|
bigNummer: '19012345601',
|
|
beroep: 'arts',
|
|
aanvraagReferentie: 'HER-2026-000842',
|
|
};
|
|
|
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
|
|
|
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
|
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
|
return TestBed.inject(BriefStore);
|
|
}
|
|
|
|
describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
|
it('is Busy synchronously once a transition starts', async () => {
|
|
const approved: BriefView = {
|
|
...view,
|
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
|
};
|
|
const store = setup({
|
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: approved }),
|
|
});
|
|
await store.load();
|
|
|
|
const pending = store.approve();
|
|
expect(store.busy()).toBe(true); // set synchronously, before any await resolves
|
|
|
|
await pending; // settle before the test ends
|
|
});
|
|
|
|
it('settles to Idle on a successful transition', async () => {
|
|
const approved: BriefView = {
|
|
...view,
|
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
|
};
|
|
const store = setup({
|
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: approved }),
|
|
});
|
|
await store.load();
|
|
|
|
await store.approve();
|
|
expect(store.busy()).toBe(false);
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
|
|
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
|
const store = setup({
|
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
|
});
|
|
await store.load();
|
|
|
|
await store.approve();
|
|
expect(store.busy()).toBe(false);
|
|
expect(store.lastError()).toBe('niet toegestaan');
|
|
});
|
|
|
|
it('a subsequent successful transition clears a prior Failed state', async () => {
|
|
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
|
const store = setup({
|
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
|
});
|
|
await store.load();
|
|
|
|
await store.approve();
|
|
expect(store.lastError()).toBe('eerste poging mislukt');
|
|
|
|
approveResult = {
|
|
ok: true,
|
|
value: {
|
|
...view,
|
|
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
|
},
|
|
};
|
|
await store.approve();
|
|
expect(store.busy()).toBe(false);
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// --- WP-27: undo/redo history + rejection diff ---
|
|
|
|
function block(id: string, text: string): LetterBlock {
|
|
return {
|
|
type: 'freeText',
|
|
blockId: id,
|
|
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
|
|
};
|
|
}
|
|
const kern = (blocks: LetterBlock[]) => ({
|
|
sectionKey: 'kern',
|
|
title: 'Kern',
|
|
required: true,
|
|
locked: false,
|
|
blocks,
|
|
});
|
|
const filledBrief: Brief = { ...brief, sections: [kern([block('local-1', 'x')])] };
|
|
const filledView: BriefView = { ...view, brief: filledBrief };
|
|
|
|
function loadedBrief(store: BriefStore): Brief {
|
|
const s = store.model();
|
|
if (s.tag !== 'loaded') throw new Error('not loaded');
|
|
return s.brief;
|
|
}
|
|
|
|
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
|
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: v });
|
|
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
|
await store.load();
|
|
return store;
|
|
}
|
|
|
|
describe('BriefStore undo/redo history', () => {
|
|
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
|
|
const store = await loadedStore();
|
|
expect(store.canUndo()).toBe(false);
|
|
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
|
expect(store.canUndo()).toBe(true);
|
|
|
|
store.undo();
|
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
|
expect(store.canRedo()).toBe(true);
|
|
|
|
store.redo();
|
|
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
|
|
|
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
|
|
store.undo(); // back to 1 block, redo available
|
|
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
|
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
|
|
});
|
|
|
|
it('a new edit clears the redo future', async () => {
|
|
const store = await loadedStore();
|
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
store.undo();
|
|
expect(store.canRedo()).toBe(true);
|
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
expect(store.canRedo()).toBe(false);
|
|
});
|
|
|
|
it('caps history at 50 snapshots', async () => {
|
|
const store = await loadedStore();
|
|
for (let i = 0; i < 55; i++) store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
let undos = 0;
|
|
while (store.canUndo()) {
|
|
store.undo();
|
|
undos++;
|
|
}
|
|
expect(undos).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('BriefStore rejection diff', () => {
|
|
it('captures the rejected letter and diffs a subsequent edit against it', async () => {
|
|
const submitted: Brief = {
|
|
...filledBrief,
|
|
status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' },
|
|
};
|
|
const rejected: Brief = {
|
|
...filledBrief,
|
|
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
|
};
|
|
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
|
Promise.resolve({ ok: true, value: v });
|
|
const store = setup({
|
|
load: () => ok({ ...filledView, brief: submitted }),
|
|
save: () => ok(filledView),
|
|
reject: () => ok({ ...filledView, brief: rejected }),
|
|
});
|
|
await store.load();
|
|
await store.reject('nee');
|
|
expect(store.hasRejectionDiff()).toBe(false); // nothing changed yet
|
|
|
|
store.edit({
|
|
tag: 'BlockContentEdited',
|
|
blockId: 'local-1',
|
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'CHANGED' }] }] },
|
|
});
|
|
expect(store.blockDiffs().get('local-1')).toBe('changed');
|
|
expect(store.removedSinceReject()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('BriefStore.previewLetter', () => {
|
|
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
|
// the property — window.open/URL.createObjectURL must be restored between tests.
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('opens the composed letter in a new tab on success', async () => {
|
|
const store = setup({
|
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
});
|
|
await store.load();
|
|
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
|
ok: true,
|
|
value: blob,
|
|
});
|
|
|
|
await store.previewLetter();
|
|
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
|
|
it('surfaces the error without opening a tab on failure', async () => {
|
|
const store = setup({
|
|
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
|
});
|
|
await store.load();
|
|
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
|
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
|
ok: false,
|
|
error: 'De voorvertoning kon niet worden geopend.',
|
|
});
|
|
|
|
await store.previewLetter();
|
|
expect(open).not.toHaveBeenCalled();
|
|
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
|
});
|
|
});
|
|
|
|
describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
// Loaded with a MASKED BIG-nummer, as the server ships it by default.
|
|
const maskedView: BriefView = {
|
|
...view,
|
|
caseContext: { ...caseContext, bigNummer: '********601' },
|
|
};
|
|
|
|
it('swaps the masked value for the revealed one on success', async () => {
|
|
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
|
await store.load();
|
|
expect(store.caseContext()?.bigNummer).toBe('********601');
|
|
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
|
|
ok: true,
|
|
value: '19012345601',
|
|
});
|
|
|
|
await store.revealBigNummer();
|
|
expect(store.caseContext()?.bigNummer).toBe('19012345601');
|
|
expect(store.lastError()).toBeNull();
|
|
});
|
|
|
|
it('keeps the value masked and surfaces the error on failure', async () => {
|
|
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
|
await store.load();
|
|
vi.spyOn(TestBed.inject(RevealBigNummerAdapter), 'reveal').mockResolvedValue({
|
|
ok: false,
|
|
error: 'geweigerd',
|
|
});
|
|
|
|
await store.revealBigNummer();
|
|
expect(store.caseContext()?.bigNummer).toBe('********601'); // unchanged
|
|
expect(store.lastError()).toBe('geweigerd');
|
|
});
|
|
});
|
|
|
|
describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
|
const okSave = () =>
|
|
vi.fn(() => Promise.resolve({ ok: true, value: filledView } as Result<string, BriefView>));
|
|
|
|
it('flushes a pending debounced edit immediately and clears the pending flag', async () => {
|
|
const save = okSave();
|
|
const store = await loadedStore({ save });
|
|
expect(store.hasPendingSave()).toBe(false);
|
|
|
|
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
|
expect(store.hasPendingSave()).toBe(true); // 600ms debounce armed, not yet fired
|
|
|
|
await store.flushPending();
|
|
expect(save).toHaveBeenCalledTimes(1); // no timer wait needed
|
|
expect(store.hasPendingSave()).toBe(false); // timer consumed
|
|
});
|
|
|
|
it('is a no-op when no edit is pending', async () => {
|
|
const save = okSave();
|
|
const store = await loadedStore({ save });
|
|
await store.flushPending();
|
|
expect(save).not.toHaveBeenCalled();
|
|
});
|
|
});
|