shoc-frontend-new/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx

182 lines
7.2 KiB
TypeScript
Raw Normal View History

import { createRef } from "react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const MB = 1_000_000;
function phoneFile(name: string, type: string, size: number): File {
return { name, type, size } as File;
}
function gallery(photos: number, videos: number): MediaFile[] {
const item = (id: string, fileType: "image" | "video") =>
({ id, fileType, isLegacy: false }) as unknown as MediaFile;
return [
...Array.from({ length: photos }, (_, i) => item(`p${i + 1}`, "image")),
...Array.from({ length: videos }, (_, i) => item(`v${i + 1}`, "video")),
];
}
function selectFiles(files: File[]) {
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files } });
}
describe("MediaUploaderDropzone", () => {
it("shows the SH-116 media contract and keeps the picker media-only", () => {
renderWithProviders(
<MediaUploaderDropzone
inputRef={createRef<HTMLInputElement>()}
pending={false}
onFilesSelected={vi.fn()}
/>,
);
expect(
screen.getByText(
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
const input = document.querySelector('input[type="file"]');
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
expect(input?.getAttribute("accept")).not.toContain(".pdf");
});
});
describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear();
vi.mocked(probeVideoDurationSeconds).mockReset();
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
});
it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
const videos = [
phoneFile("clip.mp4", "video/mp4", 60 * MB),
phoneFile("IMG_0001.MOV", "video/quicktime", 60 * MB),
phoneFile("IMG_0002.MOV", "", 60 * MB),
];
selectFiles(videos);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual(videos);
expect(toast.error).not.toHaveBeenCalled();
});
it("accepts an octet-stream video with a video extension", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
});
it("rejects a 101 MB video with the generic message and no file name", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects an 11 MB photo", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("rejects the 11th photo and the 4th video on a full work order", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={gallery(10, 3)} onUpload={onUpload} />);
selectFiles([
phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0201.MOV", "video/quicktime", 20 * MB),
]);
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
expect(onUpload).not.toHaveBeenCalled();
});
it("counts the whole work order's media when this tab shows only its share", async () => {
const onUpload = vi.fn();
renderWithProviders(
<MediaUploader uploads={[]} mediaCounts={{ photos: 10, videos: 3 }} onUpload={onUpload} />,
);
selectFiles([
phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB),
phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB),
]);
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
expect(onUpload).not.toHaveBeenCalled();
});
it("counts files within one selection against the remaining slots", async () => {
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={gallery(0, 2)} onUpload={onUpload} />);
const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB);
selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]);
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
});
it("rejects a video longer than 90 seconds when the duration is readable", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
);
expect(onUpload).not.toHaveBeenCalled();
});
it("does not block a video whose duration the browser cannot read", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const onUpload = vi.fn();
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
expect(toast.error).not.toHaveBeenCalled();
});
});