Files
atomic-design-poc/docs/project/archive/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md
T
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:38 +02:00

10 KiB
Raw Blame History

RB-27 — uploadOutcome extracted from the XHR load closure

Status: implemented · 2026-08-28 · Source finding: 02-testability.md TE-005 · 99-backlog.md RB-27, "Merges" table row for RB-25/26/27 · Depends on implementation/rb-24.md (the move that put this file at its current path) and implementation/rb-25.md (handoff paragraph read before deciding the optional half)

What was wrong

libs/shared/src/infrastructure/upload.adapter.ts's xhrUpload constructs new XMLHttpRequest() directly and attaches its load listener inline. The listener body held the actual decisions: 2xx-vs-not, JSON.parse of the response body with a fallback to a generic error, and (on a non-2xx status) ProblemDetails mapping via the un-exported parseError. None of it is reachable without stubbing the XHR global, so the interpretation logic had no spec.

TE-005's baseline citation: LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57 (5.3% branch). The file was counted "reached" in the module total only because another spec imports it — essentially nothing in it executed.

What changed

One function extracted from the load listener, in the same file:

export function uploadOutcome(
  status: number,
  responseText: string,
): Result<string, { documentId: string }> {
  if (status < 200 || status >= 300) return err(parseError(responseText));
  try {
    return ok({ documentId: JSON.parse(responseText).documentId });
  } catch {
    return err(genericError());
  }
}

placed next to genericError/parseError (below the class, above the dev simulateUpload). It contains exactly the 2xx-vs-not check, the JSON.parse-with- fallback, and the ProblemDetails mapping — the three decisions TE-005 names. The load listener is now a two-line dispatch:

xhr.addEventListener('load', () => {
  const outcome = uploadOutcome(xhr.status, xhr.responseText);
  outcome.ok ? resolve(outcome.value) : reject(outcome.error);
});

Result, ok, err are imported from @shared/kernel/fp (the repo's one Result type, already used the same way by libs/shared's other infrastructure adapters). parseError and genericError are untouched — uploadOutcome calls them exactly as the old listener body did, so their own behavior (ProblemDetails detail extraction, generic fallback) is unchanged.

Abort-vs-error: left as a separate, smaller concern

TE-005 names abort-vs-error disambiguation in the same sentence as the extraction target, but its proposed signature — uploadOutcome(status: number, responseText: string) — has no way to express "the request was aborted before any response arrived." That is a real, structural mismatch, not an oversight to route around:

  • uploadOutcome runs inside the load listener, which fires only when the browser received a complete HTTP response — it has a status and a responseText by construction.
  • The abort listener fires instead of load when xhr.abort() was called client-side. There is no HTTP response at that point — no status, no body — so folding it into uploadOutcome's signature would mean inventing a fake status (e.g. 0) to stand for "not actually a response," which trades one implicit convention for another and makes the pure function's contract lie about what it receives.

The existing code already expresses this as the smallest form it can take:

xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError())));

one ternary, deciding between two sentinels based on which native event fired and whether cancel() was called first — not on response content. It is not a second uploadOutcome-shaped decision hiding in a closure; it is a one-line dispatch already. Extracting it into its own named function would add a call site and an import for a single ternary with no reachable-only-via-DOM logic left inside it. Left in place, as DoD point 2 allows.

Spec added, verified red

libs/shared/src/infrastructure/upload.adapter.spec.ts (new file) — plain describe/it, no TestBed, no DOM, no XHR stub, matching the DoD's explicit "that is the entire point." Five cases:

  1. 2xx status with a valid JSON body → { ok: true, value: { documentId } }.
  2. 2xx status with an unparseable body → falls back to the generic UPLOAD_FAILED text (the JSON.parse-with-fallback branch).
  3. Non-2xx status with a ProblemDetails body → the detail field, via parseError.
  4. Non-2xx status with a body that is not ProblemDetails-shaped → falls back to the generic text.
  5. The 200/300 boundary: 299 is success, 300 is not.

UPLOAD_FAILED's text is not exported (unchanged by this ticket), so the spec holds its own copy of the Dutch string as a local constant with a comment pointing at the source — the same trade every other spec makes when asserting against $localize constants that never leave their module ($localizestrings are English-first prose only where the source isnl`, so this is the source text as written, not a stand-in).

Red-proof. Edited uploadOutcome's body down to a single line — return ok({ documentId: JSON.parse(responseText).documentId });, dropping the status check and the try/catch — with an Edit (not git checkout). Ran ng test shared. Result: 4 of the 5 new specs failed:

SyntaxError: Unexpected token 'o', "not json" is not valid JSON
 ❯ uploadOutcome libs/shared/src/infrastructure/upload.adapter.ts:168:32

AssertionError: expected { ok: true, value: { …(1) } } to deeply equal { ok: false, …(1) }
- Expected   "error": "Document is al aan een aanvraag gekoppeld.", "ok": false,
+ Received   "ok": true, "value": { "documentId": undefined },

SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON

AssertionError: expected true to be false // Object.is equality

(only the plain 2xx-valid-JSON case still passed, as expected of a mutant that always reports success). Re-applied the real body with a second Edit; git diff against HEAD shows only the intended net change — the red edit left no trace. Re-ran: 163/163 green.

Coverage, upload.adapter.ts

Metric Before (TE-005 baseline) After
Lines LH 5 / LF 64 (7.8%) LH 12 / LF 65 (18.5%)
Branches BRH 3 / BRF 57 (5.3%) BRH 7 / BRF 59 (11.9%)

(LF/BRF grew by one line and two branches because uploadOutcome is new source; npm run test:coverage's shared run, coverage/shared/lcov.info, narrowed to this file's SF: block.) The jump is real but modest in absolute percentage: uploadOutcome itself is now fully exercised (FNDA:6,uploadOutcome, both branches of the status check hit, both the try and the catch path hit), but the class methods (categoriesResource, status, deleteDocument, xhrUpload's own body, simulateUpload) remain unreached — they need DI/XHR/timers to test and are out of this ticket's scope, exactly as TE-005 scopes it ("extract the interpretation, not the transport").

Optional scenario-branch move: not taken

TE-005 suggests, as an explicitly optional second half, moving the currentScenario() branch from xhrUpload up into KeepaliveTransport.send() (libs/shared/src/application/upload-shell.service.ts) so xhrUpload becomes transport-only. RB-25's handoff confirms the seam is available (KeepaliveTransport is still unexported, send() is still an unchanged one-liner) but not required.

This ticket does not take that half, for a reason RB-25's handoff does not settle: the ticket's own Scope section restricts this ticket to upload.adapter.ts and its spec only ("RB-24, RB-25, RB-26, RB-28 have all already merged — nothing else in the upload module is in flight, so you have the folder to yourself"). Moving the scenario branch requires editing upload-shell.service.ts too — exporting simulateUpload (or moving it) out of upload.adapter.ts and importing it into the application-layer send() — which is a second file, outside the stated scope. Doing it anyway would also widen this single-file ticket's diff for an explicitly optional half the ticket itself says to skip when it "complicates the diff." The dev simulator's behavior is therefore byte-for-byte unchanged: xhrUpload still checks currentScenario() first and still delegates to the untouched simulateUpload for upload-slow/upload-fail, verified by inspection (the only edit inside xhrUpload is the load-listener dispatch) and by the full shared suite staying green, including upload-shell.service.spec.ts's existing scenario-adjacent assertions.

Verification

  • npm run lint: clean.
  • npm run dep:check: unaffected — the only new import is @shared/kernel/fp, already the repo's shared Result module, imported the same way by other libs/shared infrastructure adapters (no new import direction).
  • npm test / ng test shared: 163/163, across 26 spec files — 5 of those tests are the new upload.adapter.spec.ts, the other 158 across 25 pre-existing files are unchanged by this ticket.
  • npm run ci: result and step count reported in the implementing agent's final answer.

Batch 5 close-out

Batch 5 (RB-25 through RB-30) is now fully implemented. For libs/shared/upload (moved to its layered home by RB-24) specifically: upload.machine.ts (domain) has its own spec and planFileSelection extracted by RB-26; upload-shell.service.ts (application) has a full spec covering upload()/cancel()/delete()/ pollReturning() via the UPLOAD_TRANSPORT token RB-25 added; upload-controller.ts (application) was already spec'd before this batch; upload.adapter.ts (infrastructure) now has uploadOutcome as a pure, spec'd seam, though the class's HTTP-bound methods (categories/status/delete/the XHR transport itself) remain untested by design — XHR is the one boundary this batch deliberately does not abstract, per TE-005's own instruction. End to end, every layer of the upload module that can hold pure logic now does, and has a spec proving it; what is left uncovered is exactly the DOM/network edge the module exists to wrap, not logic hiding behind it.