From 7d9d8ed47bcbcb5152fa51aec52ff8a03c60ddcd Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Wed, 23 Sep 2026 05:40:40 -0300 Subject: [PATCH] fix: await media policy refresh before retrying uploads --- .../_components/vendor-media-section.tsx | 55 +++++++----- .../use-resumable-work-order-media-upload.ts | 16 ++-- src/test/app/v/vendor-media-section.test.tsx | 5 +- ...resumable-work-order-media-upload.test.tsx | 89 +++++++++++++++++++ 4 files changed, 132 insertions(+), 33 deletions(-) create mode 100644 src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx diff --git a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx index b364cc4f..8cbea431 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -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 { + 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 No photos or videos uploaded yet. )} - void handleView(item)} /> + + void openVendorMediaPreview({ item, token, dispatchId, onError: setMediaActionError }) + } + /> {Boolean(mediaActionError) && ( {mediaActionError} )} diff --git a/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts b/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts index 3e9686a8..0982cd33 100644 --- a/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts +++ b/src/domain/work-orders/use-cases/use-resumable-work-order-media-upload.ts @@ -22,16 +22,10 @@ function toUploadCategory( return typeof category === "string" && isUploadCategory(category) ? category : undefined; } -function invalidateMediaQueries( +async function invalidateMediaQueries( queryClient: ReturnType, - 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 { + 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"); } diff --git a/src/test/app/v/vendor-media-section.test.tsx b/src/test/app/v/vendor-media-section.test.tsx index ae842060..1f6fb9b4 100644 --- a/src/test/app/v/vendor-media-section.test.tsx +++ b/src/test/app/v/vendor-media-section.test.tsx @@ -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[0]) => + new Promise((_resolve, reject) => { const abort = () => reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) abort(); else signal?.addEventListener("abort", abort, { once: true }); diff --git a/src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx b/src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx new file mode 100644 index 00000000..757f15f8 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-resumable-work-order-media-upload.test.tsx @@ -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((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 }) => ( + {children} + ); + 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, + ); + }); +});