refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28)
Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).
Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.
Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.
Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -53,8 +54,26 @@ const caseContext: CaseContext = {
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||
/** 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<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: BriefAdapter, useValue: adapter },
|
||||
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
|
||||
],
|
||||
});
|
||||
return TestBed.inject(BriefStore);
|
||||
}
|
||||
|
||||
@@ -287,43 +306,46 @@ describe('BriefStore rejection diff', () => {
|
||||
});
|
||||
|
||||
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<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
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(opened).toEqual([blob]);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: false,
|
||||
error: PREVIEW_FAILED,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(opened).toHaveLength(0);
|
||||
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapt
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
/**
|
||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||
@@ -35,6 +36,7 @@ export class BriefStore implements PendingSave {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||
private blobPresenter = inject(BLOB_PRESENTER);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -244,8 +246,8 @@ export class BriefStore implements PendingSave {
|
||||
send = () => this.transition(() => this.adapter.send());
|
||||
|
||||
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
|
||||
object URL is never revoked. */
|
||||
async previewLetter() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
const r = await this.previewAdapter.preview();
|
||||
@@ -254,7 +256,7 @@ export class BriefStore implements PendingSave {
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
||||
import { UploadAdapter } from '@shared/infrastructure/upload.adapter';
|
||||
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||
import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { OrgTemplateStore } from './org-template.store';
|
||||
|
||||
const template: 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 view: OrgTemplateAdminView = {
|
||||
draft: template,
|
||||
publishedVersion: 1,
|
||||
history: [],
|
||||
unsentBriefs: 0,
|
||||
};
|
||||
|
||||
const subOrgs: SubOrgSummary[] = [
|
||||
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
|
||||
];
|
||||
|
||||
/** 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 OrgTemplateStore');
|
||||
},
|
||||
};
|
||||
return { presenter, opened };
|
||||
}
|
||||
|
||||
/** A no-op categories resource: the logo-upload sub-state is untouched by these
|
||||
tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */
|
||||
function fakeCategoriesResource(): ReturnType<UploadAdapter['categoriesResource']> {
|
||||
const fake = { status: () => 'idle' as const, value: () => undefined };
|
||||
return fake as unknown as ReturnType<UploadAdapter['categoriesResource']>;
|
||||
}
|
||||
|
||||
function setup(
|
||||
adapter: Partial<OrgTemplateAdapter>,
|
||||
blobPresenter: BlobPresenter,
|
||||
): OrgTemplateStore {
|
||||
const uploadAdapter: Partial<UploadAdapter> = {
|
||||
categoriesResource: () => fakeCategoriesResource(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: OrgTemplateAdapter, useValue: adapter },
|
||||
{ provide: UploadAdapter, useValue: uploadAdapter },
|
||||
{ provide: UploadShellService, useValue: {} },
|
||||
{ provide: BLOB_PRESENTER, useValue: blobPresenter },
|
||||
],
|
||||
});
|
||||
return TestBed.inject(OrgTemplateStore);
|
||||
}
|
||||
|
||||
// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw
|
||||
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
|
||||
|
||||
describe('OrgTemplateStore.proefbrief (RB-28)', () => {
|
||||
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
|
||||
// Given a loaded sub-org template.
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||
const store = setup(
|
||||
{
|
||||
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
|
||||
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
|
||||
proefbrief: (): Promise<Result<string, Blob>> => Promise.resolve(ok(blob)),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
|
||||
// When proefbrief() is called...
|
||||
await store.proefbrief();
|
||||
|
||||
// Then the presenter receives exactly the rendered blob, and no error surfaces.
|
||||
expect(opened).toEqual([blob]);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
// Given a loaded sub-org template whose proefbrief call fails server-side.
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
|
||||
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
|
||||
proefbrief: (): Promise<Result<string, Blob>> =>
|
||||
Promise.resolve({ ok: false, error: 'mislukt' }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
|
||||
// When proefbrief() is called...
|
||||
await store.proefbrief();
|
||||
|
||||
// Then the presenter is never reached and the error is surfaced.
|
||||
expect(opened).toHaveLength(0);
|
||||
expect(store.lastError()).toBe('mislukt');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@brief/domain/org-template.machine';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||
|
||||
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
private adapter = inject(OrgTemplateAdapter);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private shell = inject(UploadShellService);
|
||||
private blobPresenter = inject(BLOB_PRESENTER);
|
||||
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -217,7 +219,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||
|
||||
Reference in New Issue
Block a user