# 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.createObjectURL` → `document.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`: ```ts 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('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` | `createObjectURL` → `createElement('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 ``, 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` 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.ts` — **before** 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.ts` — **before** 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.