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);
|
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({
|
function VendorMediaItems({
|
||||||
items,
|
items,
|
||||||
onView,
|
onView,
|
||||||
|
|
@ -218,25 +247,6 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
|
||||||
|
|
||||||
const selectFile = (file: File | undefined) => startUpload(file ?? null);
|
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 items = loadState.media?.items ?? [];
|
||||||
const canUpload = !locked && loadState.media?.policy.canUpload === true;
|
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>
|
<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) && (
|
{Boolean(mediaActionError) && (
|
||||||
<VendorPortalFeedback tone="error">{mediaActionError}</VendorPortalFeedback>
|
<VendorPortalFeedback tone="error">{mediaActionError}</VendorPortalFeedback>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,10 @@ function toUploadCategory(
|
||||||
return typeof category === "string" && isUploadCategory(category) ? category : undefined;
|
return typeof category === "string" && isUploadCategory(category) ? category : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateMediaQueries(
|
async function invalidateMediaQueries(
|
||||||
queryClient: ReturnType<typeof useQueryClient>,
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
workOrderId: string | number,
|
): Promise<void> {
|
||||||
): void {
|
await queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -72,8 +66,8 @@ export function useResumableWorkOrderMediaUpload(
|
||||||
onProgress,
|
onProgress,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (session) => {
|
onSuccess: async (session) => {
|
||||||
invalidateMediaQueries(queryClient, workOrderId);
|
await invalidateMediaQueries(queryClient);
|
||||||
if (session.state === "Completed") {
|
if (session.state === "Completed") {
|
||||||
toast.success("File uploaded");
|
toast.success("File uploaded");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as mediaUploadEngine from "@/api/media-upload-engine";
|
import * as mediaUploadEngine from "@/api/media-upload-engine";
|
||||||
import type { MediaUploadEndpointSet } from "@/api/media-upload-client";
|
import type { MediaUploadEndpointSet } from "@/api/media-upload-client";
|
||||||
import { MediaUploadNetworkError } 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 * as mediaLimits from "@/domain/work-orders/utils/emergency-media-limits";
|
||||||
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
|
import { vendorPortalMediaApi } from "@/domain/vendor-portal/api/vendor-portal-media-api";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -131,8 +132,8 @@ describe("Vendor media controls", () => {
|
||||||
expiresAt: "2026-09-24T00:00:00Z",
|
expiresAt: "2026-09-24T00:00:00Z",
|
||||||
}),
|
}),
|
||||||
putChunk: vi.fn(
|
putChunk: vi.fn(
|
||||||
({ signal }) =>
|
({ signal }: Parameters<MediaUploadEndpointSet["putChunk"]>[0]) =>
|
||||||
new Promise((_resolve, reject) => {
|
new Promise<MediaChunkAckDto>((_resolve, reject) => {
|
||||||
const abort = () => reject(new DOMException("Aborted", "AbortError"));
|
const abort = () => reject(new DOMException("Aborted", "AbortError"));
|
||||||
if (signal?.aborted) abort();
|
if (signal?.aborted) abort();
|
||||||
else signal?.addEventListener("abort", abort, { once: true });
|
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