Files
atomic-design-poc/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md
T
ehoandClaude Opus 5 ce952941bb 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>
2026-08-28 08:38:14 +02:00

9.8 KiB
Raw Blame History

RB-28 — BLOB_PRESENTER token unlocks the three blob-to-browser success paths

Status: implemented · 2026-08-28 · Source finding: 02-testability.md TE-006 · 99-backlog.md RB-28

What was wrong

Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot meaningfully execute: StamdataStore.download() (libs/beheer/src/application/stamdata.store.ts) did URL.createObjectURLdocument.createElement('a')a.click()URL.revokeObjectURL; BriefStore.previewLetter() (apps/ssp/src/app/brief/application/brief.store.ts) and OrgTemplateStore.proefbrief() (apps/ssp/src/app/brief/application/org-template.store.ts) both did window.open(URL.createObjectURL(blob), '_blank'). Because the call was the last statement of each command, TE-006 recorded the whole success path as effectively unassertable, and download()'s two-clause guard (if (!s || !this.canDownload()) return;) as permanently dark on its true branch.

What changed

One new file, libs/shared/src/application/blob-presenter.ts, mirroring the SESSION_PORT token already in that folder — an interface, a production implementation, and an InjectionToken:

export interface BlobPresenter {
  open(blob: Blob): void;
  download(blob: Blob, filename: string): void;
}

const realBlobPresenter: BlobPresenter = {
  open(blob) {
    window.open(URL.createObjectURL(blob), '_blank');
  },
  download(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();
    URL.revokeObjectURL(url);
  },
};

export const BLOB_PRESENTER = new InjectionToken<BlobPresenter>('BLOB_PRESENTER', {
  providedIn: 'root',
  factory: () => realBlobPresenter,
});

open() never revokes the object URL (the tab it opens outlives the call — BriefStore.previewLetter's original comment already said so and is preserved, moved onto the token's own doc comment); download() does revoke, once the click has fired. This asymmetry is preserved deliberately, not unified — the two call sites behaved differently before this ticket and still do.

Each of the three commands now injects BLOB_PRESENTER and calls it instead of the DOM directly:

File Before (last statement) After
stamdata.store.ts createObjectURLcreateElement('a')click()revokeObjectURL (6 lines) this.blobPresenter.download(blob, \${s.table.id}.json`);`
brief.store.ts window.open(URL.createObjectURL(r.value), '_blank') this.blobPresenter.open(r.value);
org-template.store.ts window.open(URL.createObjectURL(r.value), '_blank') this.blobPresenter.open(r.value);

OrgTemplateStore.previewUrlFor (added by RB-24, a different seam — a document content URL for an <a href>, not a blob handoff) is untouched.

Tests added

libs/beheer/src/application/stamdata.store.spec.ts — a new StamdataStore.download (RB-28) describe block with a recording fake BlobPresenter:

  1. Does not call the presenter while canDownload() is false because nothing is dirty yet — the guard's previously-dark true branch, first clause.
  2. Does not call the presenter while previewing a date, even with a real edit present — the guard's true branch, second clause.
  3. The success path, asserting toJson(...)'s exact output reaches the file: reads the recorded blob's text and compares it byte-for-byte against a direct call to toJson(store.table()!, store.rows()), and asserts the filename is professions.json.

apps/ssp/src/app/brief/application/brief.store.spec.ts — the existing BriefStore.previewLetter describe block's success test previously spied directly on window.open/URL.createObjectURL (both already jsdom-spyable, since the properties exist even though calling them for real throws "not implemented"). It now provides the recording fake via BLOB_PRESENTER and asserts opened holds exactly the resolved blob — the same outcome, reached through the new seam instead of monkey-patching two global browser objects.

apps/ssp/src/app/brief/application/org-template.store.spec.ts (new file — OrgTemplateStore had no spec at all before this ticket) — a OrgTemplateStore.proefbrief (RB-28) describe block: the success path (presenter receives the resolved blob, no error) and the failure path (presenter never reached, error surfaced). A Partial<UploadAdapter> stub with a no-op categoriesResource (status 'idle') satisfies the store's constructor effect without touching the logo-upload sub-state, which these tests do not exercise.

Verified red without the fix

Broke StamdataStore.download() with an Edit (not git checkout): changed the filename from `${s.table.id}.json` to `${s.table.id}.csv`. Ran the new success-path spec:

AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality

Expected: "professions.json"
Received: "professions.csv"
  libs/beheer/src/application/stamdata.store.spec.ts:134:36

Re-applied the correct filename with a second Edit; the full stamdata.store.spec.ts file (6 tests) went green again.

Verification

  • grep for remaining DOM blob calls in all three stores — grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)" — zero matches. The only occurrences of those calls anywhere in apps/libs are inside blob-presenter.ts itself (checked with a second, unscoped grep — no fourth inlined handoff exists).
  • npm run lint: clean.
  • npm run dep:check: unaffected (no new import direction — libs/shared still does not depend on libs/beheer; both libs/beheer and apps/ssp/brief import the new token from libs/shared, never the reverse).
  • npm test (all four projects): all pass — ssp 276, behandelportal 37, shared 138, beheer 26 (up from 23; +3 for the new download() describe block).
  • Coverage, npm run test:coverage narrowed per project:
    • libs/beheer/src/application/stamdata.store.tsbefore BRH 15 / BRF 37 (40.5% branch, confirmed against the current tree, matching TE-006's citation exactly); after BRH 25 / BRF 37 (67.6% branch). libs/beheer/src/application has exactly this one file, so the module figure moves the same way.
    • apps/ssp/src/app/brief/application/brief.store.tsbefore BRH 39 / BRF 72 (54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23 already added branches (the 404-tolerance path) since the finding was written — see "What TE-006 got wrong" below. After: BRH 39 / BRF 72, unchanged — swapping the global-spy assertions for the injected fake changes how the success branch is reached in the spec, not whether it is reached; it was already covered before this ticket (see below).
    • apps/ssp/src/app/brief/application/org-template.store.ts — no spec existed before this ticket, so there is no meaningful "before" branch figure for it specifically. After: BRH 17 / BRF 77, including both proefbrief() branches newly covered.
  • npm run ci (foreground, timeout: 600000, no background/Monitor): result reported in the implementing agent's final answer.

What TE-006 got wrong

TE-006 states: "brief.store.spec.ts demonstrates this exactly: it tests previewLetter's failure case ... and cannot test the success case." This is not accurate for the code as it stood at the start of this ticket. The spec already had an 'opens the composed letter in a new tab on success' test that used vi.spyOn(URL, 'createObjectURL') and vi.spyOn(window, 'open') to assert the success path — jsdom defines both properties (as functions that throw "not implemented" if actually invoked), so vi.spyOn can already replace them, and the pre-existing test did. That test passed both before and after this ticket's change; this ticket did not newly unlock previewLetter's success path, it moved an already-passing assertion off two hand-spied global browser objects and onto the new injectable seam. git log --follow -p on the spec file shows this test dates to the WP-67 monorepo merge, not to any of RB-22/23/24.

The seam is still worth having: StamdataStore.download()'s success path (five DOM/API calls in a row: createObjectURL, createElement, .href, .download, .click(), revokeObjectURL) is a materially harder thing to spy on faithfully than a single window.open call, and was in fact still dark before this ticket (no download() test of any kind existed). OrgTemplateStore.proefbrief() also had no spec at all. TE-006's diagnosis (three commands share the same class of problem, one token fixes all three) is sound; only the specific "cannot test" claim about previewLetter overstates what was true for that one call site. Scope was not reduced because of this — all three call sites are migrated per the ticket's own instruction to ship them together rather than half-adopt the seam.

What this ticket did not touch

OrgTemplateStore.previewUrlFor (RB-24) — confirmed present and unchanged at org-template.store.ts:78. libs/shared/src/application/upload-shell.service.ts (RB-25) and upload-controller.ts (RB-26) — not read beyond what RB-24's own note already described, not edited. libs/shared/docs/behaviour-spec.mdx — regenerated by npm run gen:behaviour-spec (part of npm run ci) to reflect the new/renamed test names; never hand-edited.