mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
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.
286 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|