From 0c8a7f877fc174e9a50e079a12377269a9d0b7f8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 04:31:53 -0300 Subject: [PATCH] fix: fail closed on emergency media policy --- .../tabs/slide-over-completion-media.tsx | 25 ++++ .../detail/tabs/slide-over-completion-tab.tsx | 21 +++ .../detail/use-slide-over-media-state.ts | 53 ++++++- .../detail/use-work-order-slide-over.ts | 15 ++ .../detail/work-order-slide-over-tabs.tsx | 7 + .../_components/media/media-upload-modal.tsx | 26 +++- .../media/media-uploader-dropzone.tsx | 11 +- .../_components/media/media-uploader.tsx | 13 +- .../utils/emergency-media-limits.ts | 12 ++ .../media-upload-modal-api.test.tsx | 140 +++++++++++++++--- ...lide-over-completion-service-gate.test.tsx | 71 +++++++++ .../use-slide-over-media-state.test.tsx | 92 +++++++++++- 12 files changed, 448 insertions(+), 38 deletions(-) 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..7908c1f8 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 @@ -15,6 +15,13 @@ type SlideOverCompletionMediaProps = { onRemoveMedia?: (id: string) => void; onRetryMedia?: (localId: string) => void; onCancelMedia?: (localId: string) => void; + uploadDisabled?: boolean; + uploadUnavailableMessage?: string; + typeAllowed?: (file: File) => boolean; + typeErrorMessage?: string; + acceptAttribute?: string; + fileSizeError?: (file: File) => string | undefined; + uploadHint?: string; }; export function SlideOverCompletionMedia({ @@ -26,6 +33,13 @@ export function SlideOverCompletionMedia({ onRemoveMedia, onRetryMedia, onCancelMedia, + uploadDisabled = false, + uploadUnavailableMessage, + typeAllowed, + typeErrorMessage, + acceptAttribute, + fileSizeError, + uploadHint, }: SlideOverCompletionMediaProps) { return (
@@ -45,8 +59,19 @@ export function SlideOverCompletionMedia({ onCancel={onCancelMedia} readOnly={isReadOnly} pending={mediaPending} + uploadDisabled={uploadDisabled} + typeAllowed={typeAllowed} + typeErrorMessage={typeErrorMessage} + acceptAttribute={acceptAttribute} + fileSizeError={fileSizeError} + uploadHint={uploadHint} />
+ {uploadDisabled && Boolean(uploadUnavailableMessage) && ( +
+ {uploadUnavailableMessage} +
+ )} ); } 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 f88e3867..ef631d45 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 @@ -30,6 +30,13 @@ type SlideOverCompletionTabProps = { onRemoveMedia?: (id: string) => void; onRetryMedia?: (localId: string) => void; onCancelMedia?: (localId: string) => void; + uploadDisabled?: boolean; + uploadUnavailableMessage?: string; + typeAllowed?: (file: File) => boolean; + typeErrorMessage?: string; + acceptAttribute?: string; + fileSizeError?: (file: File) => string | undefined; + uploadHint?: string; onUploadCompletionPdf?: (file: File) => void; onOpenCompletionDoc: () => void; }; @@ -46,6 +53,13 @@ export function SlideOverCompletionTab({ onRemoveMedia, onRetryMedia, onCancelMedia, + uploadDisabled, + uploadUnavailableMessage, + typeAllowed, + typeErrorMessage, + acceptAttribute, + fileSizeError, + uploadHint, onUploadCompletionPdf, onOpenCompletionDoc, }: SlideOverCompletionTabProps) { @@ -65,6 +79,13 @@ export function SlideOverCompletionTab({ onRemoveMedia={onRemoveMedia} onRetryMedia={onRetryMedia} onCancelMedia={onCancelMedia} + uploadDisabled={uploadDisabled} + uploadUnavailableMessage={uploadUnavailableMessage} + typeAllowed={typeAllowed} + typeErrorMessage={typeErrorMessage} + acceptAttribute={acceptAttribute} + fileSizeError={fileSizeError} + uploadHint={uploadHint} /> ); } 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 7352fefb..de94ee7d 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 @@ -5,6 +5,7 @@ import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-m import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { useDeleteWorkOrderMedia, useUpdateWorkOrderMediaCategory, @@ -19,10 +20,16 @@ import { isExtrasSurfaceMedia, type MediaSurfaceOwnership, } from "@/domain/work-orders/utils/partition-slide-over-media"; -import { isEmergencyReactiveWorkOrderType } from "@/domain/work-orders/utils/emergency-media-limits"; +import { + classifyMediaKind, + emergencyMediaAcceptAttribute, + emergencyMediaSizeError, + isEmergencyReactiveWorkOrderType, +} from "@/domain/work-orders/utils/emergency-media-limits"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; + row?: WorkOrderTableRow | null; uploadMediaMutation: ReturnType; deleteMediaMutation: ReturnType; categorizeMediaMutation: ReturnType; @@ -79,22 +86,40 @@ function saveOwnership( export function useSlideOverMediaState({ detailForRow, + row, uploadMediaMutation, deleteMediaMutation, categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { - const workOrderId = detailForRow?.info.id; - const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type); - const { data: mediaPolicy } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); + const workOrderId = detailForRow?.info.id ?? row?.id; + const workOrderType = detailForRow?.info.type ?? row?.type; + const { + data: mediaPolicy, + isError: mediaPolicyError, + isLoading: mediaPolicyLoading, + } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null); const resumableCompletionUpload = useResumableWorkOrderMediaUpload( workOrderId ?? "", "CompletionMedia", mediaPolicy ?? undefined, ); - const policyCanUpload = mediaPolicy?.canUpload === true; + const policyCanUpload = mediaPolicy?.canUpload === true && !mediaPolicyError; + const completionHasGallery = + hasCompletionMediaGallery(detailForRow?.info.type) || + hasCompletionMediaGallery(mediaPolicy?.workOrderType) || + hasCompletionMediaGallery(row?.type); const emergencyMedia = isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) || - isEmergencyReactiveWorkOrderType(detailForRow?.info.type); + isEmergencyReactiveWorkOrderType(workOrderType); + const emergencyMediaUnavailable = emergencyMedia && !policyCanUpload; + const emergencyMediaUnavailableMessage = + mediaPolicy?.canUpload === false + ? "Uploads are not available for this work order." + : mediaPolicyError + ? "Upload permissions could not be loaded. Refresh and try again." + : mediaPolicyLoading || !mediaPolicy + ? "Loading upload permissions…" + : "Uploads are not available for this work order."; const [mediaBatchPending, setMediaBatchPending] = useState(false); const [ownership, setOwnershipState] = useState(() => loadOwnership(workOrderId), @@ -235,6 +260,22 @@ export function useSlideOverMediaState({ completionMedia, extrasUploads, mediaPending, + emergencyMedia, + emergencyMediaUnavailable, + emergencyMediaUnavailableMessage, + emergencyMediaTypeAllowed: emergencyMedia + ? (file: File) => classifyMediaKind(file) !== null + : undefined, + emergencyMediaTypeErrorMessage: emergencyMedia + ? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported." + : undefined, + emergencyMediaAcceptAttribute: emergencyMedia ? emergencyMediaAcceptAttribute() : undefined, + emergencyMediaSizeError: emergencyMedia + ? (file: File) => emergencyMediaSizeError(file, mediaPolicy?.limits) + : undefined, + emergencyMediaUploadHint: emergencyMedia + ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" + : undefined, uploadMediaRequests, uploadExtraFiles, categorizeMedia, 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..37792f32 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 @@ -119,6 +119,13 @@ export function useWorkOrderSlideOver({ completionMedia, extrasUploads, mediaPending, + emergencyMediaUnavailable, + emergencyMediaUnavailableMessage, + emergencyMediaTypeAllowed, + emergencyMediaTypeErrorMessage, + emergencyMediaAcceptAttribute, + emergencyMediaSizeError, + emergencyMediaUploadHint, uploadMediaRequests, uploadExtraFiles, categorizeMedia, @@ -127,6 +134,7 @@ export function useWorkOrderSlideOver({ removeMedia, } = useSlideOverMediaState({ detailForRow, + row, uploadMediaMutation, deleteMediaMutation, categorizeMediaMutation, @@ -175,6 +183,13 @@ export function useWorkOrderSlideOver({ completionMedia, extrasUploads, mediaPending, + emergencyMediaUnavailable, + emergencyMediaUnavailableMessage, + emergencyMediaTypeAllowed, + emergencyMediaTypeErrorMessage, + emergencyMediaAcceptAttribute, + emergencyMediaSizeError, + emergencyMediaUploadHint, completionPending, closeDisabled, uploadMediaRequests, 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..54513fc2 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 @@ -126,6 +126,13 @@ export function WorkOrderSlideOverTabs({ completion={state.detailForRow?.completion} media={state.completionMedia} mediaPending={state.mediaPending} + uploadDisabled={state.emergencyMediaUnavailable} + uploadUnavailableMessage={state.emergencyMediaUnavailableMessage} + typeAllowed={state.emergencyMediaTypeAllowed} + typeErrorMessage={state.emergencyMediaTypeErrorMessage} + acceptAttribute={state.emergencyMediaAcceptAttribute} + fileSizeError={state.emergencyMediaSizeError} + uploadHint={state.emergencyMediaUploadHint} completionPending={state.completionPending} onUploadMedia={(requests) => { state.uploadMediaRequests(requests); diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx index 5dd4f65f..58e1e722 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -19,6 +19,7 @@ import { import { classifyMediaKind, emergencyMediaAcceptAttribute, + emergencyMediaSizeError, isEmergencyReactiveWorkOrderType, } from "@/domain/work-orders/utils/emergency-media-limits"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -40,19 +41,24 @@ function resolveWorkOrderVersion( export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); - const { data: policy } = useWorkOrderMediaPolicy(row.id, open); + const { + data: policy, + isError: policyError, + isLoading: policyLoading, + } = useWorkOrderMediaPolicy(row.id, open); const legacyUploadMutation = useUploadWorkOrderMedia(row.id); const resumableUploadMutation = useResumableWorkOrderMediaUpload( row.id, "WorkOrderTable", - policy ?? undefined, + policyError ? undefined : (policy ?? undefined), ); const deleteMutation = useDeleteWorkOrderMedia(row.id); const isEmergency = + isEmergencyReactiveWorkOrderType(detail?.info.type) || isEmergencyReactiveWorkOrderType(policy?.workOrderType) || isEmergencyReactiveWorkOrderType(row.type); const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation; - const emergencyUploadUnavailable = isEmergency && policy?.canUpload !== true; + const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError); const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input)); @@ -124,6 +130,12 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr onCancel={cancelUpload} readOnly={isReadOnly} uploadDisabled={emergencyUploadUnavailable} + fileSizeError={ + isEmergency ? (file) => emergencyMediaSizeError(file, policy?.limits) : undefined + } + uploadHint={ + isEmergency ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" : undefined + } pending={pending} typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined} typeErrorMessage={ @@ -137,9 +149,13 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr {emergencyUploadUnavailable && !isReadOnly && (
- {policy + {policy?.canUpload === false ? "Uploads are not available for this work order." - : "Loading upload permissions…"} + : policyError + ? "Upload permissions could not be loaded. Refresh and try again." + : policyLoading || !policy + ? "Loading upload permissions…" + : "Uploads are not available for this work order."}
)} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index 21c57a61..1827d2b1 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -8,6 +8,8 @@ import { type MediaUploaderDropzoneProps = { inputRef: RefObject; pending: boolean; + disabled?: boolean; + helperText?: string; categorizeAfterUpload?: boolean; acceptAttribute?: string; onFilesSelected: (files: FileList | null) => void; @@ -16,6 +18,8 @@ type MediaUploaderDropzoneProps = { export function MediaUploaderDropzone({ inputRef, pending, + disabled = false, + helperText, categorizeAfterUpload = false, acceptAttribute, onFilesSelected, @@ -24,7 +28,7 @@ export function MediaUploaderDropzone({ <> @@ -53,7 +58,7 @@ export function MediaUploaderDropzone({ accept={acceptAttribute ?? workOrderMediaAcceptAttribute()} multiple hidden - disabled={pending} + disabled={pending || disabled} onChange={(e) => { onFilesSelected(e.target.files); e.target.value = ""; diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 3221a2a0..187d464c 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -36,6 +36,8 @@ type MediaUploaderProps = { typeAllowed?: (file: File) => boolean; typeErrorMessage?: string; acceptAttribute?: string; + fileSizeError?: (file: File) => string | undefined; + uploadHint?: string; }; export function MediaUploader({ @@ -52,6 +54,8 @@ export function MediaUploader({ typeAllowed, typeErrorMessage = "Unsupported file type.", acceptAttribute, + fileSizeError, + uploadHint, }: MediaUploaderProps) { const inputRef = useRef(null); @@ -65,6 +69,11 @@ export function MediaUploader({ toast.error(`${file.name} — ${typeErrorMessage}`); continue; } + const sizeError = fileSizeError?.(file); + if (sizeError) { + toast.error(sizeError); + continue; + } if (isWorkOrderFileTooLarge(file)) { toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); continue; @@ -104,7 +113,9 @@ export function MediaUploader({ {!readOnly && ( ): MediaUploadLimits { }; } +export function emergencyMediaSizeError( + file: File, + limits?: Partial, +): string | undefined { + const kind = classifyMediaKind(file); + if (!kind) return undefined; + const resolvedLimits = defaultLimits(limits); + return isMediaSizeWithinLimit(file, kind, resolvedLimits) + ? undefined + : mediaOverLimitNotice(file, kind, resolvedLimits); +} + /** * Validate one media file against the policy limits: kind, inclusive size caps and * duration when it can be probed. A failed duration probe keeps the file eligible. 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 01db34cd..c9d7b4d4 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 @@ -1,17 +1,41 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; const uploadMutateAsync = vi.fn(); +const resumableUploadMutateAsync = vi.fn(); +const resumableUploadHookSpy = vi.hoisted(() => vi.fn()); const deleteMutate = vi.fn(); const categorizeMutate = vi.fn(); +const mediaQueryState = vi.hoisted(() => ({ + detailType: "Emergency" as string | undefined, + policy: { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + } as unknown, + isError: false, + isLoading: false, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ data: { - info: { status: "Scheduled", rowVersion: "DETAIL-VER==" }, + info: { status: "Scheduled", rowVersion: "DETAIL-VER==", type: mediaQueryState.detailType }, media: [ { id: "11", @@ -41,31 +65,25 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ useWorkOrderMediaPolicy: () => ({ - data: { - workOrderType: "Emergency", - canUpload: false, - limits: { - photoMaxBytes: 10_000_000, - videoMaxBytes: 100_000_000, - videoMaxSeconds: 90, - maxPhotos: 10, - maxVideos: 3, - }, - remaining: { photos: 10, videos: 3 }, - }, + data: mediaQueryState.policy, + isError: mediaQueryState.isError, + isLoading: mediaQueryState.isLoading, }), })); vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ - useResumableWorkOrderMediaUpload: () => ({ mutateAsync: vi.fn(), isPending: false }), + useResumableWorkOrderMediaUpload: (...args: unknown[]) => { + resumableUploadHookSpy(...args); + return { mutateAsync: resumableUploadMutateAsync, isPending: false }; + }, })); -function baseRow(): WorkOrderTableRow { +function baseRow(type = "Emergency"): WorkOrderTableRow { return { id: 7, woNumber: "WO-7", site: "SEA1", - type: "Emergency", + type, severity: 1, dispatcherId: "u1", dispatcherName: "Dispatcher", @@ -111,14 +129,14 @@ function baseRow(): WorkOrderTableRow { }; } -function renderModal() { +function renderModal(rowType = "Emergency") { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return render( - + , ); } @@ -126,8 +144,26 @@ function renderModal() { describe("MediaUploadModal API wiring", () => { beforeEach(() => { uploadMutateAsync.mockReset(); + resumableUploadMutateAsync.mockReset(); + resumableUploadHookSpy.mockClear(); deleteMutate.mockReset(); categorizeMutate.mockReset(); + mediaQueryState.detailType = "Emergency"; + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + mediaQueryState.isError = false; + mediaQueryState.isLoading = false; + vi.mocked(toast.error).mockClear(); }); it("renders persisted media from board detail (refresh-safe)", async () => { @@ -138,7 +174,7 @@ describe("MediaUploadModal API wiring", () => { }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); expect( - screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"), + screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"), ).toBeInTheDocument(); expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); }); @@ -184,12 +220,72 @@ describe("MediaUploadModal API wiring", () => { it("fails closed when Emergency policy says uploads are unavailable", async () => { renderModal(); - const input = document.querySelector('input[type="file"]') as HTMLInputElement; - expect(input.disabled).toBe(true); + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument(); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything()); + }); + + it("routes by the detailed work-order type when policy denies uploads", () => { + mediaQueryState.detailType = "Emergency"; + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: false, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + renderModal("PM"); + + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); + expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument(); + expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything()); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); + }); + + it("fails closed with a visible error when the Emergency policy request fails", () => { + mediaQueryState.policy = undefined; + mediaQueryState.isError = true; + renderModal("PM"); + + expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true); + expect( + screen.getByText("Upload permissions could not be loaded. Refresh and try again."), + ).toBeInTheDocument(); + expect(uploadMutateAsync).not.toHaveBeenCalled(); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); + }); + + it("shows the Emergency photo cap notice before starting an upload", () => { + mediaQueryState.policy = { + workOrderType: "Emergency", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + renderModal(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(input, { - target: { files: [new File(["x"], "photo.jpg", { type: "image/jpeg" })] }, + target: { + files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })], + }, }); + + expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).'); + expect(resumableUploadMutateAsync).not.toHaveBeenCalled(); expect(uploadMutateAsync).not.toHaveBeenCalled(); }); }); diff --git a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx index d87162fe..4f91f046 100644 --- a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx +++ b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx @@ -1,8 +1,13 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) { const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow; render( @@ -43,4 +48,70 @@ describe("SlideOverCompletionTab service gate", () => { fireEvent.click(button); expect(onOpen).toHaveBeenCalledTimes(1); }); + + it("renders disabled Emergency media upload with the policy failure message", () => { + const onUpload = vi.fn(); + const row = { + id: "1", + type: "Emergency", + docStatus: "No", + rowVersion: "1", + } as WorkOrderTableRow; + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent( + "Upload permissions could not be loaded. Refresh and try again.", + ); + expect(document.querySelector('input[type="file"]')).toBeDisabled(); + expect( + screen.getByRole("button", { name: /drag files here or click to browse/i }), + ).toBeDisabled(); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("shows per-kind Emergency limits before starting an oversized photo upload", () => { + const onUpload = vi.fn(); + const row = { + id: "1", + type: "Emergency", + docStatus: "No", + rowVersion: "1", + } as WorkOrderTableRow; + render( + true} + fileSizeError={(file) => + file.size > 10_000_000 ? `"${file.name}" exceeds the photo limit (10 MB).` : undefined + } + uploadHint="Photos up to 10 MB · Videos up to 100 MB and 90 seconds" + onUploadMedia={onUpload} + onOpenCompletionDoc={vi.fn()} + />, + ); + + expect( + screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"), + ).toBeInTheDocument(); + fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { + target: { + files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })], + }, + }); + + expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).'); + expect(onUpload).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index 9b9669f7..eb2a34b4 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -4,8 +4,14 @@ import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +const policyState = vi.hoisted(() => ({ + data: undefined as unknown, + isError: false, + isLoading: true, +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({ - useWorkOrderMediaPolicy: () => ({ data: undefined }), + useWorkOrderMediaPolicy: () => policyState, })); vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({ @@ -98,6 +104,9 @@ function idleMutation() { describe("useSlideOverMediaState completion filter", () => { beforeEach(() => { sessionStorage.clear(); + policyState.data = undefined; + policyState.isError = false; + policyState.isLoading = true; }); it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => { @@ -368,6 +377,87 @@ describe("useSlideOverMediaState completion filter", () => { }); }); + it("keeps the Emergency completion surface disabled with a visible policy error", async () => { + policyState.isError = true; + policyState.isLoading = false; + const legacyUpload = idleMutation(); + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Emergency" }), + uploadMediaMutation: legacyUpload, + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.emergencyMediaUnavailable).toBe(true); + expect(result.current.emergencyMediaUnavailableMessage).toBe( + "Upload permissions could not be loaded. Refresh and try again.", + ); + act(() => { + result.current.uploadMediaRequests([ + { + localId: "local-policy-error", + file: new File(["x"], "photo.jpg", { type: "image/jpeg" }), + onProgress: vi.fn(), + }, + ]); + }); + + await waitFor(() => { + expect(legacyUpload.mutateAsync).not.toHaveBeenCalled(); + expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed"); + }); + }); + + it("uses the table row type to fail closed while work-order detail is loading", () => { + const { result } = renderHook(() => + useSlideOverMediaState({ + row: baseInfo({ type: "Reactive" }), + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.emergencyMediaUnavailable).toBe(true); + expect( + result.current.emergencyMediaTypeAllowed?.( + new File(["x"], "clip.mov", { type: "video/quicktime" }), + ), + ).toBe(true); + }); + + it("exposes server-derived Emergency size caps to the slide-over uploader", () => { + policyState.data = { + workOrderType: "Reactive", + canUpload: true, + limits: { + photoMaxBytes: 10_000_000, + videoMaxBytes: 100_000_000, + videoMaxSeconds: 90, + maxPhotos: 10, + maxVideos: 3, + }, + remaining: { photos: 10, videos: 3 }, + }; + policyState.isLoading = false; + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detailWithMedia([], { type: "Reactive" }), + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect( + result.current.emergencyMediaSizeError?.( + new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" }), + ), + ).toBe('"photo.jpg" exceeds the photo limit (10 MB).'); + }); + it("reuses the same idempotency key when a failed media tile is retried", async () => { const mutateAsync = vi .fn()