feat: add resumable SH-116 media uploads

This commit is contained in:
Alexandre Brandizzi 2026-09-23 04:17:18 -03:00
parent 6367dc2a7a
commit 42d26b8a6b
29 changed files with 2565 additions and 30 deletions

View file

@ -41,6 +41,8 @@ export const API_PATHS = {
`workorders/${id}/media/${mediaId}`,
mediaContent: (id: string | number, mediaId: string | number) =>
`workorders/${id}/media/${mediaId}/content`,
mediaPolicy: (id: string | number) => `workorders/${id}/media/policy`,
mediaUploads: (id: string | number) => `workorders/${id}/media/uploads`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>

View file

@ -0,0 +1,160 @@
import {
type MediaChunkAckDto,
type MediaUploadCreateDto,
type MediaUploadSessionDto,
} from "@/api/media-upload-contracts";
export interface MediaUploadEndpointSet {
create(dto: MediaUploadCreateDto, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
putChunk(args: {
uploadId: string;
index: number;
blob: Blob;
start: number;
end: number;
totalBytes: number;
sha256: string;
signal?: AbortSignal;
}): Promise<MediaChunkAckDto>;
commit(uploadId: string, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
getStatus(uploadId: string, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
cancel(uploadId: string): Promise<void>;
}
/** Network-level failure (fetch rejected) — recoverable by re-reading session status. */
export class MediaUploadNetworkError extends Error {
constructor() {
super("Upload failed. Check your connection and try again.");
this.name = "MediaUploadNetworkError";
}
}
export function isNetworkUploadError(error: unknown): boolean {
return error instanceof MediaUploadNetworkError;
}
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
export async function computeSha256Hex(buffer: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
interface EndpointOptions {
resolveUrl: (suffix: string) => string;
headers: () => Record<string, string>;
}
function parseSessionPayload(payload: unknown): MediaUploadSessionDto {
if (typeof payload !== "object" || payload === null) {
throw new MediaUploadNetworkError();
}
return payload as MediaUploadSessionDto;
}
async function readJsonBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) {
return {};
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new MediaUploadNetworkError();
}
}
async function requestJson(
url: string,
init: RequestInit,
authHeaders: Record<string, string>,
): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
...init,
headers: { Accept: "application/json", ...authHeaders, ...init.headers },
});
} catch (error) {
if (isAbortError(error)) {
throw error;
}
throw new MediaUploadNetworkError();
}
const payload = await readJsonBody(response);
if (!response.ok) {
const error = new Error(`Upload request failed (HTTP ${response.status}).`);
error.name = "MediaUploadHttpError";
Object.assign(error, { status: response.status, payload });
throw error;
}
return payload;
}
export function createMediaUploadEndpoints({
resolveUrl,
headers,
}: EndpointOptions): MediaUploadEndpointSet {
const sessionUrl = (uploadId: string, suffix = ""): string =>
resolveUrl([uploadId, suffix].filter(Boolean).join("/"));
return {
create: async (dto, signal) =>
parseSessionPayload(
await requestJson(
resolveUrl(""),
{
method: "POST",
body: JSON.stringify(dto),
headers: { "Content-Type": "application/json" },
signal,
},
headers(),
),
),
putChunk: async ({ uploadId, index, blob, start, end, totalBytes, sha256, signal }) => {
const payload = await requestJson(
sessionUrl(uploadId, `chunks/${index}`),
{
method: "PUT",
body: blob,
headers: {
"Content-Type": "application/octet-stream",
"Content-Range": `bytes ${start}-${end - 1}/${totalBytes}`,
"X-Chunk-SHA256": sha256.toLowerCase(),
},
signal,
},
headers(),
);
return payload as MediaChunkAckDto;
},
commit: async (uploadId, signal) =>
parseSessionPayload(
await requestJson(sessionUrl(uploadId, "commit"), { method: "POST", signal }, headers()),
),
getStatus: async (uploadId, signal) =>
parseSessionPayload(
await requestJson(sessionUrl(uploadId), { method: "GET", signal }, headers()),
),
cancel: async (uploadId) => {
try {
await fetch(sessionUrl(uploadId), {
method: "DELETE",
headers: { Accept: "application/json", ...headers() },
});
} catch {
// Best-effort cancel — surface nothing when the session is already gone.
}
},
};
}

View file

@ -0,0 +1,130 @@
/** Shared SH-116 resumable media upload contract (dispatcher + vendor portal). */
export const MEDIA_UPLOAD_CHUNK_SIZE_BYTES = 5_242_880;
export const MEDIA_UPLOAD_SCANNING_STATE = "Scanning" as const;
export type UploadSurface = "WorkOrderTable" | "CompletionMedia" | "VendorPortal";
export type UploadCategory = "Before" | "After" | "Extra" | "Aveta";
export type MediaKind = "Photo" | "Video";
export type MediaUploadState =
"Created" | "Uploading" | "Scanning" | "Completed" | "Rejected" | "Canceled" | "Expired";
export interface MediaUploadLimits {
photoMaxBytes: number;
videoMaxBytes: number;
videoMaxSeconds: number;
maxPhotos: number;
maxVideos: number;
}
export interface MediaUploadRemaining {
photos: number;
videos: number;
}
export interface MediaUploadPolicy {
workOrderType: string;
canUpload: boolean;
limits: MediaUploadLimits;
remaining: MediaUploadRemaining;
}
export interface MediaUploadCreateDto {
idempotencyKey: string;
fileName: string;
contentType: string;
sizeBytes: number;
category?: UploadCategory;
surface: UploadSurface;
}
export interface MediaUploadSessionDto {
uploadId: string;
state: MediaUploadState;
chunkSizeBytes: number;
sizeBytes: number;
receivedChunks: number[];
expiresAt: string;
attachment?: { id: string | number };
}
export interface MediaChunkAckDto {
index: number;
sha256: string;
sizeBytes: number;
receivedChunks: number[];
}
export const MEDIA_UPLOAD_TERMINAL_STATES: ReadonlySet<MediaUploadState> = new Set([
"Completed",
"Rejected",
"Canceled",
"Expired",
]);
export function isTerminalMediaUploadState(state: MediaUploadState): boolean {
return MEDIA_UPLOAD_TERMINAL_STATES.has(state);
}
export function isUploadCategory(value: string | undefined): value is UploadCategory {
return value === "Before" || value === "After" || value === "Extra" || value === "Aveta";
}
/** Safe, stable user-facing messages. Never render raw server payloads. */
export function safeMediaUploadErrorMessage(error: Error): string {
if (error instanceof MediaUploadValidationError) {
return error.message;
}
if (error instanceof MediaUploadRejectedError) {
return "The file was rejected by the security scan and was not saved.";
}
if (error instanceof MediaUploadExpiredError) {
return "The upload session expired. Please try again.";
}
if (error instanceof MediaUploadScanTimeoutError) {
return "The upload is still processing. Check back shortly to see the result.";
}
if (error.name === "AbortError") {
return "Upload canceled.";
}
return "Upload failed. Check your connection and try again.";
}
export class MediaUploadValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "MediaUploadValidationError";
}
}
export class MediaUploadRejectedError extends Error {
constructor() {
super("The file was rejected by the security scan.");
this.name = "MediaUploadRejectedError";
}
}
export class MediaUploadExpiredError extends Error {
constructor() {
super("The upload session expired.");
this.name = "MediaUploadExpiredError";
}
}
export class MediaUploadCanceledError extends Error {
constructor() {
super("The upload session was canceled.");
this.name = "MediaUploadCanceledError";
}
}
export class MediaUploadScanTimeoutError extends Error {
constructor() {
super("The upload is still processing.");
this.name = "MediaUploadScanTimeoutError";
}
}

View file

@ -0,0 +1,309 @@
import {
type MediaChunkAckDto,
type MediaUploadCreateDto,
type MediaUploadSessionDto,
type UploadCategory,
type UploadSurface,
MEDIA_UPLOAD_CHUNK_SIZE_BYTES,
MediaUploadCanceledError,
MediaUploadExpiredError,
MediaUploadRejectedError,
MediaUploadScanTimeoutError,
isTerminalMediaUploadState,
} from "@/api/media-upload-contracts";
import {
type MediaUploadEndpointSet,
computeSha256Hex,
isAbortError,
isNetworkUploadError,
} from "@/api/media-upload-client";
const MAX_CREATE_ATTEMPTS = 2;
const MAX_CHUNK_RECOVERY_ATTEMPTS = 3;
const CHUNK_RECOVERY_BACKOFF_MS = 200;
const MAX_COMMIT_ATTEMPTS = 2;
const MAX_POLL_ATTEMPTS = 120;
const DEFAULT_POLL_DELAY_MS = 1_500;
export interface ResumableUploadArgs {
file: File;
endpoints: MediaUploadEndpointSet;
surface: UploadSurface;
category?: UploadCategory;
signal?: AbortSignal;
onProgress?: (percent: number) => void;
/** Defaults to a fresh random UUID per attempt. */
idempotencyKey?: string;
digest?: (buffer: ArrayBuffer) => Promise<string>;
pollDelayMs?: number;
}
export function chunkCountFor(sizeBytes: number, chunkSizeBytes: number): number {
if (sizeBytes <= 0) return 1;
return Math.ceil(sizeBytes / chunkSizeBytes);
}
export function missingChunkIndices(received: readonly number[], total: number): number[] {
const acked = new Set(received);
const missing: number[] = [];
for (let index = 0; index < total; index += 1) {
if (!acked.has(index)) {
missing.push(index);
}
}
return missing;
}
function acknowledgedBytes(
received: readonly number[],
totalBytes: number,
chunkSize: number,
): number {
let bytes = 0;
for (const index of received) {
if (index >= 0 && index < chunkCountFor(totalBytes, chunkSize)) {
bytes += Math.min(chunkSize, totalBytes - index * chunkSize);
}
}
return bytes;
}
function reportProgress(
args: ResumableUploadArgs,
received: readonly number[],
totalBytes: number,
chunkSize: number,
): void {
if (!args.onProgress || totalBytes <= 0) return;
const percent = Math.min(
100,
Math.round((acknowledgedBytes(received, totalBytes, chunkSize) / totalBytes) * 100),
);
args.onProgress(percent);
}
function assertNotAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new DOMException("The upload was aborted.", "AbortError");
}
}
function throwForTerminalState(state: MediaUploadSessionDto["state"]): void {
if (state === "Rejected") throw new MediaUploadRejectedError();
if (state === "Expired") throw new MediaUploadExpiredError();
if (state === "Canceled") throw new MediaUploadCanceledError();
}
/** Idempotent create: a network failure retries the same idempotency key, never a new session. */
async function createSessionIdempotent(
endpoints: MediaUploadEndpointSet,
dto: MediaUploadCreateDto,
signal: AbortSignal | undefined,
): Promise<MediaUploadSessionDto> {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt += 1) {
try {
return await endpoints.create(dto, signal);
} catch (error) {
if (!isNetworkUploadError(error)) throw error;
lastError = error;
}
}
throw lastError;
}
/**
* Network failure recovery: re-read the session; terminal states settle here,
* otherwise the returned receivedChunks define what still needs sending.
*/
async function recoverSession(
endpoints: MediaUploadEndpointSet,
uploadId: string,
signal: AbortSignal | undefined,
): Promise<MediaUploadSessionDto> {
let session: MediaUploadSessionDto;
try {
session = await endpoints.getStatus(uploadId, signal);
} catch (error) {
if (isAbortError(error)) throw error;
if (isNetworkUploadError(error)) {
// Still offline — bubble so the attempt surfaces as retryable failure.
throw error;
}
throw error;
}
if (isTerminalMediaUploadState(session.state)) {
throwForTerminalState(session.state);
return session;
}
return session;
}
async function waitForScanTerminal(
args: ResumableUploadArgs,
uploadId: string,
): Promise<MediaUploadSessionDto> {
const delayMs = args.pollDelayMs ?? DEFAULT_POLL_DELAY_MS;
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
assertNotAborted(args.signal);
await new Promise((resolve) => setTimeout(resolve, delayMs));
assertNotAborted(args.signal);
let session: MediaUploadSessionDto;
try {
session = await args.endpoints.getStatus(uploadId, args.signal);
} catch (error) {
if (isAbortError(error)) throw error;
continue; // Transient poll failure — keep polling until the deadline.
}
if (isTerminalMediaUploadState(session.state)) {
throwForTerminalState(session.state);
return session;
}
}
throw new MediaUploadScanTimeoutError();
}
async function commitAndObserve(
args: ResumableUploadArgs,
uploadId: string,
): Promise<MediaUploadSessionDto> {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_COMMIT_ATTEMPTS; attempt += 1) {
try {
const committed = await args.endpoints.commit(uploadId, args.signal);
return committed.state === "Scanning" ? waitForScanTerminal(args, uploadId) : committed;
} catch (error) {
if (!isNetworkUploadError(error)) throw error;
lastError = error;
const observed = await args.endpoints.getStatus(uploadId, args.signal);
if (observed.state === "Completed") return observed;
if (isTerminalMediaUploadState(observed.state)) {
throwForTerminalState(observed.state);
}
if (observed.state === "Scanning") return waitForScanTerminal(args, uploadId);
if (attempt + 1 < MAX_COMMIT_ATTEMPTS) {
continue; // Commit is idempotent for this uploadId.
}
}
}
throw lastError;
}
async function sendChunk(
args: ResumableUploadArgs,
uploadId: string,
index: number,
chunkSize: number,
): Promise<MediaChunkAckDto> {
const { file } = args;
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const blob = file.slice(start, end);
const buffer = await blob.arrayBuffer();
const digest = args.digest ?? computeSha256Hex;
const sha256 = await digest(buffer);
return args.endpoints.putChunk({
uploadId,
index,
blob,
start,
end,
totalBytes: file.size,
sha256,
signal: args.signal,
});
}
/**
* Upload one file through the resumable session API: create → chunks → commit → poll.
* One in-flight chunk request per file; durable progress counts only acknowledged parts.
*/
export async function uploadFileResumable(
args: ResumableUploadArgs,
): Promise<MediaUploadSessionDto> {
assertNotAborted(args.signal);
const createDto: MediaUploadCreateDto = {
idempotencyKey: args.idempotencyKey ?? crypto.randomUUID(),
fileName: args.file.name,
contentType: args.file.type,
sizeBytes: args.file.size,
surface: args.surface,
...(args.category ? { category: args.category } : {}),
};
let session = await createSessionIdempotent(args.endpoints, createDto, args.signal);
const uploadId = session.uploadId;
const chunkSize =
Number.isFinite(session.chunkSizeBytes) && session.chunkSizeBytes > 0
? session.chunkSizeBytes
: MEDIA_UPLOAD_CHUNK_SIZE_BYTES;
const totalChunks = chunkCountFor(args.file.size, chunkSize);
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
try {
const recoveryAttempts = new Map<number, number>();
for (;;) {
assertNotAborted(args.signal);
const missing = missingChunkIndices(session.receivedChunks, totalChunks);
if (missing.length === 0) break;
for (const index of missing) {
assertNotAborted(args.signal);
try {
const ack = await sendChunk(args, session.uploadId, index, chunkSize);
const acked = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : [];
if (!acked.includes(index)) {
// Treat a malformed ack as not-yet-acked so the loop cannot spin forever.
throw new Error("Chunk acknowledgement missing from server response.");
}
session = { ...session, receivedChunks: acked };
} catch (error) {
if (!isNetworkUploadError(error)) throw error;
session = await recoverSession(args.endpoints, session.uploadId, args.signal);
if (session.receivedChunks.includes(index)) {
recoveryAttempts.delete(index);
} else {
const attempts = (recoveryAttempts.get(index) ?? 0) + 1;
recoveryAttempts.set(index, attempts);
if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error;
await new Promise((resolve) =>
setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts),
);
assertNotAborted(args.signal);
}
break; // Re-derive missing indices from the recovered server state.
}
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
}
}
assertNotAborted(args.signal);
session = await commitAndObserve(args, uploadId);
} catch (error) {
// Cancellation aborts the in-flight request and DELETEs the known session.
if (isAbortError(error)) {
await args.endpoints.cancel(uploadId);
}
throw error;
}
throwForTerminalState(session.state);
reportProgress(args, session.receivedChunks, args.file.size, chunkSize);
return session;
}
/** Upload a queue of files with at most `maxConcurrent` files in flight. */
export async function runMediaUploadQueue<T>(
items: readonly T[],
runner: (item: T) => Promise<void>,
maxConcurrent = 2,
): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(maxConcurrent, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor];
cursor += 1;
await runner(item);
}
});
await Promise.all(workers);
}

View file

@ -11,12 +11,15 @@ import type {
useUploadWorkOrderMedia,
UploadWorkOrderMediaInput,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload";
import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy";
import {
hasCompletionMediaGallery,
isCompletionSurfaceMedia,
isExtrasSurfaceMedia,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
import { isEmergencyReactiveWorkOrderType } from "@/domain/work-orders/utils/emergency-media-limits";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@ -82,6 +85,16 @@ export function useSlideOverMediaState({
}: UseSlideOverMediaStateArgs) {
const workOrderId = detailForRow?.info.id;
const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type);
const { data: mediaPolicy } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
workOrderId ?? "",
"CompletionMedia",
mediaPolicy ?? undefined,
);
const policyCanUpload = mediaPolicy?.canUpload === true;
const emergencyMedia =
isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) ||
isEmergencyReactiveWorkOrderType(detailForRow?.info.type);
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
loadOwnership(workOrderId),
@ -103,7 +116,25 @@ export function useSlideOverMediaState({
);
const persistCompletionUpload = useCallback(
async (input: UploadWorkOrderMediaInput) => {
async (input: UploadWorkOrderMediaInput): Promise<{ id: string }> => {
if (emergencyMedia) {
if (!policyCanUpload) {
throw new Error("Uploads are not available for this work order.");
}
const session = await resumableCompletionUpload.mutateAsync(input);
const attachmentId = session.attachment?.id;
if (attachmentId == null) {
throw new Error(
"Upload completed but no media item was returned. Refresh and try again.",
);
}
const mediaId = String(attachmentId);
updateOwnership((current) => ({
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), mediaId),
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
}));
return { id: mediaId };
}
const item = await uploadMediaMutation.mutateAsync(input);
updateOwnership((current) => ({
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
@ -111,7 +142,13 @@ export function useSlideOverMediaState({
}));
return item;
},
[updateOwnership, uploadMediaMutation],
[
emergencyMedia,
policyCanUpload,
resumableCompletionUpload,
updateOwnership,
uploadMediaMutation,
],
);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
@ -135,6 +172,7 @@ export function useSlideOverMediaState({
mediaBatchPending ||
hasLocalUploading ||
uploadMediaMutation.isPending ||
resumableCompletionUpload.isPending ||
deleteMediaMutation.isPending ||
categorizeMediaMutation.isPending;

View file

@ -9,11 +9,18 @@ import { Text } from "@/components/ui/text";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useResumableWorkOrderMediaUpload } from "@/domain/work-orders/use-cases/use-resumable-work-order-media-upload";
import { useWorkOrderMediaPolicy } from "@/domain/work-orders/use-cases/use-work-order-media-policy";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useDeleteWorkOrderMedia,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import {
classifyMediaKind,
emergencyMediaAcceptAttribute,
isEmergencyReactiveWorkOrderType,
} from "@/domain/work-orders/utils/emergency-media-limits";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { guardDialogClose } from "@/lib/dialog-guard";
@ -33,8 +40,19 @@ function resolveWorkOrderVersion(
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
const { data: policy } = useWorkOrderMediaPolicy(row.id, open);
const legacyUploadMutation = useUploadWorkOrderMedia(row.id);
const resumableUploadMutation = useResumableWorkOrderMediaUpload(
row.id,
"WorkOrderTable",
policy ?? undefined,
);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isEmergency =
isEmergencyReactiveWorkOrderType(policy?.workOrderType) ||
isEmergencyReactiveWorkOrderType(row.type);
const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation;
const emergencyUploadUnavailable = isEmergency && policy?.canUpload !== true;
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
@ -105,9 +123,26 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
onRetry={retryUpload}
onCancel={cancelUpload}
readOnly={isReadOnly}
uploadDisabled={emergencyUploadUnavailable}
pending={pending}
typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined}
typeErrorMessage={
isEmergency
? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported."
: undefined
}
acceptAttribute={isEmergency ? emergencyMediaAcceptAttribute() : undefined}
/>
)}
{emergencyUploadUnavailable && !isReadOnly && (
<div role="status" className="mt-2">
<Text variant="description">
{policy
? "Uploads are not available for this work order."
: "Loading upload permissions…"}
</Text>
</div>
)}
</div>
<div className="mt-2 flex justify-end">

View file

@ -9,6 +9,7 @@ type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
pending: boolean;
categorizeAfterUpload?: boolean;
acceptAttribute?: string;
onFilesSelected: (files: FileList | null) => void;
};
@ -16,6 +17,7 @@ export function MediaUploaderDropzone({
inputRef,
pending,
categorizeAfterUpload = false,
acceptAttribute,
onFilesSelected,
}: MediaUploaderDropzoneProps) {
return (
@ -48,7 +50,7 @@ export function MediaUploaderDropzone({
<input
ref={inputRef}
type="file"
accept={workOrderMediaAcceptAttribute()}
accept={acceptAttribute ?? workOrderMediaAcceptAttribute()}
multiple
hidden
disabled={pending}

View file

@ -16,6 +16,7 @@ export type MediaUploadRequest = {
localId: string;
file: File;
onProgress: (percent: number) => void;
idempotencyKey?: string;
};
type MediaUploaderProps = {
@ -30,6 +31,11 @@ type MediaUploaderProps = {
onChange?: (next: MediaFile[]) => void;
readOnly?: boolean;
pending?: boolean;
uploadDisabled?: boolean;
/** Emergency/Reactive override: policy-driven type check incl. HEIC. */
typeAllowed?: (file: File) => boolean;
typeErrorMessage?: string;
acceptAttribute?: string;
};
export function MediaUploader({
@ -42,6 +48,10 @@ export function MediaUploader({
onChange,
readOnly = false,
pending = false,
uploadDisabled = false,
typeAllowed,
typeErrorMessage = "Unsupported file type.",
acceptAttribute,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
@ -50,8 +60,9 @@ export function MediaUploader({
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
const typeOk = typeAllowed ? typeAllowed(file) : isWorkOrderMediaTypeAllowed(file);
if (!typeOk) {
toast.error(`${file.name} — ${typeErrorMessage}`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
@ -93,8 +104,9 @@ export function MediaUploader({
{!readOnly && (
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending}
pending={pending || uploadDisabled}
categorizeAfterUpload={Boolean(onCategorize)}
acceptAttribute={acceptAttribute}
onFilesSelected={addFiles}
/>
)}

View file

@ -3,13 +3,19 @@ import type { MediaUploadRequest } from "@/app/(protected)/workorders/_component
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import { runMediaUploadQueue } from "@/api/media-upload-engine";
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
type LocalUpload = MediaFile & {
sourceFile?: File;
signal?: AbortSignal;
idempotencyKey: string;
};
type UploadMutate = (input: {
file: File;
signal?: AbortSignal;
idempotencyKey: string;
onProgress?: (percent: number) => void;
}) => Promise<unknown>;
@ -17,6 +23,7 @@ type SessionUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
idempotencyKey: string;
};
function revokeBlobUrl(url: string | undefined) {
@ -38,6 +45,7 @@ function toLocalUpload(request: SessionUploadRequest): LocalUpload {
uploadStatus: "uploading" as const,
sourceFile: request.file,
signal: request.signal,
idempotencyKey: request.idempotencyKey,
};
}
@ -148,32 +156,38 @@ export function useMediaUploadSession(
localId: request.localId,
file: request.file,
signal: replaceController(request.localId).signal,
idempotencyKey:
request.idempotencyKey ??
localUploads.find((item) => item.id === request.localId)?.idempotencyKey ??
crypto.randomUUID(),
}));
const entries = sessionRequests.map(toLocalUpload);
setLocalUploads((prev) => upsertUploads(prev, entries));
for (const request of sessionRequests) {
void uploadMutateAsync({
file: request.file,
signal: request.signal,
onProgress: (percent) => {
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
),
);
},
})
.then(() => settleAttempt(request))
.catch((error: Error) => {
if (error.name === "AbortError") {
settleAttempt(request);
return;
}
failAttempt(request, error);
void runMediaUploadQueue(sessionRequests, async (request) => {
try {
await uploadMutateAsync({
file: request.file,
signal: request.signal,
idempotencyKey: request.idempotencyKey,
onProgress: (percent) => {
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
),
);
},
});
}
settleAttempt(request);
} catch (error) {
if ((error as Error).name === "AbortError") {
settleAttempt(request);
return;
}
failAttempt(request, error as Error);
}
});
}
function retryUpload(localId: string) {
@ -184,6 +198,7 @@ export function useMediaUploadSession(
localId,
file: target.sourceFile,
onProgress: () => undefined,
idempotencyKey: target.idempotencyKey,
},
]);
}

View file

@ -3,6 +3,7 @@ import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
import { VendorMediaCard } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import type {
VendorPortalDispatchDetail,
@ -61,6 +62,8 @@ export function DispatchDetailSections({
/>
</div>
<VendorMediaCard token={token} dispatchId={dispatchId} locked={locked} />
<div className="vp-card">
<Text variant="heading">NTE Uplift Requests</Text>
<UpliftRequestsSection

View file

@ -0,0 +1,255 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
import type {
VendorMediaItem,
VendorPortalMedia,
} from "@/domain/vendor-portal/types/vendor-portal";
import { MediaUploadNetworkError } from "@/api/media-upload-client";
import { uploadFileResumable } from "@/api/media-upload-engine";
import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits";
import {
MediaUploadValidationError,
safeMediaUploadErrorMessage,
} from "@/api/media-upload-contracts";
type VendorMediaSectionProps = {
token: string;
dispatchId: number;
locked: boolean;
};
type MediaLoadState = {
loading: boolean;
error?: string;
media?: VendorPortalMedia;
};
function formatBytes(sizeBytes: number): string {
if (sizeBytes >= 1_000_000) {
return `${Math.round(sizeBytes / 1_000_000)} MB`;
}
return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`;
}
function viewMediaItem(item: VendorMediaItem, blob: Blob): void {
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) {
const inputRef = useRef<HTMLInputElement>(null);
const controllerRef = useRef<AbortController | null>(null);
const idempotencyKeyRef = useRef<string | undefined>(undefined);
const failedFileRef = useRef<File | null>(null);
const [loadState, setLoadState] = useState<MediaLoadState>({ loading: true });
const [progress, setProgress] = useState<number>();
const [uploadCanceled, setUploadCanceled] = useState(false);
const load = useCallback(async () => {
setLoadState({ loading: true });
try {
const media = await vendorPortalMediaApi.getMedia(token, dispatchId);
setLoadState({ loading: false, media });
} catch {
setLoadState({
loading: false,
error: "Unable to load media. Check your connection and try again.",
});
}
}, [token, dispatchId]);
useEffect(() => {
void load();
}, [load]);
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const policy = loadState.media?.policy;
if (!policy?.canUpload) {
throw new MediaUploadValidationError("Uploading is not available for this dispatch.");
}
const check = await validateMediaFileAgainstLimits(file, policy);
if (!check.ok) {
throw new MediaUploadValidationError(check.reason);
}
return uploadFileResumable({
file,
endpoints: vendorPortalMediaApi.endpoints(token, dispatchId),
surface: "VendorPortal",
idempotencyKey: idempotencyKeyRef.current,
signal: controllerRef.current?.signal,
onProgress: setProgress,
});
},
onSuccess: () => {
failedFileRef.current = null;
idempotencyKeyRef.current = undefined;
void load();
},
onError: (error: Error) => {
setUploadCanceled(error.name === "AbortError");
},
onSettled: () => {
setProgress(undefined);
controllerRef.current = null;
if (inputRef.current) {
inputRef.current.value = "";
}
},
});
const startUpload = (file: File | null, retry = false) => {
if (!file) return;
if (!retry || !idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}
failedFileRef.current = file;
controllerRef.current = new AbortController();
setUploadCanceled(false);
uploadMutation.reset();
uploadMutation.mutate(file);
};
const selectFile = (file: File | undefined) => startUpload(file ?? null);
const handleView = async (item: VendorMediaItem) => {
try {
const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id);
viewMediaItem(item, blob);
} catch {
setLoadState((current) => ({
...current,
error: "Unable to open the media file. Please try again.",
}));
}
};
const items = loadState.media?.items ?? [];
const canUpload = !locked && loadState.media?.policy.canUpload === true;
return (
<>
<input
ref={inputRef}
type="file"
aria-label="Choose photos or videos"
accept="image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov"
hidden
onChange={(event) => selectFile(event.target.files?.[0])}
/>
{loadState.loading && <VendorPortalFeedback>Loading media…</VendorPortalFeedback>}
{!loadState.loading && Boolean(loadState.error) && (
<>
<VendorPortalFeedback tone="error">{loadState.error}</VendorPortalFeedback>
<div className="vp-actions">
<button type="button" className="vp-btn vp-btn-ghost" onClick={() => void load()}>
Retry
</button>
</div>
</>
)}
{!loadState.loading && !loadState.error && items.length === 0 && (
<VendorPortalFeedback>No photos or videos uploaded yet.</VendorPortalFeedback>
)}
{items.map((item) => (
<article key={item.id} className="vp-uplift-row">
<div className="vp-row">
<strong>File</strong>
<span>
{item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)}
</span>
</div>
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={!item.canView}
onClick={() => void handleView(item)}
>
{item.canView ? "View" : "Unavailable"}
</button>
</div>
</article>
))}
{uploadMutation.isPending && (
<div role="status" aria-live="polite">
{progress !== undefined ? (
<>
<progress max={100} value={progress} style={{ width: "100%" }} />
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
</>
) : (
<VendorPortalFeedback>Preparing upload…</VendorPortalFeedback>
)}
<button
type="button"
className="vp-btn vp-btn-ghost"
onClick={() => controllerRef.current?.abort()}
>
Cancel upload
</button>
</div>
)}
{uploadCanceled && <VendorPortalFeedback>Upload canceled.</VendorPortalFeedback>}
{uploadMutation.isError && !uploadCanceled && (
<div>
<VendorPortalFeedback tone="error">
{safeMediaUploadErrorMessage(uploadMutation.error)}
</VendorPortalFeedback>
{!locked && uploadMutation.error instanceof MediaUploadNetworkError && (
<button
type="button"
className="vp-btn vp-btn-ghost"
disabled={uploadMutation.isPending}
onClick={() => startUpload(failedFileRef.current, true)}
>
Retry upload
</button>
)}
</div>
)}
{canUpload && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={uploadMutation.isPending}
onClick={() => {
uploadMutation.reset();
inputRef.current?.click();
}}
>
Upload photo or video
</button>
</div>
)}
{locked && (
<VendorPortalFeedback>
Media is read-only while this dispatch is locked.
</VendorPortalFeedback>
)}
</>
);
}
export function VendorMediaCard(props: VendorMediaSectionProps) {
return (
<div className="vp-card">
<Text variant="heading">Photos &amp; Videos</Text>
<VendorMediaSection {...props} />
</div>
);
}

View file

@ -113,7 +113,7 @@ async function downloadVendorDocument(
);
}
async function vendorPortalCall<T>(
export async function vendorPortalCall<T>(
token: string,
path: string,
options?: { method?: string; body?: unknown },

View file

@ -0,0 +1,49 @@
import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client";
import { env } from "@/lib/env";
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
import { vendorPortalCall } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal";
function vendorMediaUrl(dispatchId: string | number, suffix = ""): string {
const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media`;
return suffix ? `${base}/${suffix}` : base;
}
export const vendorPortalMediaApi = {
/**
* GET /api/vendor-portal/dispatches/{dispatchId}/media — policy plus scan-passed
* own-dispatch items. The server derives work-order type and own-dispatch scope.
*/
getMedia: (token: string, dispatchId: string | number): Promise<VendorPortalMedia> =>
vendorPortalCall<VendorPortalMedia>(token, `/dispatches/${dispatchId}/media`),
/** Content is fetched only on explicit user action; no object URLs are persisted. */
fetchContent: async (
token: string,
dispatchId: string | number,
mediaId: number,
): Promise<Blob> => {
const response = await fetch(vendorMediaUrl(dispatchId, `${mediaId}/content`), {
headers: { "X-Vendor-Token": token },
});
if (!response.ok) {
throw new Error(
response.status === 423 ? "File is still being scanned" : "Unable to load the media file.",
);
}
return response.blob();
},
/**
* Vendor transport bound to /api/vendor-portal/dispatches/{dispatchId}/media/uploads.
* X-Vendor-Token only — never the dispatcher Authorization header.
*/
endpoints: (token: string, dispatchId: string | number): MediaUploadEndpointSet =>
createMediaUploadEndpoints({
resolveUrl: (suffix) => {
const base = `${resolveApiBaseUrl(env.apiUrl)}/vendor-portal/dispatches/${dispatchId}/media/uploads`;
return suffix ? `${base}/${suffix}` : base;
},
headers: () => ({ "X-Vendor-Token": token }),
}),
};

View file

@ -138,3 +138,20 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
signoffs: VendorPortalSignoff[];
documents: VendorPortalDocument[];
}
export interface VendorMediaItem {
id: number;
originalFileName: string;
contentType: string;
sizeBytes: number;
kind: "Photo" | "Video";
category: "Extra";
scanStatus: "Passed";
createdAt: string;
canView: boolean;
}
export interface VendorPortalMedia {
policy: import("@/api/media-upload-contracts").MediaUploadPolicy;
items: VendorMediaItem[];
}

View file

@ -0,0 +1,31 @@
import { apiGet } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { createMediaUploadEndpoints, type MediaUploadEndpointSet } from "@/api/media-upload-client";
import type { MediaUploadPolicy } from "@/api/media-upload-contracts";
import { env } from "@/lib/env";
import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url";
import { getAccessToken } from "@/lib/auth/auth-storage";
function workOrderMediaUploadUrl(workOrderId: string | number, suffix: string): string {
const base = `${resolveApiBaseUrl(env.apiUrl)}/${API_PATHS.workOrder.mediaUploads(workOrderId)}`;
return suffix ? `${base}/${suffix}` : base;
}
export const workOrdersMediaUploadApi = {
/** GET workorders/{woId}/media/policy — server-derived type, limits and remaining counts. */
getMediaPolicy: async (workOrderId: string | number): Promise<MediaUploadPolicy> =>
apiGet<MediaUploadPolicy>(API_PATHS.workOrder.mediaPolicy(workOrderId)),
/**
* Dispatcher transport bound to /api/workorders/{woId}/media/uploads.
* Work-order binding comes from the URL; clients cannot repoint it.
*/
endpoints: (workOrderId: string | number): MediaUploadEndpointSet =>
createMediaUploadEndpoints({
resolveUrl: (suffix) => workOrderMediaUploadUrl(workOrderId, suffix),
headers: (): Record<string, string> => {
const token = getAccessToken();
return token ? { Authorization: token } : {};
},
}),
};

View file

@ -0,0 +1,85 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import {
type MediaUploadPolicy,
type MediaUploadSessionDto,
type UploadCategory,
type UploadSurface,
MediaUploadValidationError,
isUploadCategory,
safeMediaUploadErrorMessage,
} from "@/api/media-upload-contracts";
import { uploadFileResumable } from "@/api/media-upload-engine";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { UploadWorkOrderMediaInput } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits";
import { queryKeys } from "@/infra/query-key/query-key";
function toUploadCategory(
category: UploadableMediaCategory | undefined,
): UploadCategory | undefined {
return typeof category === "string" && isUploadCategory(category) ? category : undefined;
}
function invalidateMediaQueries(
queryClient: ReturnType<typeof useQueryClient>,
workOrderId: string | number,
): void {
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
});
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.boardMedia(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.mediaPolicy(workOrderId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
/**
* Emergency/Reactive resumable upload (5 MiB chunks, acknowledged-part progress,
* network-loss recovery by session status). Limits are enforced client-side from the
* server policy; the server re-checks everything at commit. Media surfaces render only
* Completed/scan-passed attachments.
*/
export function useResumableWorkOrderMediaUpload(
workOrderId: string | number,
surface: Extract<UploadSurface, "WorkOrderTable" | "CompletionMedia">,
policy?: MediaUploadPolicy,
): UseMutationResult<MediaUploadSessionDto, Error, UploadWorkOrderMediaInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
file,
category,
idempotencyKey,
signal,
onProgress,
}: UploadWorkOrderMediaInput) => {
if (!policy?.canUpload) {
throw new MediaUploadValidationError("Uploads are not available for this work order.");
}
const check = await validateMediaFileAgainstLimits(file, policy);
if (!check.ok) {
throw new MediaUploadValidationError(check.reason);
}
return uploadFileResumable({
file,
endpoints: workOrdersMediaUploadApi.endpoints(workOrderId),
surface,
category: toUploadCategory(category),
idempotencyKey,
signal,
onProgress,
});
},
onSuccess: (session) => {
invalidateMediaQueries(queryClient, workOrderId);
if (session.state === "Completed") {
toast.success("File uploaded");
}
},
onError: (error: Error) => {
toast.error(safeMediaUploadErrorMessage(error));
},
});
}

View file

@ -0,0 +1,24 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import type { MediaUploadPolicy } from "@/api/media-upload-contracts";
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
import { queryKeys } from "@/infra/query-key/query-key";
/**
* Server-derived Emergency/Reactive media policy: type, canUpload, limits and
* remaining counts (existing + pending reservations included server-side).
*/
export function useWorkOrderMediaPolicy(
workOrderId: string | number | undefined,
enabled: boolean,
): UseQueryResult<MediaUploadPolicy, Error> {
return useQuery({
queryKey:
workOrderId == null
? [...queryKeys.workOrders.all, "mediaPolicy", "none"]
: queryKeys.workOrders.mediaPolicy(workOrderId),
queryFn: () => workOrdersMediaUploadApi.getMediaPolicy(workOrderId as string | number),
enabled: enabled && workOrderId != null,
staleTime: 15_000,
retry: false,
});
}

View file

@ -52,6 +52,7 @@ export interface UpdateWorkOrderCommentInput {
export interface UploadWorkOrderMediaInput {
file: File;
category?: UploadableMediaCategory;
idempotencyKey?: string;
signal?: AbortSignal;
onProgress?: (percent: number) => void;
}

View file

@ -0,0 +1,151 @@
import type { MediaKind, MediaUploadLimits, MediaUploadPolicy } from "@/api/media-upload-contracts";
export const EMERGENCY_PHOTO_MAX_BYTES = 10_000_000;
export const EMERGENCY_VIDEO_MAX_BYTES = 100_000_000;
export const EMERGENCY_VIDEO_MAX_SECONDS = 90;
const PHOTO_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/heic"]);
const PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic"]);
const VIDEO_MIME_TYPES = new Set(["video/mp4", "video/quicktime"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]);
export type MediaValidationProbe = (file: File) => Promise<number | null>;
export type MediaValidationResult = { ok: true } | { ok: false; reason: string };
function fileExtension(file: File): string {
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot) : "";
}
export function classifyMediaKind(file: File): MediaKind | null {
const mime = file.type.toLowerCase();
if (PHOTO_MIME_TYPES.has(mime) || PHOTO_EXTENSIONS.has(fileExtension(file))) {
return "Photo";
}
if (VIDEO_MIME_TYPES.has(mime) || VIDEO_EXTENSIONS.has(fileExtension(file))) {
return "Video";
}
return null;
}
/** Inclusive limit: exactly at the cap passes, one byte over fails. */
export function isMediaSizeWithinLimit(
file: File,
kind: MediaKind,
limits: MediaUploadLimits,
): boolean {
const maxBytes = kind === "Photo" ? limits.photoMaxBytes : limits.videoMaxBytes;
return file.size <= maxBytes;
}
export function mediaOverLimitNotice(
file: File,
kind: MediaKind,
limits: MediaUploadLimits,
): string {
if (kind === "Video") {
return `"${file.name}" exceeds the video limit (${Math.round(limits.videoMaxBytes / 1_000_000)} MB, ${limits.videoMaxSeconds} seconds).`;
}
return `"${file.name}" exceeds the photo limit (${Math.round(limits.photoMaxBytes / 1_000_000)} MB).`;
}
export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUploadLimits): string {
const max = kind === "Photo" ? limits.maxPhotos : limits.maxVideos;
return `${kind} limit reached (${max} per work order). Remove a file to add another.`;
}
export function emergencyMediaAcceptAttribute(): string {
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
}
/**
* Media limits apply only to Emergency/Reactive work orders; `canUpload` from the
* server policy is authoritative and work-order binding/category are fixed by route.
*/
export function isEmergencyReactiveWorkOrderType(workOrderType: string | undefined): boolean {
if (!workOrderType) return false;
const normalized = workOrderType.trim().toLowerCase();
return normalized === "emergency" || normalized === "reactive";
}
/**
* Probe video duration from browser metadata. Returns null whenever probing or
* preview decoding fails (e.g. some MOV files) — unknown duration proceeds to the
* server, which enforces the cap at commit. Temporary preview URL is always revoked.
*/
export function probeVideoDurationSeconds(file: File): Promise<number | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
let settled = false;
const finish = (value: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
URL.revokeObjectURL(url);
video.removeAttribute("src");
resolve(value);
};
const timer = setTimeout(() => finish(null), 4_000);
video.preload = "metadata";
video.addEventListener("loadedmetadata", () => {
const duration = video.duration;
finish(Number.isFinite(duration) && duration > 0 ? duration : null);
});
video.addEventListener("error", () => finish(null));
video.src = url;
});
}
function defaultLimits(limits?: Partial<MediaUploadLimits>): MediaUploadLimits {
return {
photoMaxBytes: limits?.photoMaxBytes ?? EMERGENCY_PHOTO_MAX_BYTES,
videoMaxBytes: limits?.videoMaxBytes ?? EMERGENCY_VIDEO_MAX_BYTES,
videoMaxSeconds: limits?.videoMaxSeconds ?? EMERGENCY_VIDEO_MAX_SECONDS,
maxPhotos: limits?.maxPhotos ?? 10,
maxVideos: limits?.maxVideos ?? 3,
};
}
/**
* 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.
*/
export async function validateMediaFileAgainstLimits(
file: File,
policy: Pick<MediaUploadPolicy, "limits" | "remaining">,
probe: MediaValidationProbe = probeVideoDurationSeconds,
): Promise<MediaValidationResult> {
const limits = defaultLimits(policy.limits);
const kind = classifyMediaKind(file);
if (!kind) {
return {
ok: false,
reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
};
}
if (!isMediaSizeWithinLimit(file, kind, limits)) {
return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) };
}
const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos;
if (typeof remaining === "number" && remaining <= 0) {
return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) };
}
if (kind === "Video") {
const seconds = await probe(file);
if (seconds != null && seconds > limits.videoMaxSeconds) {
return {
ok: false,
reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`,
};
}
}
return { ok: true };
}

View file

@ -24,6 +24,7 @@ export const queryKeys = {
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", id] as const,
boardMedia: (id: string | number) => [...queryKeys.workOrders.all, "boardMedia", id] as const,
mediaPolicy: (id: string | number) => [...queryKeys.workOrders.all, "mediaPolicy", id] as const,
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
taskTemplates: () => [...queryKeys.workOrders.all, "taskTemplates"] as const,
dispatchers: () => [...queryKeys.workOrders.all, "dispatchers"] as const,
@ -159,5 +160,7 @@ export const queryKeys = {
dispatches: (token: string) => [...queryKeys.vendorPortal.all, "dispatches", token] as const,
dispatch: (token: string, id: string | number) =>
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
media: (token: string, id: string | number) =>
[...queryKeys.vendorPortal.all, "media", token, id] as const,
},
} as const;

View file

@ -0,0 +1,511 @@
// @vitest-environment node
// Node's fetch/Request/Blob follow WHATWG body-serialization rules; jsdom mangles raw
// chunk bodies (the same reason api-formdata-content-type.test.ts runs in node).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
chunkCountFor,
missingChunkIndices,
runMediaUploadQueue,
uploadFileResumable,
} from "@/api/media-upload-engine";
import { computeSha256Hex, createMediaUploadEndpoints } from "@/api/media-upload-client";
type CapturedRequest = { method: string; url: string; headers: Headers; body: unknown };
const BASE = "https://api.test/uploads";
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
function fileOf(size: number, name = "clip.mp4", type = "video/mp4"): File {
return new File([new Uint8Array(size)], name, { type });
}
let captured: CapturedRequest[] = [];
let handler: (request: CapturedRequest) => Promise<Response>;
function endpointsWith() {
return createMediaUploadEndpoints({
resolveUrl: (suffix) => (suffix ? `${BASE}/${suffix}` : BASE),
headers: () => ({}),
});
}
beforeEach(() => {
captured = [];
handler = async () => jsonResponse({});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const body =
request.method === "PUT"
? await request.arrayBuffer()
: await request.json().catch(() => ({}));
const capturedRequest: CapturedRequest = {
method: request.method,
url: request.url,
headers: request.headers,
body,
};
captured.push(capturedRequest);
return handler(capturedRequest);
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function createdSession(sizeBytes: number, chunkSizeBytes: number) {
return {
uploadId: "u1",
state: "Uploading",
chunkSizeBytes,
sizeBytes,
receivedChunks: [],
expiresAt: "2026-09-24T00:00:00Z",
};
}
function completedSession(sizeBytes: number, chunkSizeBytes: number, received: number[]) {
return {
uploadId: "u1",
state: "Completed",
chunkSizeBytes,
sizeBytes,
receivedChunks: received,
expiresAt: "2026-09-24T00:00:00Z",
attachment: { id: 42 },
};
}
describe("resumable media upload engine", () => {
it("sends raw chunk bytes with Content-Range, digest header, and acknowledged-only progress", async () => {
const file = fileOf(20);
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(20, 8));
}
const match = /chunks\/(\d+)$/.exec(request.url);
if (request.method === "PUT" && match) {
const index = Number(match[1]);
const received = [0, 1, 2].filter((seen) => seen <= index);
return jsonResponse({
index,
sha256: "server-digest",
sizeBytes: (request.body as ArrayBuffer).byteLength,
receivedChunks: received,
});
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse(completedSession(20, 8, [0, 1, 2]));
}
return jsonResponse({});
};
const percents: number[] = [];
const session = await uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
onProgress: percents.push.bind(percents) as (percent: number) => void,
});
expect(session.state).toBe("Completed");
expect(session.attachment?.id).toBe(42);
const puts = captured.filter((entry) => entry.method === "PUT");
expect(puts).toHaveLength(3);
const firstSlice = file.slice(0, 8);
const expectedDigest = await computeSha256Hex(await firstSlice.arrayBuffer());
expect(puts[0]?.url).toBe(`${BASE}/u1/chunks/0`);
expect(puts[0]?.headers.get("content-range")).toBe("bytes 0-7/20");
expect(puts[0]?.headers.get("x-chunk-sha256")).toBe(expectedDigest);
expect((puts[0]?.body as ArrayBuffer).byteLength).toBe(8);
expect(puts[2]?.headers.get("content-range")).toBe("bytes 16-19/20");
expect((puts[2]?.body as ArrayBuffer).byteLength).toBe(4);
// Progress derives from acknowledged chunks only (server receivedChunks), ending at 100.
expect(percents[percents.length - 1]).toBe(100);
expect(percents.every((percent, index) => index === 0 || percent >= percents[index - 1]!)).toBe(
true,
);
});
it("recovers from network loss by re-reading session status and sending only missing chunks", async () => {
const file = fileOf(24);
let failedIndexOnce = false;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(24, 8));
}
const match = /chunks\/(\d+)$/.exec(request.url);
if (request.method === "PUT" && match) {
const index = Number(match[1]);
if (index === 1 && !failedIndexOnce) {
failedIndexOnce = true;
throw new TypeError("network down");
}
const received = Array.from({ length: index + 1 }, (_, seen) => seen);
return jsonResponse({ index, sha256: "x", sizeBytes: 8, receivedChunks: received });
}
if (request.method === "GET" && request.url === `${BASE}/u1`) {
return jsonResponse({ ...createdSession(24, 8), receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse(completedSession(24, 8, [0, 1, 2]));
}
return jsonResponse({});
};
const session = await uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
});
expect(session.state).toBe("Completed");
const putIndices = captured
.filter((entry) => entry.method === "PUT")
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
expect(putIndices).toEqual([0, 1, 1, 2]);
expect(captured.some((entry) => entry.method === "GET" && entry.url === `${BASE}/u1`)).toBe(
true,
);
});
it("stops retrying a persistently failing chunk after bounded status recovery", async () => {
const file = fileOf(8);
let puts = 0;
let statusReads = 0;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(8, 8));
}
if (request.method === "PUT") {
puts += 1;
throw new TypeError("network down");
}
if (request.method === "GET" && request.url === `${BASE}/u1`) {
statusReads += 1;
return jsonResponse(createdSession(8, 8));
}
return jsonResponse({});
};
await expect(
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }),
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
expect(puts).toBe(3);
expect(statusReads).toBe(3);
});
it("recovers a lost commit response from Completed status without a duplicate commit", async () => {
const file = fileOf(8);
let commits = 0;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(8, 8));
}
if (request.method === "PUT") {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
commits += 1;
throw new TypeError("commit response lost");
}
if (request.method === "GET" && request.url === `${BASE}/u1`) {
return jsonResponse(completedSession(8, 8, [0]));
}
return jsonResponse({});
};
const result = await uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
});
expect(result.state).toBe("Completed");
expect(result.attachment?.id).toBe(42);
expect(commits).toBe(1);
expect(captured.filter((entry) => entry.method === "GET")).toHaveLength(1);
});
it("retries the same idempotent session and sends only chunks missing after partial failure", async () => {
const file = fileOf(16);
const idempotencyKey = "11111111-2222-3333-4444-555555555555";
let createCount = 0;
let chunkOneFailures = 0;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
createCount += 1;
return jsonResponse({
...createdSession(16, 8),
receivedChunks: createCount === 1 ? [] : [0],
});
}
if (request.method === "PUT" && request.url.endsWith("/chunks/0")) {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "PUT" && request.url.endsWith("/chunks/1")) {
chunkOneFailures += 1;
if (chunkOneFailures <= 3) throw new TypeError("network down");
return jsonResponse({ index: 1, sha256: "x", sizeBytes: 8, receivedChunks: [0, 1] });
}
if (request.method === "GET" && request.url === `${BASE}/u1`) {
return jsonResponse({ ...createdSession(16, 8), receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse(completedSession(16, 8, [0, 1]));
}
return jsonResponse({});
};
await expect(
uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
idempotencyKey,
}),
).rejects.toMatchObject({ name: "MediaUploadNetworkError" });
const resumed = await uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
idempotencyKey,
});
expect(resumed.state).toBe("Completed");
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
expect(creates).toHaveLength(2);
expect(
creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey),
).toEqual([idempotencyKey, idempotencyKey]);
const putIndices = captured
.filter((entry) => entry.method === "PUT")
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
expect(putIndices.filter((index) => index === 0)).toEqual([0]);
expect(putIndices.at(-1)).toBe(1);
});
it("retries a failed create with the same idempotency key and never re-creates after chunk loss", async () => {
const file = fileOf(8);
let createAttempts = 0;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
createAttempts += 1;
if (createAttempts === 1) {
throw new TypeError("network down");
}
return jsonResponse(createdSession(8, 8));
}
const match = /chunks\/(\d+)$/.exec(request.url);
if (request.method === "PUT" && match) {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse(completedSession(8, 8, [0]));
}
return jsonResponse({});
};
await uploadFileResumable({ file, endpoints: endpointsWith(), surface: "CompletionMedia" });
const creates = captured.filter((entry) => entry.method === "POST" && entry.url === BASE);
expect(creates).toHaveLength(2);
const keys = creates.map((entry) => (entry.body as { idempotencyKey: string }).idempotencyKey);
expect(keys[0]).toBe(keys[1]);
expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/);
const firstBody = creates[0]?.body as Record<string, unknown>;
expect(firstBody).toMatchObject({
fileName: "clip.mp4",
contentType: "video/mp4",
sizeBytes: 8,
surface: "CompletionMedia",
});
expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength(
2,
);
});
it("aborts the in-flight chunk and DELETEs the session on cancellation", async () => {
const file = fileOf(16);
const controller = new AbortController();
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(16, 8));
}
const match = /chunks\/(\d+)$/.exec(request.url);
if (request.method === "PUT" && match) {
if (Number(match[1]) === 0) {
controller.abort();
}
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
return jsonResponse({});
};
const pending = uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
signal: controller.signal,
});
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe(
true,
);
});
it("uploads a queue with at most two files in flight", async () => {
let inFlight = 0;
let peak = 0;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return jsonResponse(createdSession(8, 8));
}
const match = /chunks\/(\d+)$/.exec(request.url);
if (request.method === "PUT" && match) {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse(completedSession(8, 8, [0]));
}
return jsonResponse({});
};
const files = [fileOf(8, "a.mp4"), fileOf(8, "b.mp4"), fileOf(8, "c.mp4")];
await runMediaUploadQueue(
files,
(file) =>
uploadFileResumable({ file, endpoints: endpointsWith(), surface: "WorkOrderTable" }).then(
() => undefined,
),
2,
);
expect(peak).toBe(2);
expect(captured.filter((entry) => entry.method === "POST" && entry.url === BASE)).toHaveLength(
3,
);
});
it("polls a 202 scanning commit until Completed and rejects terminal Rejected state", async () => {
const file = fileOf(8);
let statusCalls = 0;
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(8, 8));
}
const match = /chunks\/(\d+)$/.exec(request.url);
if (request.method === "PUT" && match) {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse(
{ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] },
202,
);
}
if (request.method === "GET" && request.url === `${BASE}/u1`) {
statusCalls += 1;
if (statusCalls === 1) {
return jsonResponse({ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] });
}
return jsonResponse(completedSession(8, 8, [0]));
}
return jsonResponse({});
};
const session = await uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
pollDelayMs: 0,
});
expect(session.state).toBe("Completed");
expect(statusCalls).toBe(2);
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(8, 8));
}
if (request.method === "PUT" && /chunks\/0$/.test(request.url)) {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
return jsonResponse({ ...createdSession(8, 8), state: "Scanning" }, 202);
}
if (request.method === "GET" && request.url === `${BASE}/u1`) {
return jsonResponse({ ...createdSession(8, 8), state: "Rejected" });
}
return jsonResponse({});
};
await expect(
uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
pollDelayMs: 0,
}),
).rejects.toThrow("rejected by the security scan");
});
it("DELETEs the session when cancellation aborts scan polling", async () => {
const file = fileOf(8);
const controller = new AbortController();
handler = async (request) => {
if (request.method === "POST" && request.url === BASE) {
return jsonResponse(createdSession(8, 8));
}
if (request.method === "PUT") {
return jsonResponse({ index: 0, sha256: "x", sizeBytes: 8, receivedChunks: [0] });
}
if (request.method === "POST" && request.url.endsWith("/commit")) {
setTimeout(() => controller.abort(), 0);
return jsonResponse(
{ ...createdSession(8, 8), state: "Scanning", receivedChunks: [0] },
202,
);
}
return jsonResponse({});
};
await expect(
uploadFileResumable({
file,
endpoints: endpointsWith(),
surface: "WorkOrderTable",
signal: controller.signal,
pollDelayMs: 5,
}),
).rejects.toMatchObject({ name: "AbortError" });
expect(captured.some((entry) => entry.method === "DELETE" && entry.url === `${BASE}/u1`)).toBe(
true,
);
});
});
describe("chunk math helpers", () => {
it("computes chunk counts and missing indices", () => {
expect(chunkCountFor(0, 5_242_880)).toBe(1);
expect(chunkCountFor(5_242_880, 5_242_880)).toBe(1);
expect(chunkCountFor(5_242_881, 5_242_880)).toBe(2);
expect(missingChunkIndices([0, 2], 4)).toEqual([1, 3]);
expect(missingChunkIndices([], 0)).toEqual([]);
});
});

View file

@ -39,6 +39,27 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
}));
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 },
},
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
useResumableWorkOrderMediaUpload: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function baseRow(): WorkOrderTableRow {
return {
id: 7,
@ -159,4 +180,16 @@ describe("MediaUploadModal API wiring", () => {
workOrderVersion: "DETAIL-VER==",
});
});
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(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
fireEvent.change(input, {
target: { files: [new File(["x"], "photo.jpg", { type: "image/jpeg" })] },
});
expect(uploadMutateAsync).not.toHaveBeenCalled();
});
});

View file

@ -19,6 +19,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
useWorkOrderMediaPolicy: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
useResumableWorkOrderMediaUpload: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch).
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({

View file

@ -1,9 +1,20 @@
import { act, renderHook } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
useWorkOrderMediaPolicy: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
useResumableWorkOrderMediaUpload: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 100,
@ -327,4 +338,67 @@ describe("useSlideOverMediaState completion filter", () => {
"pdf",
]);
});
it("blocks legacy completion upload while Emergency media policy is unavailable", async () => {
const legacyUpload = idleMutation();
const { result } = renderHook(() =>
useSlideOverMediaState({
detailForRow: detailWithMedia([], { type: "Emergency" }),
uploadMediaMutation: legacyUpload,
deleteMediaMutation: idleMutation(),
categorizeMediaMutation: idleMutation(),
}),
);
act(() => {
result.current.uploadMediaRequests([
{
localId: "local-emergency",
file: new File(["x"], "photo.jpg", { type: "image/jpeg" }),
onProgress: vi.fn(),
},
]);
});
await waitFor(() => {
expect(legacyUpload.mutateAsync).not.toHaveBeenCalled();
expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe(
true,
);
});
});
it("reuses the same idempotency key when a failed media tile is retried", async () => {
const mutateAsync = vi
.fn()
.mockRejectedValueOnce(new Error("network unavailable"))
.mockResolvedValue({ id: "4" });
const uploadMutation = {
mutate: vi.fn(),
mutateAsync,
isPending: false,
} as never;
const { result } = renderHook(() =>
useSlideOverMediaState({
detailForRow: detailWithMedia([], { type: "PM" }),
uploadMediaMutation: uploadMutation,
deleteMediaMutation: idleMutation(),
categorizeMediaMutation: idleMutation(),
}),
);
const file = new File(["photo"], "photo.jpg", { type: "image/jpeg" });
act(() => {
result.current.uploadMediaRequests([{ localId: "local-retry", file, onProgress: vi.fn() }]);
});
await waitFor(() => expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed"));
act(() => result.current.retryUpload("local-retry"));
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(2));
expect(mutateAsync.mock.calls[0]?.[0].file).toBe(file);
expect(mutateAsync.mock.calls[1]?.[0].file).toBe(file);
expect(mutateAsync.mock.calls[1]?.[0].idempotencyKey).toBe(
mutateAsync.mock.calls[0]?.[0].idempotencyKey,
);
});
});

View file

@ -21,6 +21,17 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
useWorkOrderMediaPolicy: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
useResumableWorkOrderMediaUpload: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),

View file

@ -0,0 +1,113 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as mediaUploadEngine from "@/api/media-upload-engine";
import { MediaUploadNetworkError } from "@/api/media-upload-client";
import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
import type { VendorPortalMedia } from "@/domain/vendor-portal/types/vendor-portal";
import { VendorMediaSection } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
const MEDIA: VendorPortalMedia = {
policy: {
workOrderType: "Emergency",
canUpload: true,
limits: {
photoMaxBytes: 10_000_000,
videoMaxBytes: 100_000_000,
videoMaxSeconds: 90,
maxPhotos: 10,
maxVideos: 3,
},
remaining: { photos: 9, videos: 3 },
},
items: [],
};
function renderVendorMedia() {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<VendorMediaSection token="vendor-token" dispatchId={9} locked={false} />
</QueryClientProvider>,
);
}
describe("Vendor media controls", () => {
beforeEach(() => {
vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue(MEDIA);
vi.spyOn(vendorPortalMediaApi, "endpoints").mockReturnValue({} as never);
vi.spyOn(mediaLimits, "validateMediaFileAgainstLimits").mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries the same file with the same idempotency key after a partial failure", async () => {
const upload = vi
.spyOn(mediaUploadEngine, "uploadFileResumable")
.mockRejectedValueOnce(new MediaUploadNetworkError())
.mockResolvedValue({
uploadId: "upload-1",
state: "Completed",
chunkSizeBytes: 5_242_880,
sizeBytes: 5,
receivedChunks: [0],
expiresAt: "2026-09-24T00:00:00Z",
attachment: { id: 1 },
});
renderVendorMedia();
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(),
);
fireEvent.change(screen.getByLabelText("Choose photos or videos"), {
target: { files: [file] },
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Retry upload" })).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: "Retry upload" }));
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2));
expect(upload.mock.calls[0]?.[0].file).toBe(file);
expect(upload.mock.calls[1]?.[0].file).toBe(file);
expect(upload.mock.calls[1]?.[0].idempotencyKey).toBe(upload.mock.calls[0]?.[0].idempotencyKey);
});
it("aborts the active vendor upload from the visible Cancel control", async () => {
const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal?.addEventListener(
"abort",
() => reject(new DOMException("Aborted", "AbortError")),
{
once: true,
},
);
}),
);
renderVendorMedia();
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Upload photo or video" })).toBeEnabled(),
);
fireEvent.change(screen.getByLabelText("Choose photos or videos"), {
target: { files: [file] },
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Cancel upload" })).toBeInTheDocument(),
);
const signal = upload.mock.calls[0]?.[0].signal;
fireEvent.click(screen.getByRole("button", { name: "Cancel upload" }));
await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument());
expect(signal?.aborted).toBe(true);
});
});

View file

@ -0,0 +1,162 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const API_URL = "http://api.test/api";
let captured: { method: string; url: string; headers: Headers; body: string }[] = [];
const MEDIA_PAYLOAD = {
policy: {
workOrderType: "Emergency",
canUpload: true,
limits: {
photoMaxBytes: 10_000_000,
videoMaxBytes: 100_000_000,
videoMaxSeconds: 90,
maxPhotos: 10,
maxVideos: 3,
},
remaining: { photos: 9, videos: 3 },
},
items: [
{
id: 5,
originalFileName: "before.jpg",
contentType: "image/jpeg",
sizeBytes: 2048,
kind: "Photo",
category: "Extra",
scanStatus: "Passed",
createdAt: "2026-09-23T10:00:00Z",
canView: true,
},
],
};
beforeEach(() => {
captured = [];
vi.resetModules();
vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } }));
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
captured.push({
method: request.method,
url: request.url,
headers: request.headers,
body: await request.text(),
});
return new Response(JSON.stringify({ data: MEDIA_PAYLOAD }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastRequest() {
const request = captured[captured.length - 1];
if (!request) throw new Error("No request reached fetch");
return request;
}
describe("vendor portal media API", () => {
it("lists media with policy and scan-passed items using X-Vendor-Token only", async () => {
const { vendorPortalMediaApi } =
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
const media = await vendorPortalMediaApi.getMedia("portal-token", 9);
const request = lastRequest();
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media`);
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
expect(request.headers.get("authorization")).toBeNull();
expect(media.policy.canUpload).toBe(true);
expect(media.items[0]).toMatchObject({
id: 5,
kind: "Photo",
scanStatus: "Passed",
canView: true,
});
});
it("creates chunked upload sessions on the vendor uploads base with the exact DTO", async () => {
const { vendorPortalMediaApi } =
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
await vendorPortalMediaApi.endpoints("portal-token", 9).create({
idempotencyKey: "99999999-8888-7777-6666-555555555555",
fileName: "after.mp4",
contentType: "video/mp4",
sizeBytes: 2048,
surface: "VendorPortal",
});
const request = lastRequest();
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads`);
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
expect(request.headers.get("authorization")).toBeNull();
expect(JSON.parse(request.body)).toEqual({
idempotencyKey: "99999999-8888-7777-6666-555555555555",
fileName: "after.mp4",
contentType: "video/mp4",
sizeBytes: 2048,
surface: "VendorPortal",
});
});
it("sends chunk, commit, status and cancel calls with the vendor token", async () => {
const { vendorPortalMediaApi } =
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
const endpoints = vendorPortalMediaApi.endpoints("portal-token", 9);
await endpoints.putChunk({
uploadId: "u3",
index: 0,
blob: new Blob([new Uint8Array(2)]),
start: 0,
end: 2,
totalBytes: 2,
sha256: "ff00",
});
expect(lastRequest().url).toBe(
`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/chunks/0`,
);
await endpoints.commit("u3");
expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3/commit`);
await endpoints.getStatus("u3");
expect(lastRequest().url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`);
await endpoints.cancel("u3");
expect(lastRequest()).toMatchObject({
method: "DELETE",
url: `${API_URL}/vendor-portal/dispatches/9/media/uploads/u3`,
});
expect(lastRequest().headers.get("x-vendor-token")).toBe("portal-token");
});
it("fetches media content only on demand from the content route", async () => {
const { vendorPortalMediaApi } =
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
const blob = await vendorPortalMediaApi.fetchContent("portal-token", 9, 5);
const request = lastRequest();
expect(request.url).toBe(`${API_URL}/vendor-portal/dispatches/9/media/5/content`);
expect(request.headers.get("x-vendor-token")).toBe("portal-token");
expect(blob).toBeInstanceOf(Blob);
});
it("never exposes object URLs or storage endpoints in the media payload", async () => {
const { vendorPortalMediaApi } =
await import("@/domain/vendor-portal/api/vendor-portal-media-api");
const media = await vendorPortalMediaApi.getMedia("portal-token", 9);
const serialized = JSON.stringify(media);
expect(serialized).not.toContain("blob:");
expect(serialized).not.toContain("https://storage");
});
});

View file

@ -0,0 +1,162 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const API_URL = "http://api.test/api";
let captured: { method: string; url: string; headers: Headers; body: string }[] = [];
beforeEach(() => {
captured = [];
vi.resetModules();
vi.doMock("@/lib/env", () => ({ env: { apiUrl: API_URL } }));
vi.doMock("@/lib/auth/auth-storage", () => ({ getAccessToken: () => "Bearer test-token" }));
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
captured.push({
method: request.method,
url: request.url,
headers: request.headers,
body: await request.text(),
});
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastRequest() {
const request = captured[captured.length - 1];
if (!request) throw new Error("No request reached fetch");
return request;
}
describe("dispatcher resumable upload API", () => {
it("fetches the Emergency/Reactive media policy from the documented route", async () => {
const { workOrdersMediaUploadApi } =
await import("@/domain/work-orders/api/work-order-media-upload-api");
await workOrdersMediaUploadApi.getMediaPolicy(7);
const request = lastRequest();
expect(request.url).toBe(`${API_URL}/workorders/7/media/policy`);
expect(request.method).toBe("GET");
expect(request.headers.get("authorization")).toBe("Bearer test-token");
});
it("creates sessions on the work-order-bound uploads base with the exact DTO shape", async () => {
const { workOrdersMediaUploadApi } =
await import("@/domain/work-orders/api/work-order-media-upload-api");
await workOrdersMediaUploadApi.endpoints(10).create({
idempotencyKey: "11111111-2222-3333-4444-555555555555",
fileName: "photo.jpg",
contentType: "image/jpeg",
sizeBytes: 12_345,
surface: "WorkOrderTable",
});
const request = lastRequest();
expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads`);
expect(request.method).toBe("POST");
expect(request.headers.get("authorization")).toBe("Bearer test-token");
expect(request.headers.get("content-type")).toBe("application/json");
expect(JSON.parse(request.body)).toEqual({
idempotencyKey: "11111111-2222-3333-4444-555555555555",
fileName: "photo.jpg",
contentType: "image/jpeg",
sizeBytes: 12_345,
surface: "WorkOrderTable",
});
});
it("omits category when not provided and sends it for explicit categories", async () => {
const { workOrdersMediaUploadApi } =
await import("@/domain/work-orders/api/work-order-media-upload-api");
const endpoints = workOrdersMediaUploadApi.endpoints(10);
await endpoints.create({
idempotencyKey: "11111111-2222-3333-4444-555555555556",
fileName: "a.jpg",
contentType: "image/jpeg",
sizeBytes: 5,
category: "Aveta",
surface: "CompletionMedia",
});
expect(JSON.parse(lastRequest().body)).toMatchObject({
category: "Aveta",
surface: "CompletionMedia",
});
});
it("PUTs raw chunk bytes with Content-Range and X-Chunk-SHA256 on the session route", async () => {
const { workOrdersMediaUploadApi } =
await import("@/domain/work-orders/api/work-order-media-upload-api");
const blob = new Blob([new Uint8Array(4)]);
await workOrdersMediaUploadApi.endpoints(10).putChunk({
uploadId: "u9",
index: 2,
blob,
start: 10_485_760,
end: 10_485_764,
totalBytes: 10_485_764,
sha256: "ABCD",
signal: undefined,
});
const request = lastRequest();
expect(request.url).toBe(`${API_URL}/workorders/10/media/uploads/u9/chunks/2`);
expect(request.method).toBe("PUT");
expect(request.headers.get("content-range")).toBe("bytes 10485760-10485763/10485764");
expect(request.headers.get("x-chunk-sha256")).toBe("abcd");
expect(request.headers.get("content-type")).toBe("application/octet-stream");
});
it("routes commit, status and cancel under the same session base", async () => {
const { workOrdersMediaUploadApi } =
await import("@/domain/work-orders/api/work-order-media-upload-api");
const endpoints = workOrdersMediaUploadApi.endpoints(10);
await endpoints.commit("u9");
expect(lastRequest()).toMatchObject({ method: "POST" });
expect(lastRequest().url).toBe(`${API_URL}/workorders/10/media/uploads/u9/commit`);
await endpoints.getStatus("u9");
expect(lastRequest()).toMatchObject({
method: "GET",
url: `${API_URL}/workorders/10/media/uploads/u9`,
});
await endpoints.cancel("u9");
expect(lastRequest()).toMatchObject({
method: "DELETE",
url: `${API_URL}/workorders/10/media/uploads/u9`,
});
});
});
describe("protected legacy upload surfaces", () => {
it("keeps the signed completion-doc, media content and Extra Docs routes and limits untouched", async () => {
const { API_PATHS } = await import("@/api/api-paths");
const limits = await import("@/domain/work-orders/utils/work-order-media-limits");
const { validateVendorDocument } =
await import("@/domain/vendor-portal/lib/document-validation");
expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content");
expect(API_PATHS.workOrder.completionDoc(10)).toBe("workorders/10/completion-doc");
expect(limits.MAX_WORK_ORDER_COMPLETION_DOC_BYTES).toBe(50_000_000);
expect(limits.MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
// Vendor completion/uplift documents keep the 10,485,760-byte legacy limit.
const legacyPdf = { size: 10 * 1024 * 1024, type: "application/pdf", name: "doc.pdf" } as File;
const oversizedPdf = {
size: 10 * 1024 * 1024 + 1,
type: "application/pdf",
name: "doc.pdf",
} as File;
expect(validateVendorDocument(legacyPdf)).toBeUndefined();
expect(validateVendorDocument(oversizedPdf)).toBeTruthy();
});
});

View file

@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import {
classifyMediaKind,
emergencyMediaAcceptAttribute,
isEmergencyReactiveWorkOrderType,
mediaKindLimitReachedNotice,
mediaOverLimitNotice,
probeVideoDurationSeconds,
validateMediaFileAgainstLimits,
} from "@/domain/work-orders/utils/emergency-media-limits";
const BASE_POLICY = {
limits: {
photoMaxBytes: 10_000_000,
videoMaxBytes: 100_000_000,
videoMaxSeconds: 90,
maxPhotos: 10,
maxVideos: 3,
},
remaining: { photos: 10, videos: 3 },
};
function fileOf(size: number, name: string, type: string): File {
return new File([new Uint8Array(size)], name, { type });
}
const nullProbe = async () => null;
describe("Emergency/Reactive media limits", () => {
it("classifies photos including HEIC and videos MP4/MOV, rejecting other kinds", () => {
expect(classifyMediaKind(fileOf(1, "a.heic", "image/heic"))).toBe("Photo");
expect(classifyMediaKind(fileOf(1, "a.jpg", ""))).toBe("Photo");
expect(classifyMediaKind(fileOf(1, "a.mov", "video/quicktime"))).toBe("Video");
expect(classifyMediaKind(fileOf(1, "a.gif", "image/gif"))).toBeNull();
expect(classifyMediaKind(fileOf(1, "b.txt", "text/plain"))).toBeNull();
});
it("applies size limits inclusively — at the cap passes, one byte over fails", async () => {
const photoAtCap = fileOf(10_000_000, "a.jpg", "image/jpeg");
const photoOver = fileOf(10_000_001, "a.jpg", "image/jpeg");
expect((await validateMediaFileAgainstLimits(photoAtCap, BASE_POLICY, nullProbe)).ok).toBe(
true,
);
const over = await validateMediaFileAgainstLimits(photoOver, BASE_POLICY, nullProbe);
expect(over).toMatchObject({ ok: false });
const videoAtCap = fileOf(100_000_000, "v.mp4", "video/mp4");
const videoOver = fileOf(100_000_001, "v.mp4", "video/mp4");
expect((await validateMediaFileAgainstLimits(videoAtCap, BASE_POLICY, nullProbe)).ok).toBe(
true,
);
expect((await validateMediaFileAgainstLimits(videoOver, BASE_POLICY, nullProbe)).ok).toBe(
false,
);
});
it("enforces the video duration cap only when the duration can be probed", async () => {
const longVideo = fileOf(1_000, "v.mp4", "video/mp4");
expect((await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 90)).ok).toBe(
true,
);
expect(
(await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 90.5)).ok,
).toBe(false);
expect((await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => 91)).ok).toBe(
false,
);
// MOV metadata probing fails — stays eligible, the server enforces at commit.
expect(
(await validateMediaFileAgainstLimits(longVideo, BASE_POLICY, async () => null)).ok,
).toBe(true);
});
it("rejects files when the policy remaining count is exhausted", async () => {
const policy = { ...BASE_POLICY, remaining: { photos: 0, videos: 3 } };
const result = await validateMediaFileAgainstLimits(
fileOf(10, "a.jpg", "image/jpeg"),
policy,
nullProbe,
);
expect(result.ok).toBe(false);
});
it("renders a standard safe over-limit notice without raw payloads", async () => {
const notice = mediaOverLimitNotice(
fileOf(100_000_001, "big.mov", "video/quicktime"),
"Video",
BASE_POLICY.limits,
);
expect(notice).toContain("big.mov");
expect(notice).toContain("100 MB");
expect(notice).toContain("90 seconds");
expect(mediaKindLimitReachedNotice("Photo", BASE_POLICY.limits)).toContain("10");
});
it("matches only Emergency and Reactive work order types", () => {
expect(isEmergencyReactiveWorkOrderType("Emergency")).toBe(true);
expect(isEmergencyReactiveWorkOrderType("reactive")).toBe(true);
expect(isEmergencyReactiveWorkOrderType("Preventive")).toBe(false);
expect(isEmergencyReactiveWorkOrderType(undefined)).toBe(false);
});
it("offers HEIC in the emergency accept attribute", () => {
const accept = emergencyMediaAcceptAttribute();
expect(accept).toContain("image/heic");
expect(accept).toContain(".mov");
});
it("revokes the preview URL after probing duration, resolving null when the decode fails", async () => {
const createObjectURL = vi.fn(() => "blob:probe");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL });
const media: Record<string, unknown> = {};
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
if (tag === "video") {
const element = originalCreateElement("video");
Object.defineProperty(element, "duration", { value: NaN, configurable: true });
media[element.src as string] = true;
setTimeout(() => {
element.dispatchEvent(new Event("error"));
}, 0);
return element;
}
return originalCreateElement(tag);
}) as typeof document.createElement);
const duration = await probeVideoDurationSeconds(fileOf(10, "clip.mov", "video/quicktime"));
expect(duration).toBeNull();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:probe");
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
});