refactor(shared): extract uploadOutcome from the XHR load closure (RB-27)
UploadAdapter.xhrUpload built new XMLHttpRequest() directly and put the
actual decisions inside its load listener: 2xx-vs-not, JSON.parse of the
body with a fallback, and ProblemDetails mapping via parseError. None of
it was reachable without stubbing the XHR global, so it had no spec
(TE-005; file LH 5/64, BRH 3/57).
Extract uploadOutcome(status, responseText): Result<string, {
documentId }>, a pure function next to genericError/parseError. It holds
the 2xx check, the JSON.parse-with-fallback, and the ProblemDetails
mapping. The load listener is now a two-line dispatch into it.
Abort-vs-error disambiguation stays where it is: it decides whether a
response exists at all, before uploadOutcome would even run, and the
proposed signature has no field for "aborted". It is already a one-line
ternary with no DOM-only logic to extract.
Add upload.adapter.spec.ts: plain describe/it, no DOM, no XHR stub,
covering a 2xx success, a 2xx unparseable body, a non-2xx ProblemDetails
body, a non-2xx non-ProblemDetails body, and the 200/300 boundary.
Verified red by editing uploadOutcome down to one line (an Edit, not
git checkout): 4 of 5 new specs failed. Re-applied with a second Edit.
Coverage for upload.adapter.ts: LH 5/64 -> 12/65, BRH 3/57 -> 7/59.
Skip TE-005's optional half (moving the currentScenario() branch into
KeepaliveTransport.send()): it needs a second file, upload-shell.
service.ts, and this ticket's own scope fences it to upload.adapter.ts
and its spec. The dev simulator's behaviour is unchanged.
Mark RB-27 implemented in 99-backlog.md and add its implementation note,
including a batch 5 close-out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { uploadOutcome } from './upload.adapter';
|
||||
|
||||
/** Matches the un-exported UPLOAD_FAILED fallback text in upload.adapter.ts. */
|
||||
const UPLOAD_FAILED = 'Uploaden is niet gelukt. Probeer het opnieuw.';
|
||||
|
||||
describe('uploadOutcome', () => {
|
||||
it('resolves a 2xx response with a valid JSON body to the document id', () => {
|
||||
const outcome = uploadOutcome(200, JSON.stringify({ documentId: 'doc-1' }));
|
||||
expect(outcome).toEqual({ ok: true, value: { documentId: 'doc-1' } });
|
||||
});
|
||||
|
||||
it('falls back to the generic error when a 2xx body is not valid JSON', () => {
|
||||
const outcome = uploadOutcome(201, 'not json');
|
||||
expect(outcome).toEqual({ ok: false, error: UPLOAD_FAILED });
|
||||
});
|
||||
|
||||
it('maps a non-2xx ProblemDetails body to its detail', () => {
|
||||
const outcome = uploadOutcome(
|
||||
409,
|
||||
JSON.stringify({ detail: 'Document is al aan een aanvraag gekoppeld.', status: 409 }),
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, error: 'Document is al aan een aanvraag gekoppeld.' });
|
||||
});
|
||||
|
||||
it('falls back to the generic error for a non-2xx body without a ProblemDetails detail', () => {
|
||||
const outcome = uploadOutcome(500, 'Internal Server Error');
|
||||
expect(outcome).toEqual({ ok: false, error: UPLOAD_FAILED });
|
||||
});
|
||||
|
||||
it('treats status 200-299 as success and everything else as failure', () => {
|
||||
expect(uploadOutcome(299, JSON.stringify({ documentId: 'd' })).ok).toBe(true);
|
||||
expect(uploadOutcome(300, JSON.stringify({ detail: 'x' })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { currentScenario } from '@shared/infrastructure/scenario';
|
||||
import { currentSubject } from '@shared/infrastructure/subject';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
import { DocumentCategory } from '@shared/domain/upload.machine';
|
||||
import { Result, err, ok } from '@shared/kernel/fp';
|
||||
|
||||
/** Answer-derived query params that affect which categories the server presents. */
|
||||
export interface CategoryParams {
|
||||
@@ -128,15 +129,8 @@ export class UploadAdapter {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve({ documentId: JSON.parse(xhr.responseText).documentId });
|
||||
} catch {
|
||||
reject(genericError());
|
||||
}
|
||||
} else {
|
||||
reject(parseError(xhr.responseText));
|
||||
}
|
||||
const outcome = uploadOutcome(xhr.status, xhr.responseText);
|
||||
outcome.ok ? resolve(outcome.value) : reject(outcome.error);
|
||||
});
|
||||
xhr.addEventListener('error', () => reject(genericError()));
|
||||
xhr.addEventListener('abort', () =>
|
||||
@@ -160,6 +154,24 @@ export class UploadAdapter {
|
||||
const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`;
|
||||
const genericError = (): string => UPLOAD_FAILED;
|
||||
|
||||
/**
|
||||
* Pure interpretation of one finished XHR `load` event: 2xx-vs-not, `JSON.parse`
|
||||
* of the body with a fallback to a generic error, and (on a non-2xx status)
|
||||
* ProblemDetails mapping via `parseError`. No DOM and no XHR — the listener that
|
||||
* calls this only reads `xhr.status`/`xhr.responseText` and dispatches the result.
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo-only (dev): the real XHR POST finishes instantly for metadata, so progress
|
||||
* and failure can't otherwise be shown. Drives the progress bar over ~2.5s, then
|
||||
|
||||
Reference in New Issue
Block a user