From ca2b3b5134c0e8f740b04b5f1147ff54374767de Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 21 Sep 2026 15:09:51 -0300 Subject: [PATCH 1/2] fix(work-orders): raise media upload limit to 200 MB --- .../utils/work-order-media-limits.ts | 2 +- .../media-upload-modal-api.test.tsx | 2 +- .../media-uploader-dropzone.test.tsx | 28 +++++++++++++++++-- .../slide-over-extras-aveta.test.tsx | 4 +-- .../utils/work-order-media-limits.test.ts | 19 +++++++++++-- 5 files changed, 45 insertions(+), 10 deletions(-) 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 5d566ccd..56b7aaef 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,7 +1,7 @@ import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; /** Backend RequestSizeLimit for media and completion-doc uploads. */ -export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000; +export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000; const MEDIA_MIME_TYPES = new Set([ "image/jpeg", 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 7202587d..48c4241c 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,7 @@ describe("MediaUploadModal API wiring", () => { }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"), + screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), ).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 35da0ef4..48ccb432 100644 --- a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -1,10 +1,17 @@ import { createRef } from "react"; -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import { 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 { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits"; +import { + MAX_WORK_ORDER_MEDIA_BYTES, + workOrderMediaAcceptAttribute, +} from "@/domain/work-orders/utils/work-order-media-limits"; import { renderWithProviders } from "@/test/test-utils"; +vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); + describe("MediaUploaderDropzone", () => { it("matches the AAP Photos accept copy and keeps it media-only", () => { renderWithProviders( @@ -16,7 +23,7 @@ describe("MediaUploaderDropzone", () => { ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"), + screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), ).toBeInTheDocument(); expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); expect(screen.queryByText(/PDF/)).not.toBeInTheDocument(); @@ -25,4 +32,19 @@ describe("MediaUploaderDropzone", () => { 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(); + + 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"]'); + + fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } }); + + expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.'); + }); }); 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 ba674966..7dcdc7d2 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,13 @@ describe("SlideOverExtrasTab Aveta", () => { expect(getMediaContent).not.toHaveBeenCalled(); }); - it("shows Extra Docs accept copy with PDF DOC and 50MB", () => { + it("shows Extra Docs accept copy with PDF DOC and 200MB", () => { renderWithProviders( , ); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50MB per file"), + screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"), ).toBeInTheDocument(); const input = document.querySelector('input[type="file"]'); expect(input?.getAttribute("accept")).toContain(".pdf"); 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 9cf3bc76..4a236b46 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 @@ -24,9 +24,10 @@ describe("work-order-media-limits", () => { expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false); }); - it("caps uploads at 50_000_000 bytes and formats decimal MB", () => { - expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(50_000_000); - expect(formatMediaSizeLimit()).toBe("50 MB"); + 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); }); @@ -39,7 +40,19 @@ describe("work-order-media-limits", () => { expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe( true, ); + expect(isWorkOrderMediaTypeAllowed({ name: "a.MP4", type: "" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.MOV", type: "" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe( + true, + ); + expect( + isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "application/octet-stream" } as File), + ).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(false); + expect(isWorkOrderMediaTypeAllowed({ name: "a.webm", type: "video/webm" } as File)).toBe(false); + expect( + isWorkOrderMediaTypeAllowed({ name: "a.txt", type: "application/octet-stream" } as File), + ).toBe(false); expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe( false, ); From 93f68f84e4b456b40b97b90b77972a51e38ffb0c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Mon, 21 Sep 2026 15:39:47 -0300 Subject: [PATCH 2/2] fix(workorders): cap completion documents at 50 MB --- .../_components/comp-doc/comp-doc-dialog.tsx | 5 +++-- .../comp-doc/use-comp-doc-pdf-actions.ts | 7 +++++-- .../detail/tabs/slide-over-completion-tab.tsx | 7 +++++-- .../utils/work-order-media-limits.ts | 5 ++++- .../utils/work-order-media-limits.test.ts | 18 ++++++++++++++++++ 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index 74d9c75e..6fe91dd6 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -11,6 +11,7 @@ import { formatMediaSizeLimit, isWorkOrderDocumentsReadOnly, isWorkOrderFileTooLarge, + MAX_WORK_ORDER_COMPLETION_DOC_BYTES, } from "@/domain/work-orders/utils/work-order-media-limits"; import { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog"; import { CompDocDialogFooter } from "./comp-doc-dialog-footer"; @@ -35,8 +36,8 @@ type UseCompDocPdfUploadArgs = { }; function rejectCompDocPdf(file: File, rowVersion: string): string | null { - if (isWorkOrderFileTooLarge(file)) { - return `File exceeds the ${formatMediaSizeLimit()} limit.`; + if (isWorkOrderFileTooLarge(file, MAX_WORK_ORDER_COMPLETION_DOC_BYTES)) { + return `File exceeds the ${formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)} limit.`; } if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { return "Please upload a PDF file."; diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts index 5d3b6475..23861a53 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts @@ -11,6 +11,7 @@ import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state"; import { formatMediaSizeLimit, isWorkOrderFileTooLarge, + MAX_WORK_ORDER_COMPLETION_DOC_BYTES, } from "@/domain/work-orders/utils/work-order-media-limits"; type UseCompDocPdfActionsArgs = { @@ -55,8 +56,10 @@ export function useCompDocPdfActions({ function handleUploadPdf(files: FileList | null) { const file = files?.[0]; if (!file) return; - if (isWorkOrderFileTooLarge(file)) { - toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`); + if (isWorkOrderFileTooLarge(file, MAX_WORK_ORDER_COMPLETION_DOC_BYTES)) { + toast.error( + `File exceeds the ${formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)} limit.`, + ); return; } if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx index cf89d2bf..f88e3867 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx @@ -11,6 +11,7 @@ import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition- import { formatMediaSizeLimit, isWorkOrderFileTooLarge, + MAX_WORK_ORDER_COMPLETION_DOC_BYTES, } from "@/domain/work-orders/utils/work-order-media-limits"; import { SlideOverCompletionActions } from "./slide-over-completion-actions"; import { SlideOverCompletionMedia } from "./slide-over-completion-media"; @@ -71,8 +72,10 @@ export function SlideOverCompletionTab({ const handlePdfSelected = (files: FileList | null) => { const file = files?.[0]; if (!file || !onUploadCompletionPdf) return; - if (isWorkOrderFileTooLarge(file)) { - toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`); + if (isWorkOrderFileTooLarge(file, MAX_WORK_ORDER_COMPLETION_DOC_BYTES)) { + toast.error( + `File exceeds the ${formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)} limit.`, + ); return; } if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) { 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 56b7aaef..00d9d261 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,8 +1,11 @@ import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock"; -/** Backend RequestSizeLimit for media and completion-doc uploads. */ +/** Backend RequestSizeLimit for media uploads and Extra Docs. */ export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000; +/** Backend RequestSizeLimit for completion-document uploads. */ +export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000; + const MEDIA_MIME_TYPES = new Set([ "image/jpeg", "image/jpg", 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 4a236b46..da86cd31 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 @@ -6,6 +6,7 @@ import { isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, isWorkOrderMediaTypeAllowed, + MAX_WORK_ORDER_COMPLETION_DOC_BYTES, MAX_WORK_ORDER_MEDIA_BYTES, workOrderExtraDocsAcceptAttribute, workOrderMediaAcceptAttribute, @@ -32,6 +33,23 @@ describe("work-order-media-limits", () => { expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false); }); + it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => { + expect(MAX_WORK_ORDER_COMPLETION_DOC_BYTES).toBe(50_000_000); + expect(formatMediaSizeLimit(MAX_WORK_ORDER_COMPLETION_DOC_BYTES)).toBe("50 MB"); + expect( + isWorkOrderFileTooLarge( + { size: MAX_WORK_ORDER_COMPLETION_DOC_BYTES } as File, + MAX_WORK_ORDER_COMPLETION_DOC_BYTES, + ), + ).toBe(false); + expect( + isWorkOrderFileTooLarge( + { size: MAX_WORK_ORDER_COMPLETION_DOC_BYTES + 1 } as File, + MAX_WORK_ORDER_COMPLETION_DOC_BYTES, + ), + ).toBe(true); + }); + it("allows only JPG PNG MP4 MOV by MIME or extension", () => { expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpg" } as File)).toBe(true);