mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
5 commits
6321f176aa
...
7d9d8ed47b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9d8ed47b | ||
|
|
9d310afa75 | ||
|
|
0e8328f91d | ||
|
|
340dd5252b | ||
|
|
f822c2cc9b |
19 changed files with 587 additions and 88 deletions
|
|
@ -136,6 +136,26 @@ async function mockPortalApi(page: Page, options: { sessionError?: boolean } = {
|
|||
fulfill(route, { data: detailState.current }),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches\/\d+\/media$/, (route) =>
|
||||
fulfill(route, {
|
||||
data: {
|
||||
policy: {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
},
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route(/\/api\/vendor-portal\/dispatches(?:\?.*)?$/, (route) =>
|
||||
fulfill(route, { data: listDispatches }),
|
||||
);
|
||||
|
|
|
|||
32
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
32
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -1,5 +1,21 @@
|
|||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const vendorMedia = {
|
||||
policy: {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
},
|
||||
items: [],
|
||||
};
|
||||
|
||||
const INTERNAL_USER = {
|
||||
token: "uplift-approver-token",
|
||||
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
|
|
@ -26,6 +42,10 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
|
|||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
@ -122,6 +142,10 @@ test("vendor can withdraw a pending uplift from the dispatch", async ({ page })
|
|||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
@ -172,6 +196,10 @@ test("vendor receives the no-approval result for a request within the current NT
|
|||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
@ -216,6 +244,10 @@ test("vendor revises a changes-requested uplift using its existing evidence", as
|
|||
await page.route("**/api/vendor-portal/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path.endsWith("/media") && request.method() === "GET") {
|
||||
await fulfillJson(route, vendorMedia);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/session")) {
|
||||
await fulfillJson(route, { id: 9, companyName: "Gateway Plumbing" });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { devices, expect, test, type Page, type Request, type Route } from "@playwright/test";
|
||||
|
||||
// Completion uploads from a mobile-sized viewport must reach the API as
|
||||
// multipart/form-data with a boundary, or ASP.NET cannot bind the [FromForm] file/category.
|
||||
// Signed completion PDFs remain multipart; Emergency/Reactive photos and videos use resumable chunks.
|
||||
test.use({ ...devices["Pixel 7"] });
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
|
|
@ -45,15 +44,21 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
type UploadSessionFixture = {
|
||||
uploadId: string;
|
||||
state: "Created" | "Uploading" | "Completed";
|
||||
chunkSizeBytes: number;
|
||||
sizeBytes: number;
|
||||
receivedChunks: number[];
|
||||
expiresAt: string;
|
||||
attachment?: { id: number };
|
||||
};
|
||||
|
||||
function multipartBoundary(request: Request): string | undefined {
|
||||
const contentType = request.headers()["content-type"] ?? "";
|
||||
return /^multipart\/form-data; boundary=(.+)$/.exec(contentType)?.[1];
|
||||
}
|
||||
|
||||
function multipartFileName(body: string): string | undefined {
|
||||
return /filename="([^"]+)"/.exec(body)?.[1];
|
||||
}
|
||||
|
||||
async function hideQueryDevtools(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
|
|
@ -133,28 +138,84 @@ test.describe("Completion uploads on a mobile viewport", () => {
|
|||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
||||
});
|
||||
|
||||
test("uploads JPG, MP4 and MOV media and categorizes as multipart", async ({ page }) => {
|
||||
test("uploads JPG, MP4 and MOV through acknowledged resumable chunks", async ({ page }) => {
|
||||
const row = boardRow({ id: 72, woNumber: "RX-MEDIA-72", workOrderType: 6 });
|
||||
const media: { id: number; category: number; url: string; fileName: string }[] = [];
|
||||
await mockBoard(page, row);
|
||||
await page.route("**/api/workorders/72/detail", (route) =>
|
||||
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media }),
|
||||
fulfillJson(route, { info: row, completion: { docStatus: "No" }, media: [] }),
|
||||
);
|
||||
const uploads: { contentType: string; body: string }[] = [];
|
||||
await page.route("**/api/workorders/72/media", (route) => {
|
||||
const sessions = new Map<string, UploadSessionFixture>();
|
||||
const creates: Record<string, unknown>[] = [];
|
||||
const chunks: { body: Buffer; headers: Record<string, string> }[] = [];
|
||||
const commits: string[] = [];
|
||||
await page.route("**/api/workorders/72/media/policy", (route) =>
|
||||
fulfillJson(route, {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 10, videos: 3 },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/workorders/72/media/uploads", async (route) => {
|
||||
const request = route.request();
|
||||
const body = request.postDataBuffer()?.toString("latin1") ?? "";
|
||||
uploads.push({ contentType: request.headers()["content-type"] ?? "", body });
|
||||
const fileName = multipartFileName(body) ?? "file";
|
||||
const item = { id: 900 + media.length, category: 3, url: `/Assets/${fileName}`, fileName };
|
||||
media.push(item);
|
||||
return fulfillJson(route, item);
|
||||
const dto = request.postDataJSON() as Record<string, unknown>;
|
||||
creates.push(dto);
|
||||
const uploadId = `media-${creates.length}`;
|
||||
const session: UploadSessionFixture = {
|
||||
uploadId,
|
||||
state: "Created",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: Number(dto.sizeBytes),
|
||||
receivedChunks: [],
|
||||
expiresAt: "2026-08-20T15:00:00.000Z",
|
||||
};
|
||||
sessions.set(uploadId, session);
|
||||
await fulfillJson(route, session);
|
||||
});
|
||||
let categorize: Request | undefined;
|
||||
await page.route("**/api/workorders/72/media/*", (route) => {
|
||||
categorize = route.request();
|
||||
const item = media[0]!;
|
||||
return fulfillJson(route, { ...item, category: 1 });
|
||||
await page.route("**/api/workorders/72/media/uploads/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const match = /\/media\/uploads\/(media-\d+)(?:\/chunks\/(\d+)|\/commit)?$/.exec(path);
|
||||
if (!match) return route.fallback();
|
||||
const uploadId = match[1]!;
|
||||
const index = match[2];
|
||||
const session = sessions.get(uploadId)!;
|
||||
|
||||
if (request.method() === "PUT" && index !== undefined) {
|
||||
const body = request.postDataBuffer() ?? Buffer.alloc(0);
|
||||
chunks.push({ body, headers: request.headers() });
|
||||
session.receivedChunks = [...new Set([...session.receivedChunks, Number(index)])];
|
||||
session.state = "Uploading";
|
||||
await fulfillJson(route, {
|
||||
index: Number(index),
|
||||
sha256: request.headers()["x-chunk-sha256"],
|
||||
sizeBytes: body.length,
|
||||
receivedChunks: session.receivedChunks,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method() === "POST" && path.endsWith("/commit")) {
|
||||
commits.push(uploadId);
|
||||
session.state = "Completed";
|
||||
session.attachment = { id: 900 + commits.length };
|
||||
await fulfillJson(route, session);
|
||||
return;
|
||||
}
|
||||
if (request.method() === "GET") {
|
||||
await fulfillJson(route, session);
|
||||
return;
|
||||
}
|
||||
if (request.method() === "DELETE") {
|
||||
await fulfillJson(route, {});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
const panel = await openCompletionTab(page, row.woNumber);
|
||||
|
|
@ -167,19 +228,22 @@ test.describe("Completion uploads on a mobile viewport", () => {
|
|||
];
|
||||
await (await chooser).setFiles(files);
|
||||
|
||||
await expect.poll(() => uploads.length).toBe(files.length);
|
||||
for (const { name } of files) {
|
||||
const upload = uploads.find((item) => multipartFileName(item.body) === name);
|
||||
expect(upload, `multipart upload for ${name}`).toBeTruthy();
|
||||
expect(upload!.contentType).toMatch(/^multipart\/form-data; boundary=/);
|
||||
}
|
||||
|
||||
await panel.getByRole("combobox", { name: "Category for IMG_0001.jpg" }).selectOption("Before");
|
||||
await expect.poll(() => categorize?.method()).toBe("PATCH");
|
||||
const boundary = multipartBoundary(categorize!);
|
||||
expect(boundary, categorize!.headers()["content-type"]).toBeTruthy();
|
||||
const body = categorize!.postDataBuffer()?.toString("latin1") ?? "";
|
||||
expect(body).toMatch(/name="category"\r\n\r\nBefore/);
|
||||
expect(body).toMatch(new RegExp(`name="workOrderVersion"\\r\\n\\r\\n${ROW_VERSION}`));
|
||||
await expect.poll(() => creates.length).toBe(files.length);
|
||||
await expect.poll(() => chunks.length).toBe(files.length);
|
||||
await expect.poll(() => commits.length).toBe(files.length);
|
||||
expect(creates.map((dto) => dto.surface)).toEqual(
|
||||
Array.from({ length: files.length }, () => "CompletionMedia"),
|
||||
);
|
||||
expect(creates.map((dto) => dto.sizeBytes).sort()).toEqual(
|
||||
files.map((file) => file.buffer.length).sort(),
|
||||
);
|
||||
expect(chunks.map((chunk) => chunk.headers["content-type"])).toEqual(
|
||||
Array.from({ length: files.length }, () => "application/octet-stream"),
|
||||
);
|
||||
expect(chunks.map((chunk) => chunk.body.toString("hex")).sort()).toEqual(
|
||||
files.map((file) => file.buffer.toString("hex")).sort(),
|
||||
);
|
||||
expect([...sessions.values()].every((session) => session.state === "Completed")).toBe(true);
|
||||
await expect(page.getByText("File uploaded").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,8 +33,18 @@ export function isNetworkUploadError(error: unknown): boolean {
|
|||
return error instanceof MediaUploadNetworkError;
|
||||
}
|
||||
|
||||
/** Retryable HTTP failures keep the same client idempotency key/session. */
|
||||
export function isRetryableMediaUploadError(error: unknown): boolean {
|
||||
if (isNetworkUploadError(error)) return true;
|
||||
if (!(error instanceof Error) || error.name !== "MediaUploadHttpError") return false;
|
||||
const status = (error as Error & { status?: unknown }).status;
|
||||
return typeof status === "number" && status >= 500 && status <= 599;
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
export async function computeSha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type SlideOverCompletionMediaProps = {
|
|||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
};
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ export function SlideOverCompletionMedia({
|
|||
typeErrorMessage,
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
}: SlideOverCompletionMediaProps) {
|
||||
return (
|
||||
|
|
@ -64,6 +66,7 @@ export function SlideOverCompletionMedia({
|
|||
typeErrorMessage={typeErrorMessage}
|
||||
acceptAttribute={acceptAttribute}
|
||||
fileSizeError={fileSizeError}
|
||||
batchFileError={batchFileError}
|
||||
uploadHint={uploadHint}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type SlideOverCompletionTabProps = {
|
|||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
onUploadCompletionPdf?: (file: File) => void;
|
||||
onOpenCompletionDoc: () => void;
|
||||
|
|
@ -59,6 +60,7 @@ export function SlideOverCompletionTab({
|
|||
typeErrorMessage,
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
onUploadCompletionPdf,
|
||||
onOpenCompletionDoc,
|
||||
|
|
@ -85,6 +87,7 @@ export function SlideOverCompletionTab({
|
|||
typeErrorMessage={typeErrorMessage}
|
||||
acceptAttribute={acceptAttribute}
|
||||
fileSizeError={fileSizeError}
|
||||
batchFileError={batchFileError}
|
||||
uploadHint={uploadHint}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
|
|
@ -254,7 +255,7 @@ function hasEmergencyMediaType(...types: Array<string | undefined>): boolean {
|
|||
|
||||
function getEmergencyUploaderOptions(
|
||||
emergencyMedia: boolean,
|
||||
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||
policy?: Parameters<typeof emergencyMediaBatchCountError>[2],
|
||||
) {
|
||||
if (!emergencyMedia) {
|
||||
return {
|
||||
|
|
@ -262,6 +263,7 @@ function getEmergencyUploaderOptions(
|
|||
emergencyMediaTypeErrorMessage: undefined,
|
||||
emergencyMediaAcceptAttribute: undefined,
|
||||
emergencyMediaSizeError: undefined,
|
||||
emergencyMediaBatchFileError: undefined,
|
||||
emergencyMediaUploadHint: undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -270,7 +272,9 @@ function getEmergencyUploaderOptions(
|
|||
emergencyMediaTypeErrorMessage:
|
||||
"Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(),
|
||||
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits),
|
||||
emergencyMediaBatchFileError: (file: File, acceptedFiles: readonly File[]) =>
|
||||
emergencyMediaBatchCountError(file, acceptedFiles, policy),
|
||||
emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
};
|
||||
}
|
||||
|
|
@ -376,7 +380,7 @@ export function useSlideOverMediaState({
|
|||
deleteMediaMutation.isPending,
|
||||
categorizeMediaMutation.isPending,
|
||||
);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy?.limits);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy);
|
||||
|
||||
return {
|
||||
completionMedia,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export function useWorkOrderSlideOver({
|
|||
emergencyMediaTypeErrorMessage,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
emergencyMediaBatchFileError,
|
||||
emergencyMediaUploadHint,
|
||||
uploadMediaRequests,
|
||||
uploadExtraFiles,
|
||||
|
|
@ -189,6 +190,7 @@ export function useWorkOrderSlideOver({
|
|||
emergencyMediaTypeErrorMessage,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
emergencyMediaBatchFileError,
|
||||
emergencyMediaUploadHint,
|
||||
completionPending,
|
||||
closeDisabled,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export function WorkOrderSlideOverTabs({
|
|||
typeErrorMessage={state.emergencyMediaTypeErrorMessage}
|
||||
acceptAttribute={state.emergencyMediaAcceptAttribute}
|
||||
fileSizeError={state.emergencyMediaSizeError}
|
||||
batchFileError={state.emergencyMediaBatchFileError}
|
||||
uploadHint={state.emergencyMediaUploadHint}
|
||||
completionPending={state.completionPending}
|
||||
onUploadMedia={(requests) => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
emergencyMediaSizeError,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
|
|
@ -60,11 +61,13 @@ function emergencyPolicyMessage(
|
|||
|
||||
function getEmergencyUploaderOptions(
|
||||
isEmergency: boolean,
|
||||
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||
policy?: Parameters<typeof emergencyMediaBatchCountError>[2],
|
||||
) {
|
||||
if (!isEmergency) return {};
|
||||
return {
|
||||
fileSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||
fileSizeError: (file: File) => emergencyMediaSizeError(file, policy?.limits),
|
||||
batchFileError: (file: File, acceptedFiles: readonly File[]) =>
|
||||
emergencyMediaBatchCountError(file, acceptedFiles, policy),
|
||||
uploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
typeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||
typeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
|
|
@ -95,7 +98,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy?.limits);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy);
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type MediaUploaderProps = {
|
|||
typeErrorMessage?: string;
|
||||
acceptAttribute?: string;
|
||||
fileSizeError?: (file: File) => string | undefined;
|
||||
batchFileError?: (file: File, acceptedFiles: readonly File[]) => string | undefined;
|
||||
uploadHint?: string;
|
||||
};
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ export function MediaUploader({
|
|||
typeErrorMessage = "Unsupported file type.",
|
||||
acceptAttribute,
|
||||
fileSizeError,
|
||||
batchFileError,
|
||||
uploadHint,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -74,6 +76,11 @@ export function MediaUploader({
|
|||
toast.error(sizeError);
|
||||
continue;
|
||||
}
|
||||
const batchError = batchFileError?.(file, accepted);
|
||||
if (batchError) {
|
||||
toast.error(batchError);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
VendorMediaItem,
|
||||
VendorPortalMedia,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { MediaUploadNetworkError } from "@/api/media-upload-client";
|
||||
import { isRetryableMediaUploadError } from "@/api/media-upload-client";
|
||||
import { uploadFileResumable } from "@/api/media-upload-engine";
|
||||
import { validateMediaFileAgainstLimits } from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
import {
|
||||
|
|
@ -34,10 +34,40 @@ function formatBytes(sizeBytes: number): string {
|
|||
return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`;
|
||||
}
|
||||
|
||||
function viewMediaItem(item: VendorMediaItem, blob: Blob): void {
|
||||
function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
previewWindow.document.title = item.originalFileName;
|
||||
previewWindow.addEventListener("pagehide", () => URL.revokeObjectURL(url), { once: true });
|
||||
previewWindow.location.replace(url);
|
||||
}
|
||||
|
||||
async function openVendorMediaPreview({
|
||||
item,
|
||||
token,
|
||||
dispatchId,
|
||||
onError,
|
||||
}: {
|
||||
item: VendorMediaItem;
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
onError: (message: string | undefined) => void;
|
||||
}): Promise<void> {
|
||||
const previewWindow = window.open("about:blank", "_blank");
|
||||
if (!previewWindow) {
|
||||
onError("Allow pop-ups to view this media file.");
|
||||
return;
|
||||
}
|
||||
previewWindow.opener = null;
|
||||
previewWindow.document.title = item.originalFileName;
|
||||
previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`;
|
||||
onError(undefined);
|
||||
try {
|
||||
const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id);
|
||||
viewMediaItem(item, blob, previewWindow);
|
||||
} catch {
|
||||
previewWindow.close();
|
||||
onError("Unable to open the media file. Check your connection and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function VendorMediaItems({
|
||||
|
|
@ -112,7 +142,7 @@ function VendorUploadError({
|
|||
return (
|
||||
<div>
|
||||
<VendorPortalFeedback tone="error">{safeMediaUploadErrorMessage(error)}</VendorPortalFeedback>
|
||||
{!locked && error instanceof MediaUploadNetworkError && (
|
||||
{!locked && isRetryableMediaUploadError(error) && (
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onRetry}>
|
||||
Retry upload
|
||||
</button>
|
||||
|
|
@ -148,6 +178,7 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
|
|||
const [loadState, setLoadState] = useState<MediaLoadState>({ loading: true });
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const [uploadCanceled, setUploadCanceled] = useState(false);
|
||||
const [mediaActionError, setMediaActionError] = useState<string>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadState({ loading: true });
|
||||
|
|
@ -216,18 +247,6 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -259,7 +278,15 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
|
|||
<VendorPortalFeedback>No photos or videos uploaded yet.</VendorPortalFeedback>
|
||||
)}
|
||||
|
||||
<VendorMediaItems items={items} onView={(item) => void handleView(item)} />
|
||||
<VendorMediaItems
|
||||
items={items}
|
||||
onView={(item) =>
|
||||
void openVendorMediaPreview({ item, token, dispatchId, onError: setMediaActionError })
|
||||
}
|
||||
/>
|
||||
{Boolean(mediaActionError) && (
|
||||
<VendorPortalFeedback tone="error">{mediaActionError}</VendorPortalFeedback>
|
||||
)}
|
||||
<VendorUploadProgress
|
||||
pending={uploadMutation.isPending}
|
||||
progress={progress}
|
||||
|
|
|
|||
|
|
@ -22,16 +22,10 @@ function toUploadCategory(
|
|||
return typeof category === "string" && isUploadCategory(category) ? category : undefined;
|
||||
}
|
||||
|
||||
function invalidateMediaQueries(
|
||||
async 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 });
|
||||
): Promise<void> {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,8 +66,8 @@ export function useResumableWorkOrderMediaUpload(
|
|||
onProgress,
|
||||
});
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
invalidateMediaQueries(queryClient, workOrderId);
|
||||
onSuccess: async (session) => {
|
||||
await invalidateMediaQueries(queryClient);
|
||||
if (session.state === "Completed") {
|
||||
toast.success("File uploaded");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,19 @@ export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUpload
|
|||
return `${kind} limit reached (${max} per work order). Remove a file to add another.`;
|
||||
}
|
||||
|
||||
export function emergencyMediaBatchCountError(
|
||||
file: File,
|
||||
acceptedFiles: readonly File[],
|
||||
policy?: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
||||
): string | undefined {
|
||||
const kind = classifyMediaKind(file);
|
||||
if (!kind || !policy) return undefined;
|
||||
const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos;
|
||||
const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind);
|
||||
if (alreadyAccepted.length < available) return undefined;
|
||||
return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits));
|
||||
}
|
||||
|
||||
export function emergencyMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,4 +288,35 @@ describe("MediaUploadModal API wiring", () => {
|
|||
expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(uploadMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects excess Emergency files in a multi-select batch using remaining policy slots", async () => {
|
||||
mediaQueryState.policy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 1 },
|
||||
};
|
||||
resumableUploadMutateAsync.mockResolvedValue({ id: "uploaded" });
|
||||
renderModal();
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" });
|
||||
const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" });
|
||||
const video = new File(["clip"], "clip.mov", { type: "video/quicktime" });
|
||||
fireEvent.change(input, { target: { files: [photoOne, photoTwo, video] } });
|
||||
|
||||
await waitFor(() => expect(resumableUploadMutateAsync).toHaveBeenCalledTimes(2));
|
||||
expect(resumableUploadMutateAsync.mock.calls.map(([request]) => request.file)).toEqual([
|
||||
photoOne,
|
||||
video,
|
||||
]);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, 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 { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { SlideOverCompletionMedia } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media";
|
||||
|
||||
const policyState = vi.hoisted(() => ({
|
||||
data: undefined as unknown,
|
||||
|
|
@ -460,6 +462,82 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
).toBe('"photo.jpg" exceeds the photo limit (10 MB).');
|
||||
});
|
||||
|
||||
it("exposes per-batch remaining-slot validation to the slide-over uploader", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 1 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const photo = new File(["photo"], "photo.jpg", { type: "image/jpeg" });
|
||||
const video = new File(["video"], "video.mov", { type: "video/quicktime" });
|
||||
|
||||
expect(result.current.emergencyMediaBatchFileError?.(photo, [])).toBeUndefined();
|
||||
expect(result.current.emergencyMediaBatchFileError?.(video, [photo])).toBeUndefined();
|
||||
expect(result.current.emergencyMediaBatchFileError?.(photo, [photo])).toBe(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps multi-select within Reactive remaining slots on the visible slide-over surface", () => {
|
||||
policyState.data = {
|
||||
workOrderType: "Reactive",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 1, videos: 3 },
|
||||
};
|
||||
policyState.isLoading = false;
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detailWithMedia([], { type: "Reactive" }),
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
const onUploadMedia = vi.fn();
|
||||
render(
|
||||
<SlideOverCompletionMedia
|
||||
isReadOnly={false}
|
||||
media={[]}
|
||||
mediaPending={false}
|
||||
onUploadMedia={onUploadMedia}
|
||||
batchFileError={result.current.emergencyMediaBatchFileError}
|
||||
/>,
|
||||
);
|
||||
const photoOne = new File(["one"], "one.jpg", { type: "image/jpeg" });
|
||||
const photoTwo = new File(["two"], "two.jpg", { type: "image/jpeg" });
|
||||
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
||||
target: { files: [photoOne, photoTwo] },
|
||||
});
|
||||
|
||||
expect(onUploadMedia).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
onUploadMedia.mock.calls[0]?.[0].map((request: MediaUploadRequest) => request.file),
|
||||
).toEqual([photoOne]);
|
||||
});
|
||||
|
||||
it("reuses the same idempotency key when a failed media tile is retried", async () => {
|
||||
const mutateAsync = vi
|
||||
.fn()
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@ 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 type { MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||
import { MediaUploadNetworkError } from "@/api/media-upload-client";
|
||||
import type { MediaChunkAckDto } from "@/api/media-upload-contracts";
|
||||
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 type {
|
||||
VendorMediaItem,
|
||||
VendorPortalMedia,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { VendorMediaSection } from "@/app/v/[token]/dispatch/_components/vendor-media-section";
|
||||
|
||||
const MEDIA: VendorPortalMedia = {
|
||||
|
|
@ -79,19 +84,66 @@ describe("Vendor media controls", () => {
|
|||
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,
|
||||
},
|
||||
);
|
||||
}),
|
||||
it("offers same-session retry after a transient HTTP 503", async () => {
|
||||
const unavailable = Object.assign(new Error("Upload request failed (HTTP 503)."), {
|
||||
name: "MediaUploadHttpError",
|
||||
status: 503,
|
||||
});
|
||||
const upload = vi
|
||||
.spyOn(mediaUploadEngine, "uploadFileResumable")
|
||||
.mockRejectedValueOnce(unavailable)
|
||||
.mockResolvedValue({
|
||||
uploadId: "upload-503",
|
||||
state: "Completed",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [0],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 2 },
|
||||
});
|
||||
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[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 cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const endpoints: MediaUploadEndpointSet = {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
uploadId: "upload-cancel",
|
||||
state: "Created",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
}),
|
||||
putChunk: vi.fn(
|
||||
({ signal }: Parameters<MediaUploadEndpointSet["putChunk"]>[0]) =>
|
||||
new Promise<MediaChunkAckDto>((_resolve, reject) => {
|
||||
const abort = () => reject(new DOMException("Aborted", "AbortError"));
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
),
|
||||
commit: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
cancel,
|
||||
};
|
||||
vi.mocked(vendorPortalMediaApi.endpoints).mockReturnValue(endpoints);
|
||||
renderVendorMedia();
|
||||
|
||||
const file = new File(["video"], "clip.mov", { type: "video/quicktime" });
|
||||
|
|
@ -104,10 +156,62 @@ describe("Vendor media controls", () => {
|
|||
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);
|
||||
expect(endpoints.putChunk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(cancel).toHaveBeenCalledWith("upload-cancel"));
|
||||
});
|
||||
|
||||
it("opens media from the View click before fetching and revokes its object URL on close", async () => {
|
||||
const item: VendorMediaItem = {
|
||||
id: 14,
|
||||
originalFileName: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
sizeBytes: 5,
|
||||
kind: "Photo",
|
||||
category: "Extra",
|
||||
scanStatus: "Passed",
|
||||
createdAt: "2026-09-23T00:00:00Z",
|
||||
canView: true,
|
||||
};
|
||||
vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue({
|
||||
...MEDIA,
|
||||
items: [item],
|
||||
});
|
||||
const addEventListener = vi.fn();
|
||||
const previewWindow = {
|
||||
opener: window,
|
||||
closed: false,
|
||||
document: { title: "", body: { textContent: "" } },
|
||||
location: { replace: vi.fn() },
|
||||
addEventListener,
|
||||
close: vi.fn(),
|
||||
} as unknown as Window;
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(previewWindow);
|
||||
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:vendor-media");
|
||||
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
|
||||
const fetchContent = vi
|
||||
.spyOn(vendorPortalMediaApi, "fetchContent")
|
||||
.mockImplementation(async (_token, _dispatchId, mediaId) => {
|
||||
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
|
||||
expect(mediaId).toBe(item.id);
|
||||
return new Blob(["image"], { type: item.contentType });
|
||||
});
|
||||
renderVendorMedia();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "View" }));
|
||||
|
||||
await waitFor(() => expect(fetchContent).toHaveBeenCalledWith("vendor-token", 9, item.id));
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(previewWindow.document.title).toBe(item.originalFileName);
|
||||
expect(previewWindow.location.replace).toHaveBeenCalledWith("blob:vendor-media");
|
||||
const pageHide = addEventListener.mock.calls[0]?.[1] as EventListener | undefined;
|
||||
pageHide?.(new Event("pagehide"));
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:vendor-media");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MediaUploadPolicy, MediaUploadSessionDto } from "@/api/media-upload-contracts";
|
||||
import * as mediaUploadEngine from "@/api/media-upload-engine";
|
||||
import { workOrdersMediaUploadApi } from "@/domain/work-orders/api/work-order-media-upload-api";
|
||||
import * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
|
||||
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";
|
||||
|
||||
const POLICY: MediaUploadPolicy = {
|
||||
workOrderType: "Emergency",
|
||||
canUpload: true,
|
||||
limits: {
|
||||
photoMaxBytes: 10_000_000,
|
||||
videoMaxBytes: 100_000_000,
|
||||
videoMaxSeconds: 90,
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
},
|
||||
remaining: { photos: 2, videos: 3 },
|
||||
};
|
||||
|
||||
const COMPLETED: MediaUploadSessionDto = {
|
||||
uploadId: "upload-1",
|
||||
state: "Completed",
|
||||
chunkSizeBytes: 5_242_880,
|
||||
sizeBytes: 5,
|
||||
receivedChunks: [0],
|
||||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
attachment: { id: 1 },
|
||||
};
|
||||
|
||||
describe("Emergency media upload policy refresh", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("keeps the upload mutation pending until fresh remaining slots arrive", async () => {
|
||||
let resolvePolicyRefresh: ((policy: MediaUploadPolicy) => void) | undefined;
|
||||
const policyRefresh = new Promise<MediaUploadPolicy>((resolve) => {
|
||||
resolvePolicyRefresh = resolve;
|
||||
});
|
||||
const getPolicy = vi
|
||||
.spyOn(workOrdersMediaUploadApi, "getMediaPolicy")
|
||||
.mockResolvedValueOnce(POLICY)
|
||||
.mockReturnValueOnce(policyRefresh);
|
||||
vi.spyOn(workOrdersMediaUploadApi, "endpoints").mockReturnValue({} as never);
|
||||
vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockResolvedValue(COMPLETED);
|
||||
vi.spyOn(mediaLimits, "validateMediaFileAgainstLimits").mockResolvedValue({ ok: true });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const policyQuery = useWorkOrderMediaPolicy(42, true);
|
||||
const upload = useResumableWorkOrderMediaUpload(42, "WorkOrderTable", policyQuery.data);
|
||||
return { policyQuery, upload };
|
||||
},
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.policyQuery.data).toEqual(POLICY));
|
||||
const uploadPromise = result.current.upload.mutateAsync({
|
||||
file: new File(["photo"], "photo.jpg", { type: "image/jpeg" }),
|
||||
});
|
||||
await waitFor(() => expect(getPolicy).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(result.current.upload.isPending).toBe(true);
|
||||
expect(result.current.policyQuery.data?.remaining.photos).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
resolvePolicyRefresh?.({ ...POLICY, remaining: { photos: 1, videos: 3 } });
|
||||
await policyRefresh;
|
||||
await uploadPromise;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.policyQuery.data?.remaining.photos).toBe(1));
|
||||
expect(result.current.upload.isPending).toBe(false);
|
||||
expect(mediaUploadEngine.uploadFileResumable).toHaveBeenCalledOnce();
|
||||
expect(mediaLimits.validateMediaFileAgainstLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "photo.jpg" }),
|
||||
POLICY,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyMediaKind,
|
||||
emergencyMediaBatchCountError,
|
||||
emergencyMediaAcceptAttribute,
|
||||
isEmergencyReactiveWorkOrderType,
|
||||
mediaKindLimitReachedNotice,
|
||||
|
|
@ -81,6 +82,19 @@ describe("Emergency/Reactive media limits", () => {
|
|||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("counts already accepted files in the current batch against remaining slots", () => {
|
||||
const policy = { limits: BASE_POLICY.limits, remaining: { photos: 1, videos: 1 } };
|
||||
const firstPhoto = fileOf(10, "first.jpg", "image/jpeg");
|
||||
const extraPhoto = fileOf(10, "extra.jpg", "image/jpeg");
|
||||
const firstVideo = fileOf(10, "first.mov", "video/quicktime");
|
||||
|
||||
expect(emergencyMediaBatchCountError(firstPhoto, [], policy)).toBeUndefined();
|
||||
expect(emergencyMediaBatchCountError(firstVideo, [firstPhoto], policy)).toBeUndefined();
|
||||
expect(emergencyMediaBatchCountError(extraPhoto, [firstPhoto], policy)).toBe(
|
||||
"Photo limit reached (10 per work order). Remove a file to add another.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a standard safe over-limit notice without raw payloads", async () => {
|
||||
const notice = mediaOverLimitNotice(
|
||||
fileOf(100_000_001, "big.mov", "video/quicktime"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue