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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user