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): 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> => Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => 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> => Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => 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> => Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => 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 = { ok: false, error: 'eerste poging mislukt' }; const store = setup({ load: (): Promise> => Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => 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 = {}): Promise { const ok = (v: BriefView): Promise> => 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> => 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> => Promise.resolve({ ok: true, value: view }), }); await store.load(); const blob = new Blob([''], { 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> => 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)); 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(); }); });