Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/features/workspaces/files/workspace-file-upload.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 39 additions & 7 deletions src/features/workspaces/files/workspace-file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,17 @@ function uploadAcceptedFiles(input: {
const total = jobs.length;

return new Promise((resolve, reject) => {
const uploadErrors: Error[] = [];

new AsyncQueuer<WorkspaceFileUploadJob>(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);
},
Expand All @@ -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;
}

Expand All @@ -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.";

Expand Down
Loading