From 44e96cf2a3d6236414c1e9a9dcddc5aa9b628c6e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 20:59:26 -0300 Subject: [PATCH] fix(media): apply the SH-116 media contract to every upload surface Photos up to 10 MB (JPG/PNG/HEIC), videos up to 100 MB and 90 s (MP4/MOV), at most 10 photos and 3 videos per work order, pre-validated with stable generic messages on the Photos & Videos modal, the Completion Doc media tab, Extra Docs and the vendor portal. Video duration is read from metadata when the browser can; unreadable metadata never blocks. Mobile MIME variants (empty type, octet-stream with a video extension, QuickTime) stay accepted. The signed completion PDF keeps its 50 MB cap. --- .../detail/tabs/slide-over-extras-tab.tsx | 16 +- .../media/media-uploader-dropzone.tsx | 14 +- .../_components/media/media-uploader.tsx | 56 ++++++- .../_components/uplift-request-form.tsx | 12 +- .../v/_components/vendor-portal-documents.tsx | 12 +- src/app/v/_components/vendor-portal.css | 7 + .../vendor-portal/lib/document-validation.ts | 54 ++++++- .../utils/work-order-media-limits.ts | 95 +++++++++-- src/lib/probe-video-duration.ts | 36 +++++ .../media-upload-modal-api.test.tsx | 4 +- .../media-uploader-dropzone.test.tsx | 152 +++++++++++++++--- .../slide-over-extras-aveta.test.tsx | 6 +- .../app/v/vendor-portal-documents.test.tsx | 59 ++++++- .../utils/work-order-media-limits.test.ts | 56 ++++++- 14 files changed, 507 insertions(+), 72 deletions(-) create mode 100644 src/lib/probe-video-duration.ts diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a02ecb56..15a41b4e 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -20,10 +20,9 @@ import { } from "@/domain/work-orders/utils/extra-docs-aveta"; import { canDeleteWorkOrderMedia, - formatMediaSizeLimit, isWorkOrderExtraDocTypeAllowed, - isWorkOrderFileTooLarge, workOrderExtraDocsAcceptAttribute, + workOrderFileSizeRejection, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -188,11 +187,14 @@ export function SlideOverExtrasTab({ const accepted: File[] = []; for (const file of Array.from(files)) { if (!isWorkOrderExtraDocTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`); + toast.error( + "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.", + ); continue; } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + toast.error(sizeRejection); continue; } accepted.push(file); @@ -250,8 +252,8 @@ export function SlideOverExtrasTab({ {pending ? "Uploading…" : "Upload documents, photos or videos"} - Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "} - per file + Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 + MB and 90 s · Docs up to 50 MB void; }; +function mediaContractHint(): string { + const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000); + const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000); + return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`; +} + export function MediaUploaderDropzone({ inputRef, pending, @@ -41,7 +51,7 @@ export function MediaUploaderDropzone({ {pending ? "Uploading…" : "Drag files here or click to browse"} - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file + Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()} {categorizeAfterUpload ? " · categorize after upload" : ""} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index aed81332..c87f430e 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -4,11 +4,17 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work- import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { canDeleteWorkOrderMedia, - formatMediaSizeLimit, - isWorkOrderFileTooLarge, + countWorkOrderMediaKinds, + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + resolveWorkOrderUploadKind, + workOrderFileSizeRejection, isWorkOrderMediaTypeAllowed, + type WorkOrderUploadKind, } from "@/domain/work-orders/utils/work-order-media-limits"; import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; import { MediaUploaderDropzone } from "./media-uploader-dropzone"; import { MediaUploaderGrid } from "./media-uploader-grid"; @@ -32,6 +38,23 @@ type MediaUploaderProps = { pending?: boolean; }; +const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV."; +const PHOTO_COUNT_MESSAGE = `A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`; +const VIDEO_COUNT_MESSAGE = `A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`; +const VIDEO_DURATION_MESSAGE = `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; + +async function isWithinContract(file: File, kind: WorkOrderUploadKind): Promise { + if (kind === "video") { + // SH-116: duration is checked client-side only; unreadable metadata never blocks. + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + toast.error(VIDEO_DURATION_MESSAGE); + return false; + } + } + return true; +} + export function MediaUploader({ uploads, onUpload, @@ -45,19 +68,36 @@ export function MediaUploader({ }: MediaUploaderProps) { const inputRef = useRef(null); - const addFiles = (files: FileList | null) => { + const addFiles = async (files: FileList | null) => { if (readOnly || pending || !files || files.length === 0) return; + // Snapshot synchronously: the caller resets the input value right after this + // handler returns, which can detach a live FileList across awaits. + const selected = Array.from(files); + const counts = countWorkOrderMediaKinds(uploads); const accepted: File[] = []; - for (const file of Array.from(files)) { + for (const file of selected) { if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); + toast.error(UNSUPPORTED_TYPE_MESSAGE); continue; } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + toast.error(sizeRejection); continue; } + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { + toast.error(PHOTO_COUNT_MESSAGE); + continue; + } + if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { + toast.error(VIDEO_COUNT_MESSAGE); + continue; + } + if (!(await isWithinContract(file, kind))) continue; + if (kind === "photo") counts.photos += 1; + if (kind === "video") counts.videos += 1; accepted.push(file); } if (accepted.length === 0) return; @@ -95,7 +135,7 @@ export function MediaUploader({ inputRef={inputRef} pending={pending} categorizeAfterUpload={Boolean(onCategorize)} - onFilesSelected={addFiles} + onFilesSelected={(files) => void addFiles(files)} /> )} (); const [formError, setFormError] = useState(""); + const latestSelection = useRef(0); const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte); @@ -54,8 +55,11 @@ export function UpliftRequestForm({ onSubmit({ amount, reason: reason.trim(), evidenceFile }); }; - const chooseFile = (file?: File) => { - const validationMessage = file ? validateVendorDocument(file) : undefined; + const chooseFile = async (file?: File) => { + const selection = ++latestSelection.current; + const validationMessage = file ? await validateVendorDocument(file) : undefined; + // A newer pick may have settled while this one's video metadata was still loading. + if (selection !== latestSelection.current) return; setFormError(validationMessage ?? ""); setEvidenceFile(file); }; @@ -119,7 +123,7 @@ export function UpliftRequestForm({ type="file" className="vp-input" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" - onChange={(event) => chooseFile(event.target.files?.[0])} + onChange={(event) => void chooseFile(event.target.files?.[0])} /> diff --git a/src/app/v/_components/vendor-portal-documents.tsx b/src/app/v/_components/vendor-portal-documents.tsx index 3055952d..02ab0248 100644 --- a/src/app/v/_components/vendor-portal-documents.tsx +++ b/src/app/v/_components/vendor-portal-documents.tsx @@ -57,12 +57,12 @@ export function VendorPortalDocuments({ inputRef.current?.click(); }; - const uploadSelectedFile = (file?: File) => { + const uploadSelectedFile = async (file?: File) => { if (!file) { return; } - const validationMessage = validateVendorDocument(file); + const validationMessage = await validateVendorDocument(file); if (validationMessage) { setValidationError(validationMessage); return; @@ -83,9 +83,9 @@ export function VendorPortalDocuments({ ref={inputRef} type="file" aria-label="Choose completion document" - accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" + accept=".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime" hidden - onChange={(event) => uploadSelectedFile(event.target.files?.[0])} + onChange={(event) => void uploadSelectedFile(event.target.files?.[0])} /> {documents.length === 0 && ( @@ -155,6 +155,10 @@ export function VendorPortalDocuments({ > Upload document + + Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and + 90 s + )} diff --git a/src/app/v/_components/vendor-portal.css b/src/app/v/_components/vendor-portal.css index 1e57cb16..e670f930 100644 --- a/src/app/v/_components/vendor-portal.css +++ b/src/app/v/_components/vendor-portal.css @@ -190,6 +190,13 @@ margin-top: 12px; } +.vp-hint { + align-self: center; + flex-basis: 100%; + font-size: 12px; + color: var(--muted-foreground); +} + .vp-checklist-item { display: flex; align-items: center; diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index 3c1f5203..fef1f766 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -1,12 +1,54 @@ -const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; -const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]); +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; -export function validateVendorDocument(file: File): string | undefined { - if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) { - return "Only PDF, JPG, and PNG files are allowed."; +/** + * SH-116 client-confirmed media contract on the vendor portal: photos up to + * 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV). + * PDF documents keep the previous 10 MB document cap. + */ +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MAX_PHOTO_BYTES = 10_000_000; +const MAX_VIDEO_BYTES = 100_000_000; +const MAX_VIDEO_DURATION_SECONDS = 90; + +const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."; + +type VendorUploadKind = "document" | "photo" | "video"; + +function resolveKind(file: File): VendorUploadKind | null { + const extension = file.name.toLowerCase().split(".").pop() ?? ""; + const mime = file.type.toLowerCase(); + if (mime.startsWith("video/") || extension === "mp4" || extension === "mov") return "video"; + if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "heic"].includes(extension)) { + return "photo"; + } + if (mime === "application/pdf" || extension === "pdf") return "document"; + return null; +} + +export async function validateVendorDocument(file: File): Promise { + const kind = resolveKind(file); + if (!kind) { + return UNSUPPORTED_TYPE_MESSAGE; } - if (file.size > MAX_DOCUMENT_BYTES) { + if (kind === "photo" && file.size > MAX_PHOTO_BYTES) { + return "Photos must be 10 MB or smaller."; + } + + if (kind === "video") { + if (file.size > MAX_VIDEO_BYTES) { + return "Videos must be 100 MB or smaller."; + } + // Duration is checked client-side only; unreadable metadata never blocks. + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; + } + } + + if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) { return "Files must be 10 MB or smaller."; } + + return undefined; } diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 00d9d261..17989068 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,20 +1,37 @@ import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; -/** Backend RequestSizeLimit for media uploads and Extra Docs. */ -export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000; +/** + * SH-116 client-confirmed media contract (2026-09-22): photos up to 10 MB + * (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most + * 10 photos and 3 videos per work order. Applies to the Photos & Videos modal, + * the Completion Doc → Media tab and the vendor portal upload. Duration is + * checked client-side only (the server cannot probe it cheaply). Documents + * keep the 50 MB document cap; the signed completion PDF keeps its own cap. + */ +export const MAX_WORK_ORDER_PHOTO_BYTES = 10_000_000; +export const MAX_WORK_ORDER_VIDEO_BYTES = 100_000_000; +export const MAX_WORK_ORDER_DOCUMENT_BYTES = 50_000_000; -/** Backend RequestSizeLimit for completion-document uploads. */ +/** Backend RequestSizeLimit for completion-document (signed PDF) uploads. */ export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000; +/** Largest single upload the media endpoint accepts (RequestSizeLimit ceiling). */ +export const MAX_WORK_ORDER_MEDIA_REQUEST_BYTES = MAX_WORK_ORDER_VIDEO_BYTES; + +export const MAX_VIDEO_DURATION_SECONDS = 90; +export const MAX_PHOTOS_PER_WORK_ORDER = 10; +export const MAX_VIDEOS_PER_WORK_ORDER = 3; + const MEDIA_MIME_TYPES = new Set([ "image/jpeg", "image/jpg", "image/png", + "image/heic", "video/mp4", "video/quicktime", ]); -const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); +const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov"]); const EXTRA_DOC_MIME_TYPES = new Set([ ...MEDIA_MIME_TYPES, @@ -25,6 +42,8 @@ const EXTRA_DOC_MIME_TYPES = new Set([ const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]); +export type WorkOrderUploadKind = "photo" | "video" | "document"; + function fileExtension(file: File): string { const name = file.name.toLowerCase(); const dot = name.lastIndexOf("."); @@ -37,11 +56,48 @@ function isTypeAllowed(file: File, mimes: Set, extensions: Set): return mimeOk || extOk; } -export function isWorkOrderFileTooLarge( - file: File, - maxBytes = MAX_WORK_ORDER_MEDIA_BYTES, -): boolean { - return file.size > maxBytes; +export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind { + const mime = file.type.toLowerCase(); + const extension = fileExtension(file); + if (mime.startsWith("video/") || extension === ".mp4" || extension === ".mov") { + return "video"; + } + if ( + mime.startsWith("image/") || + extension === ".jpg" || + extension === ".jpeg" || + extension === ".png" || + extension === ".heic" + ) { + return "photo"; + } + return "document"; +} + +function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number { + if (kind === "photo") return MAX_WORK_ORDER_PHOTO_BYTES; + if (kind === "video") return MAX_WORK_ORDER_VIDEO_BYTES; + return MAX_WORK_ORDER_DOCUMENT_BYTES; +} + +export function isWorkOrderFileTooLarge(file: File, maxBytes?: number): boolean { + const limit = maxBytes ?? workOrderUploadKindLimit(resolveWorkOrderUploadKind(file)); + return file.size > limit; +} + +/** Stable per-kind rejection message; never echoes file metadata. */ +export function workOrderFileSizeRejection(file: File): string | null { + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && file.size > MAX_WORK_ORDER_PHOTO_BYTES) { + return "Photos must be 10 MB or smaller."; + } + if (kind === "video" && file.size > MAX_WORK_ORDER_VIDEO_BYTES) { + return "Videos must be 100 MB or smaller."; + } + if (kind === "document" && file.size > MAX_WORK_ORDER_DOCUMENT_BYTES) { + return "Documents must be 50 MB or smaller."; + } + return null; } export function isWorkOrderMediaTypeAllowed(file: File): boolean { @@ -53,17 +109,34 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean { } export function workOrderMediaAcceptAttribute(): string { - return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; + return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"; } export function workOrderExtraDocsAcceptAttribute(): string { return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`; } -export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { +export function formatMediaSizeLimit(maxBytes: number): string { return `${Math.round(maxBytes / 1_000_000)} MB`; } +export type WorkOrderMediaKindCounts = { photos: number; videos: number }; + +/** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */ +export function countWorkOrderMediaKinds( + uploads: Array<{ fileType?: string; isLegacy?: boolean }>, +): WorkOrderMediaKindCounts { + return uploads.reduce( + (counts, item) => { + if (item.isLegacy) return counts; + if (item.fileType === "image") counts.photos += 1; + if (item.fileType === "video") counts.videos += 1; + return counts; + }, + { photos: 0, videos: 0 }, + ); +} + /** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */ export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean { return isWorkOrderCompletionSurfaceReadOnly(status); diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts new file mode 100644 index 00000000..61f047f6 --- /dev/null +++ b/src/lib/probe-video-duration.ts @@ -0,0 +1,36 @@ +/** + * Best-effort client-side video duration probe. Resolves null when the browser + * cannot decode the container's metadata — callers must not block the upload in + * that case (SH-116: duration is a client-side check only). + */ +export const VIDEO_DURATION_PROBE_TIMEOUT_MS = 10_000; + +export function probeVideoDurationSeconds(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const video = document.createElement("video"); + let settled = false; + + const done = (value: number | null) => { + if (settled) return; + settled = true; + video.removeAttribute("src"); + video.load(); + URL.revokeObjectURL(url); + resolve(value); + }; + + const timeout = window.setTimeout(() => done(null), VIDEO_DURATION_PROBE_TIMEOUT_MS); + + video.preload = "metadata"; + video.onloadedmetadata = () => { + window.clearTimeout(timeout); + done(Number.isFinite(video.duration) ? video.duration : null); + }; + video.onerror = () => { + window.clearTimeout(timeout); + done(null); + }; + video.src = url; + }); +} diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index 48c4241c..7b7769df 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -117,7 +117,9 @@ describe("MediaUploadModal API wiring", () => { }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), + 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(); }); diff --git a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx index 48ccb432..20a71923 100644 --- a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -1,19 +1,39 @@ import { createRef } from "react"; -import { fireEvent, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +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 { - MAX_WORK_ORDER_MEDIA_BYTES, - workOrderMediaAcceptAttribute, -} from "@/domain/work-orders/utils/work-order-media-limits"; +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("matches the AAP Photos accept copy and keeps it media-only", () => { + it("shows the SH-116 media contract and keeps the picker media-only", () => { renderWithProviders( ()} @@ -23,28 +43,122 @@ describe("MediaUploaderDropzone", () => { ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), + 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(); - expect(screen.queryByText(/DOC/)).not.toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute()); expect(input?.getAttribute("accept")).not.toContain(".pdf"); }); +}); - it("rejects an oversized media file with the generic 200 MB toast", () => { - renderWithProviders(); +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); + }); - const oversizedFile = { - name: "mobile-clip.MP4", - type: "application/octet-stream", - size: MAX_WORK_ORDER_MEDIA_BYTES + 1, - } as File; - const input = document.querySelector('input[type="file"]'); + it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => { + const onUpload = vi.fn(); + renderWithProviders(); - fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } }); + 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); - expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.'); + 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 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(); }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx index 7dcdc7d2..7633c94f 100644 --- a/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-extras-aveta.test.tsx @@ -140,13 +140,15 @@ describe("SlideOverExtrasTab Aveta", () => { expect(getMediaContent).not.toHaveBeenCalled(); }); - it("shows Extra Docs accept copy with PDF DOC and 200MB", () => { + it("shows Extra Docs accept copy with the SH-116 media contract", () => { renderWithProviders( , ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"), + screen.getByText( + "Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 MB and 90 s · Docs up to 50 MB", + ), ).toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); expect(input?.getAttribute("accept")).toContain(".pdf"); diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index e8a33e78..19507184 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents"; import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api"; import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; import { renderWithProviders } from "@/test/test-utils"; +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); + const quarantinedDocument: VendorPortalDocument = { id: 12, originalFileName: "completion.pdf", @@ -40,7 +43,7 @@ describe("VendorPortalDocuments", () => { }); expect(await screen.findByRole("alert")).toHaveTextContent( - "Only PDF, JPG, and PNG files are allowed.", + "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.", ); expect(upload).not.toHaveBeenCalled(); }); @@ -62,6 +65,60 @@ describe("VendorPortalDocuments", () => { expect(screen.getByText("Processing")).toBeInTheDocument(); }); + describe("SH-116 media contract", () => { + const MB = 1_000_000; + const renderDocuments = () => + renderWithProviders( + , + { withAuth: false }, + ); + const choose = (file: File) => + fireEvent.change(screen.getByLabelText("Choose completion document"), { + target: { files: [file] }, + }); + + it("uploads a ~60 MB phone MOV whose browser type is empty", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null); + const upload = vi + .spyOn(vendorPortalApi, "uploadDocument") + .mockResolvedValue(quarantinedDocument); + renderDocuments(); + + const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File; + choose(video); + + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + expect(upload.mock.calls[0][0].file).toBe(video); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it.each([ + [ + { name: "clip.mp4", type: "video/mp4", size: 101 * MB }, + "Videos must be 100 MB or smaller.", + ], + [ + { name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB }, + "Photos must be 10 MB or smaller.", + ], + ])("rejects %o with a generic message", async (file, message) => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderDocuments(); + + choose(file as File); + + expect(await screen.findByRole("alert")).toHaveTextContent(message); + expect(upload).not.toHaveBeenCalled(); + }); + }); + it("reports upload progress and refreshes the dispatch after success", async () => { const onChanged = vi.fn().mockResolvedValue(undefined); let finishUpload: (() => void) | undefined; diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index da86cd31..631b2319 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -1,14 +1,20 @@ import { describe, expect, it } from "vitest"; import { canDeleteWorkOrderMedia, + countWorkOrderMediaKinds, formatMediaSizeLimit, isWorkOrderDocumentsReadOnly, isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, isWorkOrderMediaTypeAllowed, + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + MAX_VIDEOS_PER_WORK_ORDER, MAX_WORK_ORDER_COMPLETION_DOC_BYTES, - MAX_WORK_ORDER_MEDIA_BYTES, + MAX_WORK_ORDER_PHOTO_BYTES, + MAX_WORK_ORDER_VIDEO_BYTES, workOrderExtraDocsAcceptAttribute, + workOrderFileSizeRejection, workOrderMediaAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -25,12 +31,48 @@ describe("work-order-media-limits", () => { expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false); }); - it("caps uploads at 200_000_000 bytes and formats decimal MB", () => { - expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000); - expect(formatMediaSizeLimit()).toBe("200 MB"); - expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false); - expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true); - expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false); + it("applies the SH-116 per-kind caps with stable generic messages", () => { + const file = (name: string, type: string, size: number) => ({ name, type, size }) as File; + + expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000); + expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000); + expect(MAX_VIDEO_DURATION_SECONDS).toBe(90); + expect(MAX_PHOTOS_PER_WORK_ORDER).toBe(10); + expect(MAX_VIDEOS_PER_WORK_ORDER).toBe(3); + + expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 60_000_000))).toBeNull(); + expect(workOrderFileSizeRejection(file("IMG_1.MOV", "video/quicktime", 60_000_000))).toBeNull(); + expect(workOrderFileSizeRejection(file("IMG_1.MOV", "", 60_000_000))).toBeNull(); + expect( + workOrderFileSizeRejection(file("clip.mp4", "application/octet-stream", 60_000_000)), + ).toBeNull(); + expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 101_000_000))).toBe( + "Videos must be 100 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("IMG_2.jpg", "image/jpeg", 11_000_000))).toBe( + "Photos must be 10 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe( + "Photos must be 10 MB or smaller.", + ); + expect(workOrderFileSizeRejection(file("scope.pdf", "application/pdf", 51_000_000))).toBe( + "Documents must be 50 MB or smaller.", + ); + expect(isWorkOrderFileTooLarge(file("clip.mp4", "video/mp4", MAX_WORK_ORDER_VIDEO_BYTES))).toBe( + false, + ); + expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB"); + }); + + it("counts only non-legacy photos and videos toward the per-work-order limits", () => { + expect( + countWorkOrderMediaKinds([ + { fileType: "image" }, + { fileType: "image", isLegacy: true }, + { fileType: "video" }, + { fileType: "document" }, + ]), + ).toEqual({ photos: 1, videos: 1 }); }); it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {