fix: await media policy refresh before retrying uploads

This commit is contained in:
Alexandre Brandizzi 2026-09-23 05:40:40 -03:00
parent 9d310afa75
commit 7d9d8ed47b
4 changed files with 132 additions and 33 deletions

View file

@ -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>
)}

View file

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

View file

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

View file

@ -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,
);
});
});