From f4d22e2b3bf5661b172dd9ed18462e5a836b4949 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 21:45:27 -0300 Subject: [PATCH] fix: show server-canceled upload copy instead of connection error A session the server moves to Canceled throws MediaUploadCanceledError, which safeMediaUploadErrorMessage did not map, so it fell through to the generic 'Check your connection and try again' copy and blamed the network for a server-side cancellation. Map it to 'The upload session was canceled.' ahead of the user-abort branch, and cover the Canceled terminal state end to end through the scan-polling path. --- src/api/media-upload-contracts.ts | 3 ++ src/test/api/media-upload-engine.test.ts | 38 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/api/media-upload-contracts.ts b/src/api/media-upload-contracts.ts index baf1663a..7a4644d1 100644 --- a/src/api/media-upload-contracts.ts +++ b/src/api/media-upload-contracts.ts @@ -88,6 +88,9 @@ export function safeMediaUploadErrorMessage(error: Error): string { if (error instanceof MediaUploadScanTimeoutError) { return "The upload is still processing. Check back shortly to see the result."; } + if (error instanceof MediaUploadCanceledError) { + return "The upload session was canceled."; + } if (error.name === "AbortError") { return "Upload canceled."; } diff --git a/src/test/api/media-upload-engine.test.ts b/src/test/api/media-upload-engine.test.ts index e06b9264..3f4bbc0a 100644 --- a/src/test/api/media-upload-engine.test.ts +++ b/src/test/api/media-upload-engine.test.ts @@ -9,6 +9,10 @@ import { uploadFileResumable, } from "@/api/media-upload-engine"; import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client"; +import { + MediaUploadCanceledError, + safeMediaUploadErrorMessage, +} from "@/api/media-upload-contracts"; type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown }; @@ -500,6 +504,40 @@ describe("resumable media upload engine", () => { }); }); +describe("safeMediaUploadErrorMessage", () => { + it("maps a server-canceled session to its own copy, distinct from a user abort", async () => { + const file = fileOf(8); + handler = async (request) => { + if (request.method === "POST" && request.url === BASE) { + return jsonResponse(createdSession(8, 8)); + } + if (request.method === "PUT" && /chunks\/0$/.test(request.url)) { + return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] }); + } + if (request.method === "POST" && request.url.endsWith("/commit")) { + return jsonResponse({ ...createdSession(8, 8), state: "Scanning" }, 202); + } + if (request.method === "GET" && request.url === `${BASE}/u1`) { + return jsonResponse({ ...createdSession(8, 8), state: "Canceled" }); + } + return jsonResponse({}); + }; + + const error = await uploadFileResumable({ + file, + endpoints: endpointsWith(), + surface: "WorkOrderTable", + pollDelayMs: 0, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MediaUploadCanceledError); + expect(safeMediaUploadErrorMessage(error as Error)).toBe("The upload session was canceled."); + expect(safeMediaUploadErrorMessage(new DOMException("aborted", "AbortError"))).toBe( + "Upload canceled.", + ); + }); +}); + describe("chunk math helpers", () => { it("computes chunk counts and missing indices", () => { expect(chunkCountFor(0, 5_242_880)).toBe(1);