shoc-frontend-new/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx
Alexandre Brandizzi c99e81d0d7 fix(media): pre-check duration and per-work-order counts on Extra Docs
Extra Docs advertised the 90-second limit but only checked type and size.
Both dispatcher surfaces now share one screening step (type, size, count,
duration), and the Completion Doc media tab and Extra Docs count the whole
work order's photos and videos rather than only their own tab's share.
Failed local uploads no longer count toward the limit.
2026-09-24 22:01:22 -03:00

181 lines
7.2 KiB
TypeScript

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();
});
});