mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
fix(media): pre-check duration and per-work-order counts on Extra Docs
Extra Docs advertised the 90-second limit but only checked type and size. Both dispatcher surfaces now share one screening step (type, size, count, duration), and the Completion Doc media tab and Extra Docs count the whole work order's photos and videos rather than only their own tab's share. Failed local uploads no longer count toward the limit.
This commit is contained in:
parent
11a753a2e9
commit
c99e81d0d7
12 changed files with 215 additions and 70 deletions
|
|
@ -5,10 +5,12 @@ import {
|
|||
} from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
type SlideOverCompletionMediaProps = {
|
||||
isReadOnly: boolean;
|
||||
media: MediaFile[];
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
mediaPending: boolean;
|
||||
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
|
||||
|
|
@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = {
|
|||
export function SlideOverCompletionMedia({
|
||||
isReadOnly,
|
||||
media,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
onUploadMedia,
|
||||
onCategorizeMedia,
|
||||
|
|
@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
|
|||
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||
<MediaUploader
|
||||
uploads={media}
|
||||
mediaCounts={mediaCounts}
|
||||
onUpload={onUploadMedia}
|
||||
onCategorize={onCategorizeMedia}
|
||||
onRemove={onRemoveMedia}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
|
||||
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
|
||||
|
|
@ -23,6 +24,7 @@ type SlideOverCompletionTabProps = {
|
|||
isReadOnly: boolean;
|
||||
completion?: WorkOrderBoardCompletion;
|
||||
media: MediaFile[];
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
mediaPending?: boolean;
|
||||
completionPending?: boolean;
|
||||
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||
|
|
@ -39,6 +41,7 @@ export function SlideOverCompletionTab({
|
|||
isReadOnly,
|
||||
completion,
|
||||
media,
|
||||
mediaCounts,
|
||||
mediaPending = false,
|
||||
completionPending = false,
|
||||
onUploadMedia,
|
||||
|
|
@ -59,6 +62,7 @@ export function SlideOverCompletionTab({
|
|||
<SlideOverCompletionMedia
|
||||
isReadOnly={isReadOnly}
|
||||
media={media}
|
||||
mediaCounts={mediaCounts}
|
||||
mediaPending={mediaPending}
|
||||
onUploadMedia={onUploadMedia}
|
||||
onCategorizeMedia={onCategorizeMedia}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,13 @@ import {
|
|||
hasExplicitAvetaFile,
|
||||
resolveExtraDocCategory,
|
||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
countWorkOrderMediaKinds,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
workOrderFileSizeRejection,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -52,11 +54,16 @@ type SlideOverExtrasTabProps = {
|
|||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
/** Photos/videos on the whole work order; Extra Docs shows only its own share. */
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_TYPE_MESSAGE =
|
||||
"Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.";
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
|
|
@ -175,6 +182,7 @@ export function SlideOverExtrasTab({
|
|||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
mediaCounts,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
|
|
@ -182,23 +190,16 @@ export function SlideOverExtrasTab({
|
|||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(
|
||||
"Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const sizeRejection = workOrderFileSizeRejection(file);
|
||||
if (sizeRejection) {
|
||||
toast.error(sizeRejection);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
}
|
||||
// Snapshot synchronously: the input is reset right after this handler returns.
|
||||
const selected = Array.from(files);
|
||||
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length > 0) {
|
||||
onUpload(accepted);
|
||||
}
|
||||
|
|
@ -235,7 +236,7 @@ export function SlideOverExtrasTab({
|
|||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
void addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
|
|
@ -264,7 +265,7 @@ export function SlideOverExtrasTab({
|
|||
hidden
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
addFiles(event.target.files);
|
||||
void addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -130,6 +131,9 @@ export function useSlideOverMediaState({
|
|||
[detailForRow?.media, ownership, completionHasGallery],
|
||||
);
|
||||
|
||||
// Per-work-order photo/video counts span both tabs, so count the whole session.
|
||||
const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
const mediaPending =
|
||||
mediaBatchPending ||
|
||||
|
|
@ -196,6 +200,7 @@ export function useSlideOverMediaState({
|
|||
return {
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
uploadMediaRequests,
|
||||
uploadExtraFiles,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({
|
|||
const {
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
uploadMediaRequests,
|
||||
uploadExtraFiles,
|
||||
|
|
@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({
|
|||
...readFlags,
|
||||
completionMedia,
|
||||
extrasUploads,
|
||||
mediaCounts,
|
||||
mediaPending,
|
||||
completionPending,
|
||||
closeDisabled,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export function WorkOrderSlideOverTabs({
|
|||
isReadOnly={state.isDocumentsReadOnly}
|
||||
completion={state.detailForRow?.completion}
|
||||
media={state.completionMedia}
|
||||
mediaCounts={state.mediaCounts}
|
||||
mediaPending={state.mediaPending}
|
||||
completionPending={state.completionPending}
|
||||
onUploadMedia={(requests) => {
|
||||
|
|
@ -151,6 +152,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
mediaCounts={state.mediaCounts}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,11 @@ import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
|||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
countWorkOrderMediaKinds,
|
||||
MAX_PHOTOS_PER_WORK_ORDER,
|
||||
MAX_VIDEOS_PER_WORK_ORDER,
|
||||
MAX_VIDEO_DURATION_SECONDS,
|
||||
resolveWorkOrderUploadKind,
|
||||
workOrderFileSizeRejection,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
type WorkOrderUploadKind,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
import { screenWorkOrderUploads } from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||
|
||||
|
|
@ -36,24 +31,11 @@ type MediaUploaderProps = {
|
|||
onChange?: (next: MediaFile[]) => void;
|
||||
readOnly?: boolean;
|
||||
pending?: boolean;
|
||||
/** Photos/videos on the whole work order when `uploads` is only one surface's share. */
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV.";
|
||||
const PHOTO_COUNT_MESSAGE = `A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`;
|
||||
const VIDEO_COUNT_MESSAGE = `A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`;
|
||||
const VIDEO_DURATION_MESSAGE = `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
|
||||
|
||||
async function isWithinContract(file: File, kind: WorkOrderUploadKind): Promise<boolean> {
|
||||
if (kind === "video") {
|
||||
// SH-116: pre-check only; the server enforces it too. Unreadable metadata never blocks.
|
||||
const duration = await probeVideoDurationSeconds(file);
|
||||
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
|
||||
toast.error(VIDEO_DURATION_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function MediaUploader({
|
||||
uploads,
|
||||
|
|
@ -65,6 +47,7 @@ export function MediaUploader({
|
|||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
mediaCounts,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -74,32 +57,12 @@ export function MediaUploader({
|
|||
// Snapshot synchronously: the caller resets the input value right after this
|
||||
// handler returns, which can detach a live FileList across awaits.
|
||||
const selected = Array.from(files);
|
||||
const counts = countWorkOrderMediaKinds(uploads);
|
||||
const accepted: File[] = [];
|
||||
for (const file of selected) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(UNSUPPORTED_TYPE_MESSAGE);
|
||||
continue;
|
||||
}
|
||||
const sizeRejection = workOrderFileSizeRejection(file);
|
||||
if (sizeRejection) {
|
||||
toast.error(sizeRejection);
|
||||
continue;
|
||||
}
|
||||
const kind = resolveWorkOrderUploadKind(file);
|
||||
if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) {
|
||||
toast.error(PHOTO_COUNT_MESSAGE);
|
||||
continue;
|
||||
}
|
||||
if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) {
|
||||
toast.error(VIDEO_COUNT_MESSAGE);
|
||||
continue;
|
||||
}
|
||||
if (!(await isWithinContract(file, kind))) continue;
|
||||
if (kind === "photo") counts.photos += 1;
|
||||
if (kind === "video") counts.videos += 1;
|
||||
accepted.push(file);
|
||||
}
|
||||
const { accepted, rejections } = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
if (onUpload) {
|
||||
|
|
|
|||
64
src/domain/work-orders/utils/screen-work-order-uploads.ts
Normal file
64
src/domain/work-orders/utils/screen-work-order-uploads.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
MAX_PHOTOS_PER_WORK_ORDER,
|
||||
MAX_VIDEOS_PER_WORK_ORDER,
|
||||
MAX_VIDEO_DURATION_SECONDS,
|
||||
resolveWorkOrderUploadKind,
|
||||
workOrderFileSizeRejection,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
export type WorkOrderUploadScreen = {
|
||||
/** Photos and videos already on the work order, across every surface. */
|
||||
existing: WorkOrderMediaKindCounts;
|
||||
isTypeAllowed: (file: File) => boolean;
|
||||
unsupportedTypeMessage: string;
|
||||
};
|
||||
|
||||
export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] };
|
||||
|
||||
/**
|
||||
* SH-116 browser pre-check shared by every dispatcher upload surface: type, size,
|
||||
* per-work-order photo/video count (including earlier files in the same selection)
|
||||
* and video duration. The server enforces the same contract; a video whose duration
|
||||
* the browser cannot read is never blocked here.
|
||||
*/
|
||||
export async function screenWorkOrderUploads(
|
||||
files: readonly File[],
|
||||
{ existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen,
|
||||
): Promise<WorkOrderUploadScreenResult> {
|
||||
const counts = { ...existing };
|
||||
const accepted: File[] = [];
|
||||
const rejections: string[] = [];
|
||||
for (const file of files) {
|
||||
if (!isTypeAllowed(file)) {
|
||||
rejections.push(unsupportedTypeMessage);
|
||||
continue;
|
||||
}
|
||||
const sizeRejection = workOrderFileSizeRejection(file);
|
||||
if (sizeRejection) {
|
||||
rejections.push(sizeRejection);
|
||||
continue;
|
||||
}
|
||||
const kind = resolveWorkOrderUploadKind(file);
|
||||
if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) {
|
||||
rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`);
|
||||
continue;
|
||||
}
|
||||
if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) {
|
||||
rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`);
|
||||
continue;
|
||||
}
|
||||
if (kind === "video") {
|
||||
const duration = await probeVideoDurationSeconds(file);
|
||||
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
|
||||
rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (kind === "photo") counts.photos += 1;
|
||||
if (kind === "video") counts.videos += 1;
|
||||
accepted.push(file);
|
||||
}
|
||||
return { accepted, rejections };
|
||||
}
|
||||
|
|
@ -135,11 +135,11 @@ export type WorkOrderMediaKindCounts = { photos: number; videos: number };
|
|||
|
||||
/** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */
|
||||
export function countWorkOrderMediaKinds(
|
||||
uploads: Array<{ fileType?: string; isLegacy?: boolean }>,
|
||||
uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>,
|
||||
): WorkOrderMediaKindCounts {
|
||||
return uploads.reduce(
|
||||
(counts, item) => {
|
||||
if (item.isLegacy) return counts;
|
||||
if (item.isLegacy || item.uploadStatus === "failed") return counts;
|
||||
if (item.fileType === "image") counts.photos += 1;
|
||||
if (item.fileType === "video") counts.videos += 1;
|
||||
return counts;
|
||||
|
|
|
|||
|
|
@ -126,6 +126,23 @@ describe("MediaUploader media contract (Completion Doc → Media, Photos & Video
|
|||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts the whole work order's media when this tab shows only its share", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(
|
||||
<MediaUploader uploads={[]} mediaCounts={{ photos: 10, videos: 3 }} onUpload={onUpload} />,
|
||||
);
|
||||
|
||||
selectFiles([
|
||||
phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB),
|
||||
phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB),
|
||||
]);
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2));
|
||||
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
|
||||
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts files within one selection against the remaining slots", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={gallery(0, 2)} onUpload={onUpload} />);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { fireEvent, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
|
||||
|
||||
const MB = 1_000_000;
|
||||
|
||||
function phoneFile(name: string, type: string, size: number): File {
|
||||
return { name, type, size } as File;
|
||||
}
|
||||
|
||||
function selectFiles(files: File[]) {
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
fireEvent.change(input as HTMLInputElement, { target: { files } });
|
||||
}
|
||||
|
||||
describe("SlideOverExtrasTab SH-116 media contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.error).mockClear();
|
||||
vi.mocked(probeVideoDurationSeconds).mockReset();
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
});
|
||||
|
||||
it("rejects a video longer than 90 seconds before upload", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
|
||||
);
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the 11th photo and 4th video counted across the whole work order", async () => {
|
||||
const onUpload = vi.fn();
|
||||
const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB);
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[]}
|
||||
mediaCounts={{ photos: 10, videos: 3 }}
|
||||
isReadOnly={false}
|
||||
onUpload={onUpload}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
selectFiles([
|
||||
phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB),
|
||||
phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB),
|
||||
report,
|
||||
]);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||
expect(onUpload).toHaveBeenCalledWith([report]);
|
||||
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos.");
|
||||
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
|
||||
});
|
||||
|
||||
it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
|
||||
const onUpload = vi.fn();
|
||||
const video = phoneFile("IMG_0703.MOV", "", 60 * MB);
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
selectFiles([video]);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -68,13 +68,14 @@ describe("work-order-media-limits", () => {
|
|||
expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB");
|
||||
});
|
||||
|
||||
it("counts only non-legacy photos and videos toward the per-work-order limits", () => {
|
||||
it("counts only non-legacy, non-failed photos and videos toward the per-work-order limits", () => {
|
||||
expect(
|
||||
countWorkOrderMediaKinds([
|
||||
{ fileType: "image" },
|
||||
{ fileType: "image", isLegacy: true },
|
||||
{ fileType: "video" },
|
||||
{ fileType: "document" },
|
||||
{ fileType: "video", uploadStatus: "failed" },
|
||||
]),
|
||||
).toEqual({ photos: 1, videos: 1 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue