diff --git a/src/features/workspaces/files/workspace-file-upload.test.ts b/src/features/workspaces/files/workspace-file-upload.test.ts new file mode 100644 index 00000000..2e11f362 --- /dev/null +++ b/src/features/workspaces/files/workspace-file-upload.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { runWorkspaceFileUploadBatch } from "#/features/workspaces/files/workspace-file-upload"; + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + promise: vi.fn(), + }, +})); + +function pptxFile(name: string) { + return new File(["deck"], name, { + type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }); +} + +function conversionFailureResponse() { + return new Response( + JSON.stringify({ + requestId: "req_test", + code: "CONVERSION_FAILED", + message: "Unable to convert this file to PDF right now.", + details: { message: "LibreOffice conversion failed: timed out." }, + }), + { status: 422, headers: { "content-type": "application/json" } }, + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("runWorkspaceFileUploadBatch", () => { + it("surfaces the real server reason when a single office conversion fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => conversionFailureResponse()), + ); + + await expect( + runWorkspaceFileUploadBatch({ + workspaceId: "ws_1", + parentId: null, + files: [pptxFile("23. Special Topic Limb Loss.pptx")], + onSuccess: () => {}, + }), + ).rejects.toMatchObject({ + message: + "Failed to upload 23. Special Topic Limb Loss.pptx. Unable to convert this file to PDF right now.", + }); + + vi.unstubAllGlobals(); + }); + + it("attaches the underlying failure as the error cause", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => conversionFailureResponse()), + ); + + const error = await runWorkspaceFileUploadBatch({ + workspaceId: "ws_1", + parentId: null, + files: [pptxFile("deck.pptx")], + onSuccess: () => {}, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).cause).toBeInstanceOf(Error); + expect(((error as Error).cause as Error).message).toBe( + "Unable to convert this file to PDF right now.", + ); + + vi.unstubAllGlobals(); + }); +}); diff --git a/src/features/workspaces/files/workspace-file-upload.ts b/src/features/workspaces/files/workspace-file-upload.ts index 7c1faea4..cf46477b 100644 --- a/src/features/workspaces/files/workspace-file-upload.ts +++ b/src/features/workspaces/files/workspace-file-upload.ts @@ -122,10 +122,17 @@ function uploadAcceptedFiles(input: { const total = jobs.length; return new Promise((resolve, reject) => { + const uploadErrors: Error[] = []; + new AsyncQueuer(postWorkspaceFileUpload, { concurrency: workspaceFileUploadLimits.concurrency, throwOnError: false, initialItems: jobs, + onError: (error) => { + // The queuer swallows per-job failures (throwOnError: false), so hold on to + // them here — otherwise the real server reason never reaches error tracking. + uploadErrors.push(error); + }, onSuccess: (command) => { input.onSuccess(command); }, @@ -137,13 +144,7 @@ function uploadAcceptedFiles(input: { const { successCount, errorCount } = queuer.store.state; if (successCount === 0) { - reject( - new Error( - total === 1 - ? `Failed to upload ${input.files[0]?.name ?? "file"}.` - : `Failed to upload ${total} files.`, - ), - ); + reject(buildUploadFailureError(input.files, total, uploadErrors)); return; } @@ -153,6 +154,37 @@ function uploadAcceptedFiles(input: { }); } +function buildUploadFailureError( + files: readonly File[], + total: number, + errors: readonly Error[], +): Error { + const summary = + total === 1 + ? `Failed to upload ${files[0]?.name ?? "file"}.` + : `Failed to upload ${total} files.`; + + const reason = summarizeUploadErrors(errors); + const error = new Error(reason ? `${summary} ${reason}` : summary); + + if (errors.length > 0) { + // Preserve the underlying failures so error tracking keeps the server context. + error.cause = errors.length === 1 ? errors[0] : errors; + } + + return error; +} + +function summarizeUploadErrors(errors: readonly Error[]): string | null { + const messages = [ + ...new Set( + errors.map((error) => getErrorMessage(error, "").trim()).filter((message) => message !== ""), + ), + ]; + + return messages.length > 0 ? messages.join(" ") : null; +} + async function getWorkspaceFileUploadErrorMessage(response: Response) { const fallback = "Unable to upload file to workspace storage.";