shoc-frontend-new/src/test/app/v/vendor-portal-documents.test.tsx
Alexandre Brandizzi 6012ebabe7 fix(media): count HEIC and untyped phone files, guard overlapping picks
Persisted HEIC files and local files with an empty or octet-stream type
were classified as "other", so they did not count toward the 10 photo /
3 video limit. Both classifiers now recognise them by extension.

The Photos & Videos uploader and Extra Docs ignore a new selection while
the previous one is still being screened, and the vendor portal drops an
earlier pick whose video check finishes after a newer pick.

Also removes ticket keys from source comments.
2026-09-24 22:51:17 -03:00

286 lines
9.4 KiB
TypeScript

import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
const quarantinedDocument: VendorPortalDocument = {
id: 12,
originalFileName: "completion.pdf",
contentType: "application/pdf",
sizeBytes: 1024,
scanStatus: "Pending",
reviewStatus: "Processing",
version: 1,
canDownload: false,
};
afterEach(() => {
vi.restoreAllMocks();
});
describe("VendorPortalDocuments", () => {
it("rejects unsupported files before starting an upload", async () => {
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [new File(["text"], "notes.txt", { type: "text/plain" })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
it("keeps pending documents quarantined", () => {
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[quarantinedDocument]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: "Quarantined" })).toBeDisabled();
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByText("Processing")).toBeInTheDocument();
});
it.each([
["animation.gif", "image/gif"],
["clip.webm", "video/webm"],
])("rejects %s (%s), which the server does not accept", async (name, type) => {
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [new File(["x"], name, { type })] },
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
);
expect(upload).not.toHaveBeenCalled();
});
describe("SH-116 media contract", () => {
const MB = 1_000_000;
const renderDocuments = () =>
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const choose = (file: File) =>
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: { files: [file] },
});
it("uploads a ~60 MB phone MOV whose browser type is empty", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderDocuments();
const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File;
choose(video);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0].file).toBe(video);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it.each([
[
{ name: "clip.mp4", type: "video/mp4", size: 101 * MB },
"Videos must be 100 MB or smaller.",
],
[
{ name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB },
"Photos must be 10 MB or smaller.",
],
])("rejects %o with a generic message", async (file, message) => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderDocuments();
choose(file as File);
expect(await screen.findByRole("alert")).toHaveTextContent(message);
expect(upload).not.toHaveBeenCalled();
});
// Three videos already on the work order (dispatcher and vendor uploads together).
const fullCounts = {
maxPhotos: 10,
maxVideos: 3,
photos: 10,
videos: 3,
completionPhotos: 10,
completionVideos: 3,
};
const videoDocument: VendorPortalDocument = {
...quarantinedDocument,
id: 21,
originalFileName: "walkthrough.mp4",
contentType: "video/mp4",
};
it("rejects the 4th video and 11th photo counted across the whole work order", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
mediaCounts={fullCounts}
locked={false}
onChanged={vi.fn()}
/>,
{ withAuth: false },
);
choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File);
expect(await screen.findByRole("alert")).toHaveTextContent(
"A work order can have at most 3 videos.",
);
choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File);
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent(
"A work order can have at most 10 photos.",
),
);
expect(upload).not.toHaveBeenCalled();
});
it("lets a vendor replace one of the counted videos on a full work order", async () => {
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[videoDocument]}
mediaCounts={fullCounts}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File;
choose(replacement);
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 });
});
});
it("reports upload progress and refreshes the dispatch after success", async () => {
const onChanged = vi.fn().mockResolvedValue(undefined);
let finishUpload: (() => void) | undefined;
vi.spyOn(vendorPortalApi, "uploadDocument").mockImplementation(async ({ onProgress }) => {
onProgress(65);
await new Promise<void>((resolve) => {
finishUpload = resolve;
});
return quarantinedDocument;
});
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={onChanged}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByLabelText("Choose completion document"), {
target: {
files: [
new File(["%PDF-1.4"], "completion.pdf", {
type: "application/pdf",
}),
],
},
});
expect(await screen.findByText("Uploading… 65%")).toBeInTheDocument();
finishUpload?.();
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
});
it("drops an earlier pick whose video check finishes after a newer pick", async () => {
let finishProbe: ((seconds: number) => void) | undefined;
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishProbe = resolve;
}),
);
const upload = vi
.spyOn(vendorPortalApi, "uploadDocument")
.mockResolvedValue(quarantinedDocument);
renderWithProviders(
<VendorPortalDocuments
token="portal-token"
dispatchId={7}
documents={[]}
locked={false}
onChanged={vi.fn().mockResolvedValue(undefined)}
/>,
{ withAuth: false },
);
const input = screen.getByLabelText("Choose completion document");
const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
fireEvent.change(input, { target: { files: [video] } });
await waitFor(() => expect(finishProbe).toBeDefined());
fireEvent.change(input, { target: { files: [photo] } });
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
finishProbe?.(30);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(upload).toHaveBeenCalledOnce();
expect(upload.mock.calls[0][0].file).toBe(photo);
});
});