diff --git a/apps/web/__tests__/unit/dashboard-paste-import.test.ts b/apps/web/__tests__/unit/dashboard-paste-import.test.ts new file mode 100644 index 00000000000..878e2b76b9f --- /dev/null +++ b/apps/web/__tests__/unit/dashboard-paste-import.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DashboardPasteImport } from "@/app/(org)/dashboard/_components/DashboardPasteImport"; + +const mocks = vi.hoisted(() => ({ + importMediaFile: vi.fn(), + push: vi.fn(), + refresh: vi.fn(), +})); + +vi.mock("@tanstack/react-store", () => ({ useStore: () => false })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), +})); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); +vi.mock("@/app/(org)/dashboard/Contexts", () => ({ + useDashboardContext: () => ({ + activeOrganization: { organization: { id: "org-123" } }, + setUpgradeModalOpen: vi.fn(), + user: { isPro: true }, + }), +})); +vi.mock("@/app/(org)/dashboard/caps/UploadingContext", () => ({ + useUploadingContext: () => ({ + uploadingStore: {}, + setUploadStatus: vi.fn(), + }), +})); +vi.mock("@/app/(org)/dashboard/import/import-media", () => ({ + importMediaFile: mocks.importMediaFile, + isSupportedMediaFile: () => true, +})); + +describe("DashboardPasteImport", () => { + let root: Root; + + beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.importMediaFile.mockReset(); + mocks.push.mockReset(); + mocks.refresh.mockReset(); + root = createRoot(document.createElement("div")); + await act(async () => { + root.render(createElement(DashboardPasteImport)); + }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); + }); + + const paste = async () => { + const file = new File(["video"], "clip.mp4", { type: "video/mp4" }); + const event = new Event("paste", { cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { items: [], files: [file] }, + }); + await act(async () => { + window.dispatchEvent(event); + }); + }; + + it("refreshes the page so a failed import's deleted Cap disappears", async () => { + mocks.importMediaFile.mockResolvedValue(false); + await paste(); + + expect(mocks.refresh).toHaveBeenCalledTimes(1); + expect(mocks.push).not.toHaveBeenCalled(); + }); + + it("opens Caps after a successful import", async () => { + mocks.importMediaFile.mockResolvedValue(true); + await paste(); + + expect(mocks.push).toHaveBeenCalledWith("/dashboard/caps"); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/import-media-progress.test.ts b/apps/web/__tests__/unit/import-media-progress.test.ts index ed0318d1ddf..6d8ede64f71 100644 --- a/apps/web/__tests__/unit/import-media-progress.test.ts +++ b/apps/web/__tests__/unit/import-media-progress.test.ts @@ -1,10 +1,16 @@ import { Organisation, Video } from "@cap/web-domain"; +import { Option } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ create: vi.fn(), trigger: vi.fn(), progress: vi.fn(), + createImage: vi.fn(), + deleteVideo: vi.fn(), + downloadInfo: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), })); vi.mock("@/actions/video/create-for-processing", () => ({ @@ -14,16 +20,31 @@ vi.mock("@/actions/video/trigger-processing", () => ({ triggerVideoProcessing: mocks.trigger, })); vi.mock("@/actions/video/upload", () => ({ - createVideoAndGetUploadUrl: vi.fn(), + createVideoAndGetUploadUrl: mocks.createImage, })); vi.mock("@/app/(org)/dashboard/caps/components/sendProgressUpdate", () => ({ sendProgressUpdate: mocks.progress, })); +vi.mock("@/lib/EffectRuntime", () => ({ + EffectRuntime: { runPromise: (task: Promise) => task }, +})); +vi.mock("@/lib/Rpcs", () => ({ + withRpc: ( + run: (rpc: { + VideoDelete: typeof mocks.deleteVideo; + VideoGetDownloadInfo: typeof mocks.downloadInfo; + }) => Promise, + ) => + run({ + VideoDelete: mocks.deleteVideo, + VideoGetDownloadInfo: mocks.downloadInfo, + }), +})); vi.mock("@remotion/media-parser", () => ({ parseMedia: async () => ({ durationInSeconds: 60, dimensions: null }), })); vi.mock("sonner", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: mocks.toastSuccess, error: mocks.toastError }, })); import { importMediaFile } from "@/app/(org)/dashboard/import/import-media"; @@ -135,4 +156,172 @@ describe("dashboard video import progress", () => { expect(vi.getTimerCount()).toBe(0); }, ); + + describe("failed uploads", () => { + const startImport = async (file: File) => { + const importing = importMediaFile({ + file, + orgId: Organisation.OrganisationId.make("org-123"), + setUploadStatus: vi.fn(), + }); + const request = await vi.waitFor(() => { + if (!MockXMLHttpRequest.current) throw new Error("Upload not started"); + return MockXMLHttpRequest.current; + }); + return { importing, request }; + }; + const videoFile = () => { + const file = new File(["video"], "clip.mp4", { type: "video/mp4" }); + Object.defineProperty(file, "size", { value: total }); + return file; + }; + const imageFile = () => + new File(["image"], "shot.png", { type: "image/png" }); + + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + mocks.deleteVideo.mockReset().mockResolvedValue(undefined); + mocks.downloadInfo + .mockReset() + .mockResolvedValue( + Option.some({ fileName: "shot.png", downloadUrl: "https://signed" }), + ); + mocks.toastError.mockReset(); + mocks.toastSuccess.mockReset(); + mocks.trigger.mockReset().mockResolvedValue({ success: true }); + mocks.createImage.mockResolvedValue({ + id: videoId, + uploadTarget: { + type: "put", + url: "https://storage.example/upload", + headers: {}, + }, + }); + }); + + it("deletes the Cap when a video upload stops partway", async () => { + const { importing, request } = await startImport(videoFile()); + request.progress(total / 2, total); + request.onerror?.(); + + expect(await importing).toBe(false); + expect(mocks.deleteVideo).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.trigger).not.toHaveBeenCalled(); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to upload video. Please try again.", + ); + }); + + it("deletes the Cap when an image upload stops partway", async () => { + const image = imageFile(); + const { importing, request } = await startImport(image); + request.progress(image.size - 1, image.size); + request.onerror?.(); + + expect(await importing).toBe(false); + expect(mocks.deleteVideo).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to upload image. Please try again.", + ); + }); + + it("keeps the image Cap when the response was lost but storage has the image", async () => { + const image = imageFile(); + const { importing, request } = await startImport(image); + request.progress(image.size, image.size); + request.onerror?.(); + + expect(await importing).toBe(true); + expect(mocks.downloadInfo).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.deleteVideo).not.toHaveBeenCalled(); + expect(mocks.toastError).not.toHaveBeenCalled(); + expect(mocks.progress).toHaveBeenLastCalledWith( + videoId, + image.size, + image.size, + ); + }); + + it("deletes the image Cap when the response was lost and storage has no image", async () => { + mocks.downloadInfo.mockResolvedValue(Option.none()); + const image = imageFile(); + const { importing, request } = await startImport(image); + request.progress(image.size, image.size); + request.onerror?.(); + + expect(await importing).toBe(false); + expect(mocks.deleteVideo).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to upload image. Please try again.", + ); + }); + + it("keeps the image Cap but reports failure when storage cannot be checked", async () => { + mocks.downloadInfo.mockRejectedValue(new Error("offline")); + const image = imageFile(); + const { importing, request } = await startImport(image); + request.progress(image.size, image.size); + request.onerror?.(); + + expect(await importing).toBe(false); + expect(mocks.deleteVideo).not.toHaveBeenCalled(); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to upload image. Please try again.", + ); + }); + + it("deletes the image Cap when storage rejects it after every byte was sent", async () => { + const image = imageFile(); + const { importing, request } = await startImport(image); + request.progress(image.size, image.size); + request.status = 403; + request.onload?.(); + + expect(await importing).toBe(false); + expect(mocks.downloadInfo).not.toHaveBeenCalled(); + expect(mocks.deleteVideo).toHaveBeenCalledExactlyOnceWith(videoId); + expect(mocks.toastSuccess).not.toHaveBeenCalled(); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to upload image. Please try again.", + ); + }); + + it("still reports the upload failure when the delete fails", async () => { + mocks.deleteVideo.mockRejectedValue(new Error("delete failed")); + const { importing, request } = await startImport(videoFile()); + request.onerror?.(); + + expect(await importing).toBe(false); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to upload video. Please try again.", + ); + }); + + it("keeps the Cap when every byte was sent before the request failed", async () => { + const { importing, request } = await startImport(videoFile()); + request.progress(total, total); + request.onerror?.(); + + expect(await importing).toBe(true); + expect(mocks.trigger).toHaveBeenCalledTimes(1); + expect(mocks.deleteVideo).not.toHaveBeenCalled(); + }); + + it("keeps the uploaded Cap when processing fails to start", async () => { + mocks.trigger.mockRejectedValue(new Error("workflow unavailable")); + const { importing, request } = await startImport(videoFile()); + request.status = 200; + request.progress(total, total); + request.onload?.(); + + expect(await importing).toBe(false); + expect(mocks.deleteVideo).not.toHaveBeenCalled(); + expect(mocks.toastError).toHaveBeenCalledWith( + "Failed to start video processing. Please try again.", + ); + }); + }); }); diff --git a/apps/web/app/(org)/dashboard/_components/DashboardPasteImport.tsx b/apps/web/app/(org)/dashboard/_components/DashboardPasteImport.tsx index d30793b3984..aac9170775a 100644 --- a/apps/web/app/(org)/dashboard/_components/DashboardPasteImport.tsx +++ b/apps/web/app/(org)/dashboard/_components/DashboardPasteImport.tsx @@ -82,6 +82,7 @@ export function DashboardPasteImport() { }) .then((ok) => { if (ok) router.push("/dashboard/caps"); + else router.refresh(); }) .finally(() => { handlingPasteRef.current = false; diff --git a/apps/web/app/(org)/dashboard/import/import-media.ts b/apps/web/app/(org)/dashboard/import/import-media.ts index 22a1d5384c4..f2818916e91 100644 --- a/apps/web/app/(org)/dashboard/import/import-media.ts +++ b/apps/web/app/(org)/dashboard/import/import-media.ts @@ -1,13 +1,16 @@ "use client"; -import type { Folder, Organisation } from "@cap/web-domain"; +import type { Folder, Organisation, Video } from "@cap/web-domain"; +import { Option } from "effect"; import { toast } from "sonner"; import { createVideoForServerProcessing } from "@/actions/video/create-for-processing"; import { triggerVideoProcessing } from "@/actions/video/trigger-processing"; import { createVideoAndGetUploadUrl } from "@/actions/video/upload"; import { sendProgressUpdate } from "@/app/(org)/dashboard/caps/components/sendProgressUpdate"; import type { UploadStatus } from "@/app/(org)/dashboard/caps/UploadingContext"; -import { uploadWithTarget } from "@/utils/upload-target"; +import { EffectRuntime } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; +import { UploadRejectedError, uploadWithTarget } from "@/utils/upload-target"; import { getSupportedImageContentType, isSupportedVideoFile, @@ -51,6 +54,23 @@ export async function importMediaFile({ return false; } +// A retry creates a new Cap, so keeping this one would leave a dead card in the +// library for every failed attempt. +async function deleteFailedImport(videoId: Video.VideoId) { + try { + await EffectRuntime.runPromise(withRpc((rpc) => rpc.VideoDelete(videoId))); + } catch (error) { + console.error("Failed to delete failed import", error); + } +} + +async function isImageInStorage(videoId: Video.VideoId) { + const downloadInfo = await EffectRuntime.runPromise( + withRpc((rpc) => rpc.VideoGetDownloadInfo(videoId)), + ); + return Option.isSome(downloadInfo); +} + async function uploadImageAsScreenshot( file: File, contentType: string, @@ -77,24 +97,46 @@ async function uploadImageAsScreenshot( thumbnailUrl, }); let uploadTotal = file.size || 1; + let uploadedBytes = 0; - await uploadWithTarget({ - target: imageData.uploadTarget, - body: file, - fileName: - file.name || - (contentType === "image/png" ? "pasted-image.png" : "pasted-image.jpg"), - onProgress: ({ loaded, total }) => { - uploadTotal = Math.max(total, file.size, 1); - const percent = (loaded / uploadTotal) * 100; - setUploadStatus({ - status: "uploadingVideo", - capId: imageData.id, - progress: percent, - thumbnailUrl, - }); - }, - }); + try { + await uploadWithTarget({ + target: imageData.uploadTarget, + body: file, + fileName: + file.name || + (contentType === "image/png" + ? "pasted-image.png" + : "pasted-image.jpg"), + onProgress: ({ loaded, total }) => { + uploadTotal = Math.max(total, file.size, 1); + uploadedBytes = loaded; + const percent = (loaded / uploadTotal) * 100; + setUploadStatus({ + status: "uploadingVideo", + capId: imageData.id, + progress: percent, + thumbnailUrl, + }); + }, + }); + } catch (uploadError) { + // Drive lists an object as soon as its upload starts, so storage can only + // settle a lost response, not overrule an explicit rejection. + if ( + uploadedBytes < uploadTotal || + uploadError instanceof UploadRejectedError || + !(await isImageInStorage(imageData.id)) + ) { + await deleteFailedImport(imageData.id); + throw uploadError; + } + + console.warn( + "Upload response was lost but storage has the image; keeping it:", + uploadError, + ); + } await sendProgressUpdate(imageData.id, uploadTotal, uploadTotal); setUploadStatus(undefined); @@ -231,6 +273,7 @@ async function uploadVideoForServerProcessing( } catch (uploadError) { if (!progressTracker.didFinishSending()) { progressTracker.cleanup(); + await deleteFailedImport(uploadId); throw uploadError; } diff --git a/apps/web/utils/upload-target.ts b/apps/web/utils/upload-target.ts index 47ae42c4cf2..ca0393f3c48 100644 --- a/apps/web/utils/upload-target.ts +++ b/apps/web/utils/upload-target.ts @@ -12,6 +12,13 @@ type UploadProgress = { total: number; }; +export class UploadRejectedError extends Error { + constructor(status: number) { + super(`Upload failed with status ${status}`); + this.name = "UploadRejectedError"; + } +} + const isPostTarget = ( target: UploadTarget, ): target is { url: string; fields: Record } => @@ -52,7 +59,7 @@ export function uploadWithTarget({ if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { - reject(new Error(`Upload failed with status ${xhr.status}`)); + reject(new UploadRejectedError(xhr.status)); } }; xhr.onerror = () => reject(new Error("Upload failed")); @@ -79,7 +86,7 @@ export function uploadWithTarget({ if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { - reject(new Error(`Upload failed with status ${xhr.status}`)); + reject(new UploadRejectedError(xhr.status)); } }; xhr.onerror = () => reject(new Error("Upload failed"));