shoc-frontend-new/src/test/app/v/vendor-media-section.test.tsx
2026-09-23 05:40:40 -03:00

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");
});
});