import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { Result } from '@shared/kernel/fp'; import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief'; import { OrgTemplate } from '@brief/domain/org-template'; import { BRIEF_LOAD_FAILED, BriefAdapter, BriefLoadFailure, BriefView, } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter, PREVIEW_FAILED } 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 }; /** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of touching the DOM, so a spec can assert a command's success path directly. */ function fakeBlobPresenter() { const opened: Blob[] = []; const presenter: BlobPresenter = { open: (blob) => opened.push(blob), download: () => { throw new Error('not used by BriefStore'); }, }; return { presenter, opened }; } function setup(adapter: Partial, blobPresenter?: BlobPresenter): BriefStore { TestBed.configureTestingModule({ providers: [ { provide: BriefAdapter, useValue: adapter }, ...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []), ], }); 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 { // Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one // helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error // channel `string`) — it only ever produces the `ok: true` branch. const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over }); await store.load(); return store; } describe('BriefStore undo/redo history', () => { it('starts with nothing to undo', async () => { // Given a freshly loaded brief. // When no edit has happened yet... const store = await loadedStore(); // Then there is nothing to undo. expect(store.canUndo()).toBe(false); }); it('records an edit and makes it undoable', async () => { // Given a loaded brief with one block. const store = await loadedStore(); // When a block is removed... store.edit({ tag: 'BlockRemoved', blockId: 'local-1' }); // Then the block is gone and the edit becomes undoable. expect(loadedBrief(store).sections[0].blocks.length).toBe(0); expect(store.canUndo()).toBe(true); }); it('undo reverts the edit and enables redo', async () => { // Given a brief with one recorded edit (a removed block). const store = await loadedStore(); store.edit({ tag: 'BlockRemoved', blockId: 'local-1' }); // When the edit is undone... store.undo(); // Then the block is back, and redo becomes available. expect(loadedBrief(store).sections[0].blocks.length).toBe(1); expect(store.canRedo()).toBe(true); }); it('redo reapplies the undone edit', async () => { // Given an edit that was undone. const store = await loadedStore(); store.edit({ tag: 'BlockRemoved', blockId: 'local-1' }); store.undo(); // When it is redone... store.redo(); // Then the edit is reapplied. expect(loadedBrief(store).sections[0].blocks.length).toBe(0); }); it('a no-op edit does not clear the redo future', async () => { // Given an undone edit, with redo available. const store = await loadedStore(); store.edit({ tag: 'BlockRemoved', blockId: 'local-1' }); store.undo(); // back to 1 block, redo available // When an edit that changes nothing (an unknown block) is applied... store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' }); // Then the no-op leaves no dead history step — redo is still available. expect(store.canRedo()).toBe(true); }); 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.resolve({ ok: true, value: v } as const); 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', () => { afterEach(() => vi.restoreAllMocks()); it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { const { presenter, opened } = fakeBlobPresenter(); const store = setup( { load: (): Promise> => Promise.resolve({ ok: true, value: view }), }, presenter, ); await store.load(); const blob = new Blob([''], { type: 'text/html' }); vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ ok: true, value: blob, }); await store.previewLetter(); expect(opened).toEqual([blob]); expect(store.lastError()).toBeNull(); }); it('surfaces the error without opening a tab on failure', async () => { const { presenter, opened } = fakeBlobPresenter(); const store = setup( { load: (): Promise> => Promise.resolve({ ok: true, value: view }), }, presenter, ); await store.load(); vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ ok: false, error: PREVIEW_FAILED, }); await store.previewLetter(); expect(opened).toHaveLength(0); expect(store.lastError()).toBe(PREVIEW_FAILED); }); }); 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(); }); }); // --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the // existing reset() command, exactly once. Today's backend never 404s (RB-23 adds // that); this fake adapter is what exercises the branch until then. --- describe('BriefStore.load — 404 tolerance (RB-22)', () => { const notFound: Result = { ok: false, error: { tag: 'notFound' } }; const resetOk: Result = { ok: true, value: view }; it('a 404 drives exactly one reset(), which populates the store', async () => { // Given GET /brief 404s (no brief exists yet) and reset() succeeds. const load = vi.fn(() => Promise.resolve(notFound)); const reset = vi.fn(() => Promise.resolve(resetOk)); const store = setup({ load, reset }); // When the store loads... await store.load(); // Then reset() ran exactly once, and the store ends up loaded from its result. expect(reset).toHaveBeenCalledTimes(1); expect(store.model().tag).toBe('Loaded'); }); it('a second 404 does not drive a second reset()', async () => { // Given every load() attempt 404s (e.g. the brief still fails to appear). const load = vi.fn(() => Promise.resolve(notFound)); const reset = vi.fn(() => Promise.resolve(resetOk)); const store = setup({ load, reset }); // When the store loads twice... await store.load(); await store.load(); // Then reset() ran exactly once — the once-only bound holds across calls, not // just within one — and the second 404 surfaces as an ordinary load failure. expect(reset).toHaveBeenCalledTimes(1); expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED }); }); });