mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 15:52:04 +00:00
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.
This commit is contained in:
parent
6367dc2a7a
commit
44e96cf2a3
14 changed files with 507 additions and 72 deletions
|
|
@ -20,10 +20,9 @@ import {
|
||||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||||
import {
|
import {
|
||||||
canDeleteWorkOrderMedia,
|
canDeleteWorkOrderMedia,
|
||||||
formatMediaSizeLimit,
|
|
||||||
isWorkOrderExtraDocTypeAllowed,
|
isWorkOrderExtraDocTypeAllowed,
|
||||||
isWorkOrderFileTooLarge,
|
|
||||||
workOrderExtraDocsAcceptAttribute,
|
workOrderExtraDocsAcceptAttribute,
|
||||||
|
workOrderFileSizeRejection,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||||
|
|
@ -188,11 +187,14 @@ export function SlideOverExtrasTab({
|
||||||
const accepted: File[] = [];
|
const accepted: File[] = [];
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (isWorkOrderFileTooLarge(file)) {
|
const sizeRejection = workOrderFileSizeRejection(file);
|
||||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
if (sizeRejection) {
|
||||||
|
toast.error(sizeRejection);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
accepted.push(file);
|
accepted.push(file);
|
||||||
|
|
@ -250,8 +252,8 @@ export function SlideOverExtrasTab({
|
||||||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
|
||||||
per file
|
MB and 90 s · Docs up to 50 MB
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
formatMediaSizeLimit,
|
MAX_PHOTOS_PER_WORK_ORDER,
|
||||||
|
MAX_VIDEOS_PER_WORK_ORDER,
|
||||||
|
MAX_VIDEO_DURATION_SECONDS,
|
||||||
|
MAX_WORK_ORDER_PHOTO_BYTES,
|
||||||
|
MAX_WORK_ORDER_VIDEO_BYTES,
|
||||||
workOrderMediaAcceptAttribute,
|
workOrderMediaAcceptAttribute,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
|
@ -12,6 +16,12 @@ type MediaUploaderDropzoneProps = {
|
||||||
onFilesSelected: (files: FileList | null) => void;
|
onFilesSelected: (files: FileList | null) => 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({
|
export function MediaUploaderDropzone({
|
||||||
inputRef,
|
inputRef,
|
||||||
pending,
|
pending,
|
||||||
|
|
@ -41,7 +51,7 @@ export function MediaUploaderDropzone({
|
||||||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()}
|
||||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -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 type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
import {
|
import {
|
||||||
canDeleteWorkOrderMedia,
|
canDeleteWorkOrderMedia,
|
||||||
formatMediaSizeLimit,
|
countWorkOrderMediaKinds,
|
||||||
isWorkOrderFileTooLarge,
|
MAX_PHOTOS_PER_WORK_ORDER,
|
||||||
|
MAX_VIDEOS_PER_WORK_ORDER,
|
||||||
|
MAX_VIDEO_DURATION_SECONDS,
|
||||||
|
resolveWorkOrderUploadKind,
|
||||||
|
workOrderFileSizeRejection,
|
||||||
isWorkOrderMediaTypeAllowed,
|
isWorkOrderMediaTypeAllowed,
|
||||||
|
type WorkOrderUploadKind,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
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 { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||||
|
|
||||||
|
|
@ -32,6 +38,23 @@ type MediaUploaderProps = {
|
||||||
pending?: boolean;
|
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<boolean> {
|
||||||
|
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({
|
export function MediaUploader({
|
||||||
uploads,
|
uploads,
|
||||||
onUpload,
|
onUpload,
|
||||||
|
|
@ -45,19 +68,36 @@ export function MediaUploader({
|
||||||
}: MediaUploaderProps) {
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const addFiles = (files: FileList | null) => {
|
const addFiles = async (files: FileList | null) => {
|
||||||
if (readOnly || pending || !files || files.length === 0) return;
|
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[] = [];
|
const accepted: File[] = [];
|
||||||
for (const file of Array.from(files)) {
|
for (const file of selected) {
|
||||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
toast.error(UNSUPPORTED_TYPE_MESSAGE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isWorkOrderFileTooLarge(file)) {
|
const sizeRejection = workOrderFileSizeRejection(file);
|
||||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
if (sizeRejection) {
|
||||||
|
toast.error(sizeRejection);
|
||||||
continue;
|
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);
|
accepted.push(file);
|
||||||
}
|
}
|
||||||
if (accepted.length === 0) return;
|
if (accepted.length === 0) return;
|
||||||
|
|
@ -95,7 +135,7 @@ export function MediaUploader({
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
categorizeAfterUpload={Boolean(onCategorize)}
|
categorizeAfterUpload={Boolean(onCategorize)}
|
||||||
onFilesSelected={addFiles}
|
onFilesSelected={(files) => void addFiles(files)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MediaUploaderGrid
|
<MediaUploaderGrid
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,6 +36,7 @@ export function UpliftRequestForm({
|
||||||
const [reason, setReason] = useState(initialReason);
|
const [reason, setReason] = useState(initialReason);
|
||||||
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
|
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
|
const latestSelection = useRef(0);
|
||||||
|
|
||||||
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
|
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
|
||||||
|
|
||||||
|
|
@ -54,8 +55,11 @@ export function UpliftRequestForm({
|
||||||
onSubmit({ amount, reason: reason.trim(), evidenceFile });
|
onSubmit({ amount, reason: reason.trim(), evidenceFile });
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseFile = (file?: File) => {
|
const chooseFile = async (file?: File) => {
|
||||||
const validationMessage = file ? validateVendorDocument(file) : undefined;
|
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 ?? "");
|
setFormError(validationMessage ?? "");
|
||||||
setEvidenceFile(file);
|
setEvidenceFile(file);
|
||||||
};
|
};
|
||||||
|
|
@ -119,7 +123,7 @@ export function UpliftRequestForm({
|
||||||
type="file"
|
type="file"
|
||||||
className="vp-input"
|
className="vp-input"
|
||||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
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])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,12 @@ export function VendorPortalDocuments({
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadSelectedFile = (file?: File) => {
|
const uploadSelectedFile = async (file?: File) => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationMessage = validateVendorDocument(file);
|
const validationMessage = await validateVendorDocument(file);
|
||||||
if (validationMessage) {
|
if (validationMessage) {
|
||||||
setValidationError(validationMessage);
|
setValidationError(validationMessage);
|
||||||
return;
|
return;
|
||||||
|
|
@ -83,9 +83,9 @@ export function VendorPortalDocuments({
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
aria-label="Choose completion document"
|
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
|
hidden
|
||||||
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
|
onChange={(event) => void uploadSelectedFile(event.target.files?.[0])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{documents.length === 0 && (
|
{documents.length === 0 && (
|
||||||
|
|
@ -155,6 +155,10 @@ export function VendorPortalDocuments({
|
||||||
>
|
>
|
||||||
Upload document
|
Upload document
|
||||||
</button>
|
</button>
|
||||||
|
<span className="vp-hint">
|
||||||
|
Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and
|
||||||
|
90 s
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,13 @@
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-hint {
|
||||||
|
align-self: center;
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
.vp-checklist-item {
|
.vp-checklist-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,54 @@
|
||||||
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||||
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
|
||||||
|
|
||||||
export function validateVendorDocument(file: File): string | undefined {
|
/**
|
||||||
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
|
* SH-116 client-confirmed media contract on the vendor portal: photos up to
|
||||||
return "Only PDF, JPG, and PNG files are allowed.";
|
* 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<string | undefined> {
|
||||||
|
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 "Files must be 10 MB or smaller.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,37 @@
|
||||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
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;
|
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([
|
const MEDIA_MIME_TYPES = new Set([
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/jpg",
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
|
"image/heic",
|
||||||
"video/mp4",
|
"video/mp4",
|
||||||
"video/quicktime",
|
"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([
|
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||||
...MEDIA_MIME_TYPES,
|
...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"]);
|
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||||
|
|
||||||
|
export type WorkOrderUploadKind = "photo" | "video" | "document";
|
||||||
|
|
||||||
function fileExtension(file: File): string {
|
function fileExtension(file: File): string {
|
||||||
const name = file.name.toLowerCase();
|
const name = file.name.toLowerCase();
|
||||||
const dot = name.lastIndexOf(".");
|
const dot = name.lastIndexOf(".");
|
||||||
|
|
@ -37,11 +56,48 @@ function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>):
|
||||||
return mimeOk || extOk;
|
return mimeOk || extOk;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWorkOrderFileTooLarge(
|
export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind {
|
||||||
file: File,
|
const mime = file.type.toLowerCase();
|
||||||
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
|
const extension = fileExtension(file);
|
||||||
): boolean {
|
if (mime.startsWith("video/") || extension === ".mp4" || extension === ".mov") {
|
||||||
return file.size > maxBytes;
|
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 {
|
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||||
|
|
@ -53,17 +109,34 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workOrderMediaAcceptAttribute(): string {
|
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 {
|
export function workOrderExtraDocsAcceptAttribute(): string {
|
||||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
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`;
|
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. */
|
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||||
|
|
|
||||||
36
src/lib/probe-video-duration.ts
Normal file
36
src/lib/probe-video-duration.ts
Normal file
|
|
@ -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<number | null> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -117,7 +117,9 @@ describe("MediaUploadModal API wiring", () => {
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,39 @@
|
||||||
import { createRef } from "react";
|
import { createRef } from "react";
|
||||||
import { fireEvent, screen } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
|
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
|
||||||
import {
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
workOrderMediaAcceptAttribute,
|
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
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", () => {
|
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(
|
renderWithProviders(
|
||||||
<MediaUploaderDropzone
|
<MediaUploaderDropzone
|
||||||
inputRef={createRef<HTMLInputElement>()}
|
inputRef={createRef<HTMLInputElement>()}
|
||||||
|
|
@ -23,28 +43,122 @@ describe("MediaUploaderDropzone", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
|
|
||||||
const input = document.querySelector('input[type="file"]');
|
const input = document.querySelector('input[type="file"]');
|
||||||
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
|
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
|
||||||
expect(input?.getAttribute("accept")).not.toContain(".pdf");
|
expect(input?.getAttribute("accept")).not.toContain(".pdf");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an oversized media file with the generic 200 MB toast", () => {
|
describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => {
|
||||||
renderWithProviders(<MediaUploader uploads={[]} />);
|
beforeEach(() => {
|
||||||
|
vi.mocked(toast.error).mockClear();
|
||||||
|
vi.mocked(probeVideoDurationSeconds).mockReset();
|
||||||
|
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||||
|
});
|
||||||
|
|
||||||
const oversizedFile = {
|
it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
|
||||||
name: "mobile-clip.MP4",
|
const onUpload = vi.fn();
|
||||||
type: "application/octet-stream",
|
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||||
size: MAX_WORK_ORDER_MEDIA_BYTES + 1,
|
|
||||||
} as File;
|
|
||||||
const input = document.querySelector('input[type="file"]');
|
|
||||||
|
|
||||||
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(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a 101 MB video with the generic message and no file name", async () => {
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."),
|
||||||
|
);
|
||||||
|
expect(onUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an 11 MB photo", async () => {
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."),
|
||||||
|
);
|
||||||
|
expect(onUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the 11th photo and the 4th video on a full work order", async () => {
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(<MediaUploader uploads={gallery(10, 3)} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
selectFiles([
|
||||||
|
phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB),
|
||||||
|
phoneFile("IMG_0201.MOV", "video/quicktime", 20 * MB),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
|
||||||
|
expect(onUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts files within one selection against the remaining slots", async () => {
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(<MediaUploader uploads={gallery(0, 2)} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB);
|
||||||
|
selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||||
|
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a video longer than 90 seconds when the duration is readable", async () => {
|
||||||
|
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
|
||||||
|
);
|
||||||
|
expect(onUpload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block a video whose duration the browser cannot read", async () => {
|
||||||
|
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
|
||||||
|
const onUpload = vi.fn();
|
||||||
|
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||||
|
|
||||||
|
selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -140,13 +140,15 @@ describe("SlideOverExtrasTab Aveta", () => {
|
||||||
expect(getMediaContent).not.toHaveBeenCalled();
|
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(
|
renderWithProviders(
|
||||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
|
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
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();
|
).toBeInTheDocument();
|
||||||
const input = document.querySelector('input[type="file"]');
|
const input = document.querySelector('input[type="file"]');
|
||||||
expect(input?.getAttribute("accept")).toContain(".pdf");
|
expect(input?.getAttribute("accept")).toContain(".pdf");
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
|
||||||
|
|
||||||
const quarantinedDocument: VendorPortalDocument = {
|
const quarantinedDocument: VendorPortalDocument = {
|
||||||
id: 12,
|
id: 12,
|
||||||
originalFileName: "completion.pdf",
|
originalFileName: "completion.pdf",
|
||||||
|
|
@ -40,7 +43,7 @@ describe("VendorPortalDocuments", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
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();
|
expect(upload).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -62,6 +65,60 @@ describe("VendorPortalDocuments", () => {
|
||||||
expect(screen.getByText("Processing")).toBeInTheDocument();
|
expect(screen.getByText("Processing")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SH-116 media contract", () => {
|
||||||
|
const MB = 1_000_000;
|
||||||
|
const renderDocuments = () =>
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorPortalDocuments
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={7}
|
||||||
|
documents={[]}
|
||||||
|
locked={false}
|
||||||
|
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
/>,
|
||||||
|
{ 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 () => {
|
it("reports upload progress and refreshes the dispatch after success", async () => {
|
||||||
const onChanged = vi.fn().mockResolvedValue(undefined);
|
const onChanged = vi.fn().mockResolvedValue(undefined);
|
||||||
let finishUpload: (() => void) | undefined;
|
let finishUpload: (() => void) | undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
canDeleteWorkOrderMedia,
|
canDeleteWorkOrderMedia,
|
||||||
|
countWorkOrderMediaKinds,
|
||||||
formatMediaSizeLimit,
|
formatMediaSizeLimit,
|
||||||
isWorkOrderDocumentsReadOnly,
|
isWorkOrderDocumentsReadOnly,
|
||||||
isWorkOrderExtraDocTypeAllowed,
|
isWorkOrderExtraDocTypeAllowed,
|
||||||
isWorkOrderFileTooLarge,
|
isWorkOrderFileTooLarge,
|
||||||
isWorkOrderMediaTypeAllowed,
|
isWorkOrderMediaTypeAllowed,
|
||||||
|
MAX_PHOTOS_PER_WORK_ORDER,
|
||||||
|
MAX_VIDEO_DURATION_SECONDS,
|
||||||
|
MAX_VIDEOS_PER_WORK_ORDER,
|
||||||
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
|
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
|
||||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
MAX_WORK_ORDER_PHOTO_BYTES,
|
||||||
|
MAX_WORK_ORDER_VIDEO_BYTES,
|
||||||
workOrderExtraDocsAcceptAttribute,
|
workOrderExtraDocsAcceptAttribute,
|
||||||
|
workOrderFileSizeRejection,
|
||||||
workOrderMediaAcceptAttribute,
|
workOrderMediaAcceptAttribute,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
|
@ -25,12 +31,48 @@ describe("work-order-media-limits", () => {
|
||||||
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
|
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps uploads at 200_000_000 bytes and formats decimal MB", () => {
|
it("applies the SH-116 per-kind caps with stable generic messages", () => {
|
||||||
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
|
const file = (name: string, type: string, size: number) => ({ name, type, size }) as File;
|
||||||
expect(formatMediaSizeLimit()).toBe("200 MB");
|
|
||||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false);
|
expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000);
|
||||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
|
expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000);
|
||||||
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
|
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", () => {
|
it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue