mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix: await media policy refresh before retrying uploads
This commit is contained in:
parent
9d310afa75
commit
7d9d8ed47b
4 changed files with 132 additions and 33 deletions
|
|
@ -41,6 +41,35 @@ function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window)
|
|||
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({
|
||||
items,
|
||||
onView,
|
||||
|
|
@ -218,25 +247,6 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
|
|||
|
||||
const selectFile = (file: File | undefined) => startUpload(file ?? null);
|
||||
|
||||
const handleView = async (item: VendorMediaItem) => {
|
||||
const previewWindow = window.open("about:blank", "_blank");
|
||||
if (!previewWindow) {
|
||||
setMediaActionError("Allow pop-ups to view this media file.");
|
||||
return;
|
||||
}
|
||||
previewWindow.opener = null;
|
||||
previewWindow.document.title = item.originalFileName;
|
||||
previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`;
|
||||
setMediaActionError(undefined);
|
||||
try {
|
||||
const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id);
|
||||
viewMediaItem(item, blob, previewWindow);
|
||||
} catch {
|
||||
previewWindow.close();
|
||||
setMediaActionError("Unable to open the media file. Check your connection and try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const items = loadState.media?.items ?? [];
|
||||
const canUpload = !locked && loadState.media?.policy.canUpload === true;
|
||||
|
||||
|
|
@ -268,7 +278,12 @@ 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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 {
|
||||
|
|
@ -131,8 +132,8 @@ describe("Vendor media controls", () => {
|
|||
expiresAt: "2026-09-24T00:00:00Z",
|
||||
}),
|
||||
putChunk: vi.fn(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
({ 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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue