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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/web/__tests__/unit/dashboard-paste-import.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
193 changes: 191 additions & 2 deletions apps/web/__tests__/unit/import-media-progress.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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<void>) => task },
}));
vi.mock("@/lib/Rpcs", () => ({
withRpc: (
run: (rpc: {
VideoDelete: typeof mocks.deleteVideo;
VideoGetDownloadInfo: typeof mocks.downloadInfo;
}) => Promise<unknown>,
) =>
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";
Expand Down Expand Up @@ -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.",
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function DashboardPasteImport() {
})
.then((ok) => {
if (ok) router.push("/dashboard/caps");
else router.refresh();
})
.finally(() => {
handlingPasteRef.current = false;
Expand Down
Loading
Loading