2026-09-08 11:28:48 -03:00
|
|
|
import { createRef } from "react";
|
2026-09-24 20:59:26 -03:00
|
|
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
2026-09-21 15:09:51 -03:00
|
|
|
import { toast } from "react-toastify";
|
|
|
|
|
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
2026-09-08 11:28:48 -03:00
|
|
|
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
|
2026-09-24 20:59:26 -03:00
|
|
|
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";
|
2026-09-08 11:28:48 -03:00
|
|
|
import { renderWithProviders } from "@/test/test-utils";
|
|
|
|
|
|
2026-09-21 15:09:51 -03:00
|
|
|
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
2026-09-24 20:59:26 -03:00
|
|
|
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 } });
|
|
|
|
|
}
|
2026-09-21 15:09:51 -03:00
|
|
|
|
2026-09-08 11:28:48 -03:00
|
|
|
describe("MediaUploaderDropzone", () => {
|
2026-09-24 20:59:26 -03:00
|
|
|
it("shows the SH-116 media contract and keeps the picker media-only", () => {
|
2026-09-08 11:28:48 -03:00
|
|
|
renderWithProviders(
|
|
|
|
|
<MediaUploaderDropzone
|
|
|
|
|
inputRef={createRef<HTMLInputElement>()}
|
|
|
|
|
pending={false}
|
|
|
|
|
onFilesSelected={vi.fn()}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-08 13:48:02 -03:00
|
|
|
expect(
|
2026-09-24 20:59:26 -03:00
|
|
|
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)",
|
|
|
|
|
),
|
2026-09-08 13:48:02 -03:00
|
|
|
).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
2026-09-08 11:28:48 -03:00
|
|
|
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");
|
|
|
|
|
});
|
2026-09-24 20:59:26 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-09-21 15:09:51 -03:00
|
|
|
|
2026-09-24 20:59:26 -03:00
|
|
|
it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
|
|
|
|
|
const onUpload = vi.fn();
|
|
|
|
|
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
2026-09-21 15:09:51 -03:00
|
|
|
|
2026-09-24 20:59:26 -03:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-24 22:01:22 -03:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-24 20:59:26 -03:00
|
|
|
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} />);
|
2026-09-21 15:09:51 -03:00
|
|
|
|
2026-09-24 20:59:26 -03:00
|
|
|
selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
|
2026-09-21 15:09:51 -03:00
|
|
|
|
2026-09-24 20:59:26 -03:00
|
|
|
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(toast.error).not.toHaveBeenCalled();
|
2026-09-21 15:09:51 -03:00
|
|
|
});
|
2026-09-08 11:28:48 -03:00
|
|
|
});
|