mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix: fail closed on emergency media policy
This commit is contained in:
parent
42d26b8a6b
commit
0c8a7f877f
12 changed files with 448 additions and 38 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue