mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
217 lines
8.2 KiB
TypeScript
217 lines
8.2 KiB
TypeScript
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 {
|
|
VendorMediaItem,
|
|
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("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" });
|
|
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(),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Cancel upload" }));
|
|
|
|
await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument());
|
|
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");
|
|
});
|
|
});
|