diff --git a/e2e/vendors/vendor-uplift-workflow.spec.ts b/e2e/vendors/vendor-uplift-workflow.spec.ts index f23f3ee1..9f87208e 100644 --- a/e2e/vendors/vendor-uplift-workflow.spec.ts +++ b/e2e/vendors/vendor-uplift-workflow.spec.ts @@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy await page.getByLabel("New total NTE").fill("125"); await page.getByLabel("Reason").fill("Replacement compressor"); - await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({ + await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({ name: "estimate.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\nmock estimate"), diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx index 24e468d1..cd85ad9e 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx @@ -5,10 +5,12 @@ import { } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits"; type SlideOverCompletionMediaProps = { isReadOnly: boolean; media: MediaFile[]; + mediaCounts?: WorkOrderMediaKindCounts; mediaPending: boolean; onUploadMedia?: (requests: MediaUploadRequest[]) => void; onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void; @@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = { export function SlideOverCompletionMedia({ isReadOnly, media, + mediaCounts, mediaPending, onUploadMedia, onCategorizeMedia, @@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
void; @@ -39,6 +41,7 @@ export function SlideOverCompletionTab({ isReadOnly, completion, media, + mediaCounts, mediaPending = false, completionPending = false, onUploadMedia, @@ -59,6 +62,7 @@ export function SlideOverCompletionTab({ = { @@ -53,11 +57,16 @@ type SlideOverExtrasTabProps = { pending?: boolean; avetaRequired?: boolean; workOrderId?: string | number; + /** Photos/videos on the whole work order; Extra Docs shows only its own share. */ + mediaCounts?: WorkOrderMediaKindCounts; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +const UNSUPPORTED_TYPE_MESSAGE = + "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC."; + async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { const tab = window.open("about:blank", "_blank"); if (!tab) { @@ -176,27 +185,38 @@ export function SlideOverExtrasTab({ pending = false, avetaRequired = false, workOrderId, + mediaCounts, onUpload, onRemove, onCategorize, }: SlideOverExtrasTabProps) { const inputRef = useRef(null); const avetaAttached = hasExplicitAvetaFile(uploads); + // One selection at a time: a second pick during the async screen would read the + // same photo/video counts and could pass the per-work-order limit. + const screeningRef = useRef(false); + const [screening, setScreening] = useState(false); + const busy = pending || screening; - const addFiles = (files: FileList | null) => { - if (isReadOnly || pending || !files || files.length === 0) return; - 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).`); - continue; - } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); - continue; - } - accepted.push(file); + const addFiles = async (files: FileList | null) => { + if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return; + // Snapshot synchronously: the input is reset right after this handler returns. + const selected = Array.from(files); + screeningRef.current = true; + setScreening(true); + let screened: WorkOrderUploadScreenResult; + try { + screened = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderExtraDocTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + } finally { + screeningRef.current = false; + setScreening(false); } + const { accepted, rejections } = screened; + for (const message of rejections) toast.error(message); if (accepted.length > 0) { onUpload(accepted); } @@ -228,12 +248,12 @@ export function SlideOverExtrasTab({ <> { - addFiles(event.target.files); + void addFiles(event.target.files); event.target.value = ""; }} /> diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 5bde5578..d4e26b70 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -17,6 +17,7 @@ import { isExtrasSurfaceMedia, type MediaSurfaceOwnership, } from "@/domain/work-orders/utils/partition-slide-over-media"; +import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -130,6 +131,9 @@ export function useSlideOverMediaState({ [detailForRow?.media, ownership, completionHasGallery], ); + // Per-work-order photo/video counts span both tabs, so count the whole session. + const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]); + const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); const mediaPending = mediaBatchPending || @@ -196,6 +200,7 @@ export function useSlideOverMediaState({ return { completionMedia, extrasUploads, + mediaCounts, mediaPending, uploadMediaRequests, uploadExtraFiles, diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index f9263dd1..78e95ac2 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({ const { completionMedia, extrasUploads, + mediaCounts, mediaPending, uploadMediaRequests, uploadExtraFiles, @@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({ ...readFlags, completionMedia, extrasUploads, + mediaCounts, mediaPending, completionPending, closeDisabled, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 7337ace1..12d015fa 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -125,6 +125,7 @@ export function WorkOrderSlideOverTabs({ isReadOnly={state.isDocumentsReadOnly} completion={state.detailForRow?.completion} media={state.completionMedia} + mediaCounts={state.mediaCounts} mediaPending={state.mediaPending} completionPending={state.completionPending} onUploadMedia={(requests) => { @@ -151,6 +152,7 @@ export function WorkOrderSlideOverTabs({ > 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..0bc1592c 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -1,14 +1,18 @@ -import { useRef } from "react"; +import { useRef, useState } from "react"; import { toast } from "react-toastify"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import { canDeleteWorkOrderMedia, - formatMediaSizeLimit, - isWorkOrderFileTooLarge, + countWorkOrderMediaKinds, isWorkOrderMediaTypeAllowed, + type WorkOrderMediaKindCounts, } from "@/domain/work-orders/utils/work-order-media-limits"; import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; +import { + screenWorkOrderUploads, + type WorkOrderUploadScreenResult, +} from "@/domain/work-orders/utils/screen-work-order-uploads"; import { MediaUploaderDropzone } from "./media-uploader-dropzone"; import { MediaUploaderGrid } from "./media-uploader-grid"; @@ -30,8 +34,12 @@ type MediaUploaderProps = { onChange?: (next: MediaFile[]) => void; readOnly?: boolean; pending?: boolean; + /** Photos/videos on the whole work order when `uploads` is only one surface's share. */ + mediaCounts?: WorkOrderMediaKindCounts; }; +const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV."; + export function MediaUploader({ uploads, onUpload, @@ -42,24 +50,35 @@ export function MediaUploader({ onChange, readOnly = false, pending = false, + mediaCounts, }: MediaUploaderProps) { const inputRef = useRef(null); + // One selection at a time: a second pick during the async screen would read the + // same photo/video counts and could pass the per-work-order limit. + const screeningRef = useRef(false); + const [screening, setScreening] = useState(false); - const addFiles = (files: FileList | null) => { - if (readOnly || pending || !files || files.length === 0) return; + const addFiles = async (files: FileList | null) => { + if (readOnly || pending || screeningRef.current || !files || files.length === 0) return; - const accepted: File[] = []; - for (const file of Array.from(files)) { - if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); - continue; - } - if (isWorkOrderFileTooLarge(file)) { - toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); - continue; - } - accepted.push(file); + // 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); + screeningRef.current = true; + setScreening(true); + let screened: WorkOrderUploadScreenResult; + try { + screened = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderMediaTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + } finally { + screeningRef.current = false; + setScreening(false); } + const { accepted, rejections } = screened; + for (const message of rejections) toast.error(message); if (accepted.length === 0) return; if (onUpload) { @@ -93,9 +112,9 @@ export function MediaUploader({ {!readOnly && ( void addFiles(files)} /> )} @@ -68,6 +69,7 @@ export function DispatchDetailSections({ dispatchId={dispatchId} currentNte={data.nteAmount} upliftRequests={data.upliftRequests ?? []} + mediaCounts={data.mediaCounts} locked={locked} onChanged={refresh} pollDocument={pollEvidenceDocument} diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx index c9f4845e..f9b6dd8f 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Text } from "@/components/ui/text"; import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form"; +import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation"; import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation"; import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal"; import { formatDateTime } from "@/lib/time-utils"; @@ -15,6 +16,7 @@ type UpliftRequestCardProps = { onWithdraw: (request: VendorPortalUpliftRequest) => void; onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void; onCancelRevise: () => void; + mediaUsage?: VendorMediaUsage; }; const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]); @@ -33,6 +35,7 @@ export function UpliftRequestCard({ onWithdraw, onRevise, onCancelRevise, + mediaUsage, }: UpliftRequestCardProps) { const [revising, setRevising] = useState(false); const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status); @@ -100,6 +103,7 @@ export function UpliftRequestCard({ initialRequested={String(request.requestedNTE)} initialReason={request.vendorReason ?? ""} hasExistingEvidence={request.evidenceDocumentId != null} + mediaUsage={mediaUsage} submitting={submitting} uploadingPercent={uploadingPercent} errorMessage="" diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx index 22ddbffe..0ee1744a 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -1,6 +1,10 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Text } from "@/components/ui/text"; -import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation"; +import { + validateVendorDocument, + VENDOR_UPLOAD_ACCEPT, + type VendorMediaUsage, +} from "@/domain/vendor-portal/lib/document-validation"; import { isApprovalRequired, validateUpliftAmount, @@ -13,6 +17,8 @@ type UpliftRequestFormProps = { initialRequested?: string; initialReason?: string; hasExistingEvidence?: boolean; + /** Work-order photo/video usage for the evidence pre-check. */ + mediaUsage?: VendorMediaUsage; submitting: boolean; uploadingPercent: number | undefined; errorMessage: string; @@ -26,6 +32,7 @@ export function UpliftRequestForm({ initialRequested = "", initialReason = "", hasExistingEvidence = false, + mediaUsage, submitting, uploadingPercent, errorMessage, @@ -36,11 +43,15 @@ export function UpliftRequestForm({ const [reason, setReason] = useState(initialReason); const [evidenceFile, setEvidenceFile] = useState(); const [formError, setFormError] = useState(""); + const [checkingEvidence, setCheckingEvidence] = useState(false); + const latestSelection = useRef(0); const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte); const submit = (event: React.FormEvent) => { event.preventDefault(); + // The picked file is not in state until its check settles; submitting now would validate the old one. + if (checkingEvidence) return; const amount = Number.parseFloat(requested); const validationError = validateUpliftAmount(requested, currentNte, reason, { file: evidenceFile, @@ -54,10 +65,15 @@ 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; + setCheckingEvidence(Boolean(file)); + const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : 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); + setCheckingEvidence(false); }; const shownError = errorMessage || formError; @@ -112,14 +128,16 @@ export function UpliftRequestForm({
- + chooseFile(event.target.files?.[0])} + accept={VENDOR_UPLOAD_ACCEPT} + onChange={(event) => void chooseFile(event.target.files?.[0])} />
@@ -134,8 +152,14 @@ export function UpliftRequestForm({ {shownError}
- {mode === "revise" && ( + {!locked && ( + + )} +
+ + ); +} + type UploadRequest = { file: File; replacementId?: number; @@ -24,12 +90,15 @@ export function VendorPortalDocuments({ dispatchId, documents, locked, + mediaCounts, onChanged, }: VendorPortalDocumentsProps) { const inputRef = useRef(null); const [replacementId, setReplacementId] = useState(); const [progress, setProgress] = useState(); const [validationError, setValidationError] = useState(); + // Bumped on every new pick so a slower validation of an earlier file cannot upload it. + const selectionRef = useRef(0); const uploadMutation = useMutation({ mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) => @@ -51,18 +120,27 @@ export function VendorPortalDocuments({ }); const chooseFile = (documentId?: number) => { + selectionRef.current += 1; setValidationError(undefined); uploadMutation.reset(); setReplacementId(documentId); inputRef.current?.click(); }; - const uploadSelectedFile = (file?: File) => { + const uploadSelectedFile = async (file?: File) => { if (!file) { return; } - const validationMessage = validateVendorDocument(file); + const selection = ++selectionRef.current; + const replaced = documents.find((document) => document.id === replacementId); + const validationMessage = await validateVendorDocument( + file, + vendorMediaUsage(mediaCounts, "completion", replaced?.contentType), + ); + if (selection !== selectionRef.current) { + return; + } if (validationMessage) { setValidationError(validationMessage); return; @@ -83,9 +161,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={VENDOR_UPLOAD_ACCEPT} hidden - onChange={(event) => uploadSelectedFile(event.target.files?.[0])} + onChange={(event) => void uploadSelectedFile(event.target.files?.[0])} /> {documents.length === 0 && ( @@ -93,45 +171,14 @@ export function VendorPortalDocuments({ )} {documents.map((document) => ( -
-
- File - - {document.originalFileName} · v{document.version} - -
-
- Security scan - {document.scanStatus} -
-
- Review - {document.reviewStatus} -
- {Boolean(document.rejectionReason) && ( - {document.rejectionReason} - )} -
- - {!locked && ( - - )} -
-
+ void vendorPortalApi.downloadDocument(token, dispatchId, document)} + onReplace={() => chooseFile(document.id)} + /> ))} {progress !== undefined && ( @@ -155,6 +202,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 · PDFs up to 10 MB + )} 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..ee7969ab 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -1,12 +1,129 @@ -const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; -const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]); +import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal"; +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."; +/** + * 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."; + +/** File-picker filter for every vendor portal upload, matching the allowlist below. */ +export const VENDOR_UPLOAD_ACCEPT = + ".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime"; + +type VendorUploadKind = "document" | "photo" | "video"; + +// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the +// extension decides (mobile browsers often send an empty or octet-stream type). +const KIND_BY_MIME: Record = { + "application/pdf": "document", + "image/jpeg": "photo", + "image/jpg": "photo", + "image/png": "photo", + "image/heic": "photo", + "video/mp4": "video", + "video/quicktime": "video", +}; + +const KIND_BY_EXTENSION: Record = { + pdf: "document", + jpg: "photo", + jpeg: "photo", + png: "photo", + heic: "photo", + mp4: "video", + mov: "video", +}; + +function resolveKind(file: File): VendorUploadKind | null { + const name = file.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null; +} + +/** Photos/videos already counted against the work order for one upload, with the limits. */ +export type VendorMediaUsage = { + photos: number; + videos: number; + maxPhotos: number; + maxVideos: number; +}; + +/** + * The count basis the server uses for this upload. A completion upload replaces a + * document: the latest one by default (already excluded in `completion*`), or the + * one the vendor picked, which is not counted when it is the same kind. Evidence + * uploads count everything. Undefined when the backend does not report counts. + */ +export function vendorMediaUsage( + counts: VendorPortalMediaCounts | null | undefined, + purpose: "completion" | "evidence", + replacedContentType?: string, +): VendorMediaUsage | undefined { + if (!counts) return undefined; + const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos }; + if (purpose === "evidence") { + return { ...limits, photos: counts.photos, videos: counts.videos }; + } + if (replacedContentType === undefined) { + return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos }; + } + const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()]; + return { + ...limits, + photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)), + videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)), + }; +} + +function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined { + if (kind === "photo" && usage.photos >= usage.maxPhotos) { + return `A work order can have at most ${usage.maxPhotos} photos.`; + } + if (kind === "video" && usage.videos >= usage.maxVideos) { + return `A work order can have at most ${usage.maxVideos} videos.`; + } + return undefined; +} + +export async function validateVendorDocument( + file: File, + usage?: VendorMediaUsage, +): Promise { + const kind = resolveKind(file); + if (!kind) { + return UNSUPPORTED_TYPE_MESSAGE; } - if (file.size > MAX_DOCUMENT_BYTES) { + const countMessage = usage && countRejection(kind, usage); + if (countMessage) { + return countMessage; + } + + 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."; + } + // Pre-check; the server enforces it too. 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/vendor-portal/lib/uplift-validation.ts b/src/domain/vendor-portal/lib/uplift-validation.ts index b8fafe04..7b3187ed 100644 --- a/src/domain/vendor-portal/lib/uplift-validation.ts +++ b/src/domain/vendor-portal/lib/uplift-validation.ts @@ -48,7 +48,7 @@ export function validateUpliftAmount( return "Please include a reason for the uplift request."; } if (!options.file && !options.hasExisting) { - return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence."; + return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence."; } return ""; } diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts index d9853b07..ec7da972 100644 --- a/src/domain/vendor-portal/types/vendor-portal.ts +++ b/src/domain/vendor-portal/types/vendor-portal.ts @@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary { refusedAt?: string; } +/** Counts for a new completion version exclude the document it replaces. */ +export interface VendorPortalMediaCounts { + maxPhotos: number; + maxVideos: number; + photos: number; + videos: number; + completionPhotos: number; + completionVideos: number; +} + export interface VendorPortalDocument { id: number; originalFileName: string; @@ -137,4 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary upliftRequests: VendorPortalUpliftRequest[]; signoffs: VendorPortalSignoff[]; documents: VendorPortalDocument[]; + /** Per-work-order photo/video usage; absent until the backend reports it. */ + mediaCounts?: VendorPortalMediaCounts | null; } diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 54b333ed..a1034796 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -233,7 +233,7 @@ function cryptoRandomId(): string { function classifyFileName(fileName: string, url: string): FileCategory { const name = `${fileName} ${url}`.toLowerCase(); - if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) { + if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) { return "image"; } if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) { diff --git a/src/domain/work-orders/utils/screen-work-order-uploads.ts b/src/domain/work-orders/utils/screen-work-order-uploads.ts new file mode 100644 index 00000000..49988f80 --- /dev/null +++ b/src/domain/work-orders/utils/screen-work-order-uploads.ts @@ -0,0 +1,64 @@ +import { + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + resolveWorkOrderUploadKind, + workOrderFileSizeRejection, + type WorkOrderMediaKindCounts, +} from "@/domain/work-orders/utils/work-order-media-limits"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +export type WorkOrderUploadScreen = { + /** Photos and videos already on the work order, across every surface. */ + existing: WorkOrderMediaKindCounts; + isTypeAllowed: (file: File) => boolean; + unsupportedTypeMessage: string; +}; + +export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] }; + +/** + * Browser pre-check shared by every dispatcher upload surface: type, size, + * per-work-order photo/video count (including earlier files in the same selection) + * and video duration. The server enforces the same contract; a video whose duration + * the browser cannot read is never blocked here. + */ +export async function screenWorkOrderUploads( + files: readonly File[], + { existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen, +): Promise { + const counts = { ...existing }; + const accepted: File[] = []; + const rejections: string[] = []; + for (const file of files) { + if (!isTypeAllowed(file)) { + rejections.push(unsupportedTypeMessage); + continue; + } + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + rejections.push(sizeRejection); + continue; + } + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { + rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`); + continue; + } + if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { + rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`); + continue; + } + if (kind === "video") { + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`); + continue; + } + } + if (kind === "photo") counts.photos += 1; + if (kind === "video") counts.videos += 1; + accepted.push(file); + } + return { accepted, rejections }; +} 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..0dd7cd49 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,38 @@ 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; +/** + * 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 + * pre-checked here and enforced by the server from the MP4/MOV header; + * unreadable metadata never blocks. 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 +43,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 +57,58 @@ 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; +const KIND_BY_MIME: Record = { + "image/jpeg": "photo", + "image/jpg": "photo", + "image/png": "photo", + "image/heic": "photo", + "video/mp4": "video", + "video/quicktime": "video", +}; + +const KIND_BY_EXTENSION: Record = { + ".jpg": "photo", + ".jpeg": "photo", + ".png": "photo", + ".heic": "photo", + ".mp4": "video", + ".mov": "video", +}; + +/** + * Same rule as the server: an allowlisted browser type decides, otherwise the + * extension (mobile browsers often send an empty, octet-stream or foreign type). + */ +export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind { + return ( + KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "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 +120,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; uploadStatus?: string }>, +): WorkOrderMediaKindCounts { + return uploads.reduce( + (counts, item) => { + if (item.isLegacy || item.uploadStatus === "failed") 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/domain/work-orders/utils/work-order-media-utils.ts b/src/domain/work-orders/utils/work-order-media-utils.ts index 4deee9a0..42c77c1b 100644 --- a/src/domain/work-orders/utils/work-order-media-utils.ts +++ b/src/domain/work-orders/utils/work-order-media-utils.ts @@ -3,6 +3,7 @@ import type { MediaCategory, MediaFile, } from "@/domain/work-orders/types/work-order-media"; +import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits"; let mediaIdCounter = 0; @@ -16,6 +17,10 @@ export function classifyUpload(f: File): { if (t === "application/pdf" || /\.pdf$/i.test(f.name)) { return { fileType: "pdf", category: "Report" }; } + // Phones often send an empty or octet-stream type; fall back to the extension. + const kind = resolveWorkOrderUploadKind(f); + if (kind === "photo") return { fileType: "image", category: "Photo" }; + if (kind === "video") return { fileType: "video", category: "Video" }; return { fileType: "other", category: "Other" }; } diff --git a/src/lib/probe-video-duration.ts b/src/lib/probe-video-duration.ts new file mode 100644 index 00000000..f4e019fe --- /dev/null +++ b/src/lib/probe-video-duration.ts @@ -0,0 +1,75 @@ +/** + * Best-effort video duration probe for MP4/MOV. Reads the movie header + * (`moov` → `mvhd`) the same way the server does, seeking over box headers so a + * phone file with `moov` after a large `mdat` costs only a few small reads. + * Resolves null when the header cannot be read — callers must not block the + * upload in that case (the server enforces the limit from the same + * header and also lets unreadable metadata through). + */ +const MAX_BOXES_PER_LEVEL = 1024; + +type Box = { bodyStart: number; end: number }; + +async function readView(file: Blob, start: number, length: number): Promise { + if (start < 0 || length <= 0 || start + length > file.size) return null; + const buffer = await file.slice(start, start + length).arrayBuffer(); + return buffer.byteLength === length ? new DataView(buffer) : null; +} + +function boxType(view: DataView): string { + return String.fromCharCode( + view.getUint8(4), + view.getUint8(5), + view.getUint8(6), + view.getUint8(7), + ); +} + +async function findBox(file: Blob, start: number, end: number, type: string): Promise { + let offset = start; + for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) { + const header = await readView(file, offset, Math.min(16, end - offset)); + if (!header) return null; + let size = header.getUint32(0); + let headerLength = 8; + if (size === 1) { + if (header.byteLength < 16) return null; + const largeSize = header.getBigUint64(8); + if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null; + size = Number(largeSize); + headerLength = 16; + } else if (size === 0) { + size = end - offset; + } + if (size < headerLength || offset + size > end) return null; + if (boxType(header) === type) { + return { bodyStart: offset + headerLength, end: offset + size }; + } + offset += size; + } + return null; +} + +async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise { + const version = await readView(file, mvhd.bodyStart, 1); + if (!version) return null; + const isVersion1 = version.getUint8(0) === 1; + const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20); + if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null; + const timescale = fields.getUint32(isVersion1 ? 20 : 12); + const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16)); + const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn; + if (timescale === 0 || duration === unknown) return null; + return Number(duration) / timescale; +} + +export async function probeVideoDurationSeconds(file: Blob): Promise { + try { + const moov = await findBox(file, 0, file.size, "moov"); + if (!moov) return null; + const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd"); + return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null; + } catch { + return null; + } +} 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..9aae2d73 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,139 @@ 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 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(); }); }); 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/(protected)/workorders/slide-over-extras-upload-contract.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx new file mode 100644 index 00000000..f179079d --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; +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 selectFiles(files: File[]) { + const input = document.querySelector('input[type="file"]'); + fireEvent.change(input as HTMLInputElement, { target: { files } }); +} + +describe("SlideOverExtrasTab SH-116 media contract", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + vi.mocked(probeVideoDurationSeconds).mockReset(); + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + }); + + it("rejects a video longer than 90 seconds before upload", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95); + const onUpload = vi.fn(); + renderWithProviders( + , + ); + + selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."), + ); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("rejects the 11th photo and 4th video counted across the whole work order", async () => { + const onUpload = vi.fn(); + const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB); + renderWithProviders( + , + ); + + selectFiles([ + phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB), + phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB), + report, + ]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(onUpload).toHaveBeenCalledWith([report]); + 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."); + }); + + it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null); + const onUpload = vi.fn(); + const video = phoneFile("IMG_0703.MOV", "", 60 * MB); + renderWithProviders( + , + ); + + selectFiles([video]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video])); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("ignores a second pick while the first one is still being checked", async () => { + let finishProbe: ((seconds: number) => void) | undefined; + vi.mocked(probeVideoDurationSeconds).mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = resolve; + }), + ); + const onUpload = vi.fn(); + renderWithProviders( + , + ); + const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB); + const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB); + + selectFiles([first]); + await waitFor(() => expect(finishProbe).toBeDefined()); + expect(document.querySelector('input[type="file"]')).toBeDisabled(); + selectFiles([second]); + finishProbe?.(30); + + await waitFor(() => expect(onUpload).toHaveBeenCalledOnce()); + expect(onUpload).toHaveBeenCalledWith([first]); + expect(probeVideoDurationSeconds).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx index 91cfc13e..d09e0cca 100644 --- a/src/test/app/v/uplift-request-form.test.tsx +++ b/src/test/app/v/uplift-request-form.test.tsx @@ -1,6 +1,9 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); function renderForm(overrides: Partial> = {}) { return render( @@ -38,6 +41,70 @@ describe("UpliftRequestForm revise mode", () => { expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument(); }); + it("refuses photo evidence when the work order already has 10 photos", async () => { + renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } }); + + fireEvent.change(screen.getByLabelText(/evidence/i), { + target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] }, + }); + + expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument(); + }); + + it("offers and accepts the same phone photo and video types as the upload allowlist", async () => { + renderForm(); + + const input = screen.getByLabelText("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)"); + expect(input.getAttribute("accept")?.split(",")).toEqual( + expect.arrayContaining([".pdf", ".jpg", ".png", ".heic", ".mp4", ".mov"]), + ); + + fireEvent.change(input, { + target: { files: [new File(["gif"], "clip.gif", { type: "image/gif" })] }, + }); + expect( + await screen.findByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."), + ).toBeInTheDocument(); + + fireEvent.change(input, { + target: { files: [new File(["heic"], "site.heic", { type: "" })] }, + }); + await waitFor(() => + expect( + screen.queryByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."), + ).not.toBeInTheDocument(), + ); + }); + + it("holds submit while a picked video is still being checked, then submits that video", async () => { + let finishProbe: (seconds: number | null) => void = () => {}; + vi.mocked(probeVideoDurationSeconds).mockImplementationOnce( + () => new Promise((resolve) => (finishProbe = resolve)), + ); + const onSubmit = vi.fn(); + renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit }); + + const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" }); + fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } }); + + const checking = await screen.findByRole("button", { name: "Checking evidence…" }); + expect(checking).toBeDisabled(); + fireEvent.submit(checking.closest("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument(); + + finishProbe(30); + const submitButton = await screen.findByRole("button", { name: /submit revision/i }); + expect(submitButton).toBeEnabled(); + fireEvent.click(submitButton); + + expect(onSubmit).toHaveBeenCalledWith({ + amount: 1200, + reason: "Extra parts", + evidenceFile: video, + }); + }); + it("reports the current NTE threshold in the actionable error", () => { renderForm({ initialRequested: "900", currentNte: 1000 }); diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index e8a33e78..a0085787 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,163 @@ describe("VendorPortalDocuments", () => { expect(screen.getByText("Processing")).toBeInTheDocument(); }); + it.each([ + ["animation.gif", "image/gif"], + ["clip.webm", "video/webm"], + ])("rejects %s (%s), which the server does not accept", async (name, type) => { + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.change(screen.getByLabelText("Choose completion document"), { + target: { files: [new File(["x"], name, { type })] }, + }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.", + ); + expect(upload).not.toHaveBeenCalled(); + }); + + 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("states the limit for every accepted kind, including PDFs", () => { + renderDocuments(); + + expect( + screen.getByText( + "Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and 90 s · PDFs up to 10 MB", + ), + ).toBeInTheDocument(); + }); + + 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(); + }); + + // Three videos already on the work order (dispatcher and vendor uploads together). + const fullCounts = { + maxPhotos: 10, + maxVideos: 3, + photos: 10, + videos: 3, + completionPhotos: 10, + completionVideos: 3, + }; + const videoDocument: VendorPortalDocument = { + ...quarantinedDocument, + id: 21, + originalFileName: "walkthrough.mp4", + contentType: "video/mp4", + }; + + it("rejects the 4th video and 11th photo counted across the whole work order", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + const upload = vi.spyOn(vendorPortalApi, "uploadDocument"); + renderWithProviders( + , + { withAuth: false }, + ); + + choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File); + expect(await screen.findByRole("alert")).toHaveTextContent( + "A work order can have at most 3 videos.", + ); + choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "A work order can have at most 10 photos.", + ), + ); + expect(upload).not.toHaveBeenCalled(); + }); + + it("lets a vendor replace one of the counted videos on a full work order", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File; + choose(replacement); + + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 }); + }); + }); + it("reports upload progress and refreshes the dispatch after success", async () => { const onChanged = vi.fn().mockResolvedValue(undefined); let finishUpload: (() => void) | undefined; @@ -97,4 +257,40 @@ describe("VendorPortalDocuments", () => { finishUpload?.(); await waitFor(() => expect(onChanged).toHaveBeenCalledOnce()); }); + + it("drops an earlier pick whose video check finishes after a newer pick", async () => { + let finishProbe: ((seconds: number) => void) | undefined; + vi.mocked(probeVideoDurationSeconds).mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = resolve; + }), + ); + const upload = vi + .spyOn(vendorPortalApi, "uploadDocument") + .mockResolvedValue(quarantinedDocument); + renderWithProviders( + , + { withAuth: false }, + ); + const input = screen.getByLabelText("Choose completion document"); + const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File; + const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File; + + fireEvent.change(input, { target: { files: [video] } }); + await waitFor(() => expect(finishProbe).toBeDefined()); + fireEvent.change(input, { target: { files: [photo] } }); + await waitFor(() => expect(upload).toHaveBeenCalledOnce()); + finishProbe?.(30); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(upload).toHaveBeenCalledOnce(); + expect(upload.mock.calls[0][0].file).toBe(photo); + }); }); diff --git a/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts b/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts new file mode 100644 index 00000000..dca523e7 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-media-kind-counts.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + boardMediaItemsToMediaFiles, + mapBoardDetailMediaItem, +} from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits"; +import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils"; + +describe("photo/video counting for the per-work-order limit", () => { + it("counts a persisted HEIC photo after a refresh", () => { + const heic = mapBoardDetailMediaItem({ + id: 41, + category: 3, + url: "https://cdn.example.com/IMG_0042.HEIC", + fileName: "IMG_0042.HEIC", + }); + const files = boardMediaItemsToMediaFiles([heic]); + + expect(files[0]?.fileType).toBe("image"); + expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 }); + }); + + it.each([ + [{ name: "IMG_0043.HEIC", type: "" }, "image"], + [{ name: "IMG_0044.MOV", type: "" }, "video"], + [{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"], + [{ name: "report.docx", type: "" }, "other"], + ])("classifies a local %o by extension when the browser type is missing", (file, fileType) => { + expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType); + }); +}); 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..e1892159 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,53 @@ 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.", + ); + // A foreign declared type never widens the class: this .jpg is still sized as a photo. + expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_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, non-failed photos and videos toward the per-work-order limits", () => { + expect( + countWorkOrderMediaKinds([ + { fileType: "image" }, + { fileType: "image", isLegacy: true }, + { fileType: "video" }, + { fileType: "document" }, + { fileType: "video", uploadStatus: "failed" }, + ]), + ).toEqual({ photos: 1, videos: 1 }); }); it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => { diff --git a/src/test/lib/probe-video-duration.test.ts b/src/test/lib/probe-video-duration.test.ts new file mode 100644 index 00000000..0e45a171 --- /dev/null +++ b/src/test/lib/probe-video-duration.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function ascii(text: string): Uint8Array { + return Uint8Array.from(text, (char) => char.charCodeAt(0)); +} + +function box(type: string, ...children: Uint8Array[]): Uint8Array { + const body = concat(...children); + const out = new Uint8Array(8 + body.length); + new DataView(out.buffer).setUint32(0, out.length); + out.set(ascii(type), 4); + out.set(body, 8); + return out; +} + +function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array { + const body = new Uint8Array(version1 ? 32 : 20); + const view = new DataView(body.buffer); + body[0] = version1 ? 1 : 0; + if (version1) { + view.setUint32(20, timescale); + view.setBigUint64(24, duration); + } else { + view.setUint32(12, timescale); + view.setUint32(16, Number(duration)); + } + return box("mvhd", body); +} + +/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */ +function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array { + return concat( + box("ftyp", ascii("isom"), new Uint8Array(4)), + box("mdat", new Uint8Array(4096)), + box("moov", movieHeader(timescale, duration, version1)), + ); +} + +function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File { + return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" }); +} + +describe("probeVideoDurationSeconds", () => { + it("reads the duration from a moov box after the media data", async () => { + await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95); + }); + + it("reads a version 1 movie header", async () => { + await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5); + }); + + it("follows 64-bit box sizes", async () => { + const mdat = new Uint8Array(16 + 512); + const view = new DataView(mdat.buffer); + view.setUint32(0, 1); + mdat.set(ascii("mdat"), 4); + view.setBigUint64(8, BigInt(mdat.length)); + const file = concat( + box("ftyp", ascii("qt "), new Uint8Array(4)), + mdat, + box("moov", movieHeader(90_000, 90_000n * 120n)), + ); + + await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120); + }); + + it.each([ + [ + "no moov", + concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))), + ], + ["truncated", mp4(600, 57_000n).slice(0, -10)], + ["zero timescale", mp4(0, 57_000n)], + [ + "oversized box", + concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")), + ], + ["not a video", ascii("%PDF-1.7 not a movie")], + ])("returns null for an unreadable file (%s)", async (_shape, bytes) => { + await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull(); + }); +});