fix: fail closed on emergency media policy

This commit is contained in:
Alexandre Brandizzi 2026-09-23 04:31:53 -03:00
parent 42d26b8a6b
commit 0c8a7f877f
12 changed files with 448 additions and 38 deletions

View file

@ -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 (
<div className="space-y-3">
@ -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}
/>
</div>
{uploadDisabled && Boolean(uploadUnavailableMessage) && (
<div role="status" className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{uploadUnavailableMessage}
</div>
)}
</div>
);
}

View file

@ -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}
/>
);
}

View file

@ -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<typeof useUploadWorkOrderMedia>;
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
@ -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<MediaSurfaceOwnership>(() =>
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,

View file

@ -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,

View file

@ -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);

View file

@ -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 && (
<div role="status" className="mt-2">
<Text variant="description">
{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."}
</Text>
</div>
)}

View file

@ -8,6 +8,8 @@ import {
type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
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({
<>
<button
type="button"
disabled={pending}
disabled={pending || disabled}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
@ -43,7 +47,8 @@ export function MediaUploaderDropzone({
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
{helperText ??
`Accepted: JPG, PNG, MP4, MOV · Max ${formatMediaSizeLimit().replace(" ", "")} per file`}
{categorizeAfterUpload ? " · categorize after upload" : ""}
</span>
</button>
@ -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 = "";

View file

@ -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<HTMLInputElement>(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 && (
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending || uploadDisabled}
pending={pending}
disabled={uploadDisabled}
helperText={uploadHint}
categorizeAfterUpload={Boolean(onCategorize)}
acceptAttribute={acceptAttribute}
onFilesSelected={addFiles}

View file

@ -114,6 +114,18 @@ function defaultLimits(limits?: Partial<MediaUploadLimits>): MediaUploadLimits {
};
}
export function emergencyMediaSizeError(
file: File,
limits?: Partial<MediaUploadLimits>,
): 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.

View file

@ -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(
<QueryClientProvider client={client}>
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
<MediaUploadModal row={baseRow(rowType)} open onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
}
@ -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();
});
});

View file

@ -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(
<SlideOverCompletionTab
row={row}
isReadOnly={false}
media={[]}
uploadDisabled
uploadUnavailableMessage="Upload permissions could not be loaded. Refresh and try again."
onUploadMedia={onUpload}
onOpenCompletionDoc={vi.fn()}
/>,
);
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(
<SlideOverCompletionTab
row={row}
isReadOnly={false}
media={[]}
typeAllowed={() => 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();
});
});

View file

@ -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()