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>
7.1 KiB
RB-25 — UPLOAD_TRANSPORT injection token replaces inject(KeepaliveTransport)
Status: implemented · 2026-08-28 · Source finding: 02-testability.md TE-003 ·
99-backlog.md RB-25, "Merges" table row for RB-25/26/27 · Depends on
implementation/rb-24.md (the move that put this file at its current path)
What was wrong
libs/shared/src/application/upload-shell.service.ts defines export interface UploadTransport and documents it as the swap seam for upload transport. It then binds
private transport: UploadTransport = inject(KeepaliveTransport) — the concrete class,
which is @Injectable but not exported. A spec cannot reference the class to override its
provider, and cannot provide against the interface either, because an interface is not a
DI token. The port existed on paper only.
The consequence TE-003 measures: upload(), delete(), cancel(), and pollReturning()
— the methods that translate transport and adapter outcomes into UploadMsgs — had no
spec at all. libs/shared/upload sat at 52.0% line / 50.0% branch, and
upload-shell.service.ts was one of the two unreached non-ui/ files.
What changed
One file plus one new spec, exactly as scoped:
-
libs/shared/src/application/upload-shell.service.ts: addedexport const UPLOAD_TRANSPORT = new InjectionToken<UploadTransport>('UPLOAD_TRANSPORT', { providedIn: 'root', factory: () => inject(KeepaliveTransport), });copied verbatim from TE-003's own fix, placed directly under the
KeepaliveTransportclass it wraps.UploadShellService.transportnow readsinject(UPLOAD_TRANSPORT)instead ofinject(KeepaliveTransport). This is the same interface-plus-token shape asSessionPort/SESSION_PORT(libs/shared/src/application/session.port.ts), the repo's one other explicit port.KeepaliveTransportitself is untouched: still a private, unexported@Injectable, and still the default factory's target — a real app gets the exact same singleton instance it always did. -
libs/shared/src/application/upload-shell.service.spec.ts(new): a recording fakeUploadTransport(records everysend()call, exposesresolveDone/rejectDoneper call so a test drives the returnedPromiseby hand) provided againstUPLOAD_TRANSPORT, plus a fakeUploadAdapter(a plain object withvi.fn()forstatus/deleteDocument) provided against the already-exportedUploadAdapterclass. 16 specs across all four target methods:upload()—UploadQueuedcarries the transport'sbackgroundSyncAvailable;onProgress→UploadProgress; a resolved transport →UploadComplete; a rejected transport →UploadFailedwith the rejection reason; a rejection with theUPLOAD_ABORTEDsentinel dispatches nothing.cancel()— calls the stored cancel function for an in-flight upload and forgets it (a secondcancel()on the same id is a no-op); an unknown id is a no-op.delete()—UploadDeletingthenUploadDeleteCompleteon success;UploadDeleteFailedwith the server'sdetailon a ProblemDetails rejection; falls back to an empty reason when the rejection carries nodetail.pollReturning()— skips the adapter call entirely for an empty upload list; dispatchesBackgroundUploadsReturnedfiltered to only the items the server reportscompletewith adocumentId; dispatches nothing when nothing has arrived.
No other file changed. UploadAdapter, upload.machine.ts, and upload-controller.ts
are untouched, per the ticket's file-scope fence (RB-26 and RB-28 are concurrently in
adjacent files).
Verification
-
Coverage,
upload-shell.service.ts(npm run test:coveragenarrowed toshared, read fromcoverage/shared/lcov.info):Metric Before After Lines 0% 88.57% (31/35) Branches 0% 85.00% (17/20) Functions 0% 87.50% (14/16) "Before" is 0% across the board: no spec file for this service existed prior to this ticket (confirmed by
grep -rln UploadShellService --include=*.spec.ts, which returns only the new spec), matching TE-003's "unreached" classification. The remaining uncovered lines are theKeepaliveTransportclass body (send(), itsinject) and theUPLOAD_TRANSPORTfactory closure itself — both require a realXMLHttpRequest/real DI resolution to exercise and are intentionally out of this ticket's scope: TE-003's fix is the seam, not a rewrite of the transport it wraps. -
Red-proof. Edited
upload()'s success branch fromdispatch({ type: 'UploadComplete', localId: req.localId, documentId })todispatch({ type: 'UploadFailed', localId: req.localId, reason: 'BROKEN-FOR-RED-PROOF' }), ranng test shared. Result: 1 failed / 150 passed, withAssertionError: expected "vi.fn()" to be called with arguments: [ { type: 'UploadComplete', …(2) } ] Received: 1st vi.fn() call: [{ "backgroundSync": false, "localId": "l1", "type": "UploadQueued" }] 2nd vi.fn() call: [{ "localId": "l1", "reason": "BROKEN-FOR-RED-PROOF", "type": "UploadFailed" }]at
upload-shell.service.spec.ts:79(theUploadCompleteassertion). Re-applied the original line with a second edit (notgit checkout);git diffagainst HEAD shows only the intended token change — the red edit left no trace. Re-ran: 151/151 green. -
npm run ci: result and step count in the final answer.
Judgement call
- The fake
UploadAdapteris a plain object, not a class extendingUploadAdapter.UploadAdapteris exported and already usable as a DI token (it always was — TE-003's gap was specific toKeepaliveTransport, notUploadAdapter), sodelete()andpollReturning()(which never touchthis.transport) were technically fakeable before this ticket by providing a fakeUploadAdapter. Nobody had written that spec, though, andupload()/cancel()still neededUPLOAD_TRANSPORTregardless (they populate and drain theinflightmap viatransport.send()). The spec fakes both seams together so all four methods are exercised as one coherent suite, per the ticket's own framing ("provide a recording fake transport and assert the message translation inupload(),delete(),cancel()andpollReturning()").
Handoff to RB-27
RB-27 extracts uploadOutcome(status, responseText) out of the XHR closure in
libs/shared/src/infrastructure/upload.adapter.ts's xhrUpload — a different file,
untouched by this ticket. The token makes RB-27's optional half (moving the
currentScenario() branch into KeepaliveTransport.send()) no easier and no harder than
before: KeepaliveTransport is still unexported and its send() body is unchanged, one
line (inject(UploadAdapter), return this.adapter.xhrUpload(req, onProgress)). If RB-27
takes that optional move, it can inject UPLOAD_TRANSPORT in its own spec to assert the
scenario branch without touching this file — the seam is there and provided-in-root, but
RB-27 does not need to change anything here to use it.
npm run ci
Result and step count reported in the final answer.