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( ()} 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(); 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(); 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(); 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(); 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(); 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( , ); 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(); 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(); 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(); selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]); await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); expect(toast.error).not.toHaveBeenCalled(); }); });