mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): harden CompDoc generate order and media abort ownership
Session owns AbortControllers for retry/cancel; refresh board state on Generate prerequisite failures; remove dead EditWorkorder completedDate mapper.
This commit is contained in:
parent
c0d5e556f2
commit
346760b973
11 changed files with 209 additions and 129 deletions
|
|
@ -11,13 +11,10 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||
import {
|
||||
DOC_STATE_CFG,
|
||||
docStateOf,
|
||||
fmtDocNow,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
||||
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
|
||||
|
||||
type UseCompDocDialogArgs = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -114,15 +111,21 @@ export function useCompDocDialog({
|
|||
seaRep,
|
||||
};
|
||||
|
||||
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||
await onSave({ notes, generatedAt, ...extra }, status);
|
||||
};
|
||||
|
||||
const syncDateIfChanged = async () => {
|
||||
if (editDateCompleted !== (info.completedDate || "")) {
|
||||
await onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
};
|
||||
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
|
||||
useCompDocPersistFlow({
|
||||
workOrderId: row.id,
|
||||
notes,
|
||||
generatedAt,
|
||||
editDateCompleted,
|
||||
currentCompletedDate: info.completedDate || "",
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
onGenerated: (ts) => {
|
||||
setGeneratedAt(ts);
|
||||
setDocState("generated");
|
||||
},
|
||||
onDraftSaved: notifyDraftSaved,
|
||||
});
|
||||
|
||||
function handleMarkNotRequired() {
|
||||
setDocState("not-required");
|
||||
|
|
@ -134,28 +137,6 @@ export function useCompDocDialog({
|
|||
void persist({}, "No");
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
try {
|
||||
await syncDateIfChanged();
|
||||
await persist({}, "No");
|
||||
notifyDraftSaved();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
const ts = fmtDocNow();
|
||||
try {
|
||||
await syncDateIfChanged();
|
||||
await persist({ generatedAt: ts }, "Yes");
|
||||
setGeneratedAt(ts);
|
||||
setDocState("generated");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||
}
|
||||
}
|
||||
|
||||
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
||||
info,
|
||||
signOffAttachment,
|
||||
|
|
@ -168,6 +149,7 @@ export function useCompDocDialog({
|
|||
syncDateIfChanged,
|
||||
setGeneratedAt,
|
||||
markGenerated: () => setDocState("generated"),
|
||||
onPersistFailure: refreshAuthoritativeState,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type UseCompDocPdfActionsArgs = {
|
|||
syncDateIfChanged: () => Promise<void>;
|
||||
setGeneratedAt: (value: string) => void;
|
||||
markGenerated: () => void;
|
||||
onPersistFailure?: () => void;
|
||||
};
|
||||
|
||||
export function useCompDocPdfActions({
|
||||
|
|
@ -39,6 +40,7 @@ export function useCompDocPdfActions({
|
|||
syncDateIfChanged,
|
||||
setGeneratedAt,
|
||||
markGenerated,
|
||||
onPersistFailure,
|
||||
}: UseCompDocPdfActionsArgs) {
|
||||
function handleDownloadPdf() {
|
||||
if (signOffAttachment) {
|
||||
|
|
@ -75,6 +77,7 @@ export function useCompDocPdfActions({
|
|||
setGeneratedAt(ts);
|
||||
markGenerated();
|
||||
} catch (error) {
|
||||
onPersistFailure?.();
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to finalize completion document",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
type UseCompDocPersistFlowArgs = {
|
||||
workOrderId: string | number;
|
||||
notes: string;
|
||||
generatedAt: string;
|
||||
editDateCompleted: string;
|
||||
currentCompletedDate: string;
|
||||
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
onGenerated: (generatedAt: string) => void;
|
||||
onDraftSaved: () => void;
|
||||
};
|
||||
|
||||
export function useCompDocPersistFlow({
|
||||
workOrderId,
|
||||
notes,
|
||||
generatedAt,
|
||||
editDateCompleted,
|
||||
currentCompletedDate,
|
||||
onSave,
|
||||
onDateCompletedChange,
|
||||
onGenerated,
|
||||
onDraftSaved,
|
||||
}: UseCompDocPersistFlowArgs) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const refreshAuthoritativeState = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.workOrders.detail(workOrderId),
|
||||
});
|
||||
};
|
||||
|
||||
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||
await onSave({ notes, generatedAt, ...extra }, status);
|
||||
};
|
||||
|
||||
const syncDateIfChanged = async () => {
|
||||
if (editDateCompleted !== currentCompletedDate) {
|
||||
await onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
};
|
||||
|
||||
async function handleSaveDraft() {
|
||||
try {
|
||||
// Prerequisites first; docStatus last.
|
||||
await syncDateIfChanged();
|
||||
await persist({}, "No");
|
||||
onDraftSaved();
|
||||
} catch (error) {
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
const ts = fmtDocNow();
|
||||
try {
|
||||
// Prerequisites first; only mark Generated after completedDate persists.
|
||||
await syncDateIfChanged();
|
||||
await persist({ generatedAt: ts }, "Yes");
|
||||
onGenerated(ts);
|
||||
} catch (error) {
|
||||
refreshAuthoritativeState();
|
||||
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
persist,
|
||||
syncDateIfChanged,
|
||||
refreshAuthoritativeState,
|
||||
handleSaveDraft,
|
||||
handleGenerate,
|
||||
};
|
||||
}
|
||||
|
|
@ -56,7 +56,6 @@ export function useSlideOverMediaState({
|
|||
for (const request of requests) {
|
||||
await uploadMediaMutation.mutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
onProgress: request.onProgress,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,10 +29,8 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
|
||||
detail,
|
||||
(input) => uploadMutation.mutateAsync(input),
|
||||
);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
|
|
@ -85,7 +83,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
}}
|
||||
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
|
||||
onRetry={retryUpload}
|
||||
onCancel={removeLocal}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
pending={pending}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
|
@ -15,7 +15,6 @@ import { MediaUploaderGrid } from "./media-uploader-grid";
|
|||
export type MediaUploadRequest = {
|
||||
localId: string;
|
||||
file: File;
|
||||
signal: AbortSignal;
|
||||
onProgress: (percent: number) => void;
|
||||
};
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ type MediaUploaderProps = {
|
|||
onUpload?: (requests: MediaUploadRequest[]) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (localId: string, signal: AbortSignal) => void;
|
||||
onRetry?: (localId: string) => void;
|
||||
onCancel?: (localId: string) => void;
|
||||
/** Legacy local-only mode (table modal fallback). */
|
||||
onChange?: (next: MediaFile[]) => void;
|
||||
|
|
@ -45,7 +44,6 @@ export function MediaUploader({
|
|||
pending = false,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (readOnly || pending || !files || files.length === 0) return;
|
||||
|
|
@ -65,19 +63,11 @@ export function MediaUploader({
|
|||
if (accepted.length === 0) return;
|
||||
|
||||
if (onUpload) {
|
||||
const nextControllers: Record<string, AbortController> = { ...controllers };
|
||||
const requests: MediaUploadRequest[] = accepted.map((file) => {
|
||||
const localId = `local-${crypto.randomUUID()}`;
|
||||
const controller = new AbortController();
|
||||
nextControllers[localId] = controller;
|
||||
return {
|
||||
localId,
|
||||
file,
|
||||
signal: controller.signal,
|
||||
onProgress: () => undefined,
|
||||
};
|
||||
});
|
||||
setControllers(nextControllers);
|
||||
const requests: MediaUploadRequest[] = accepted.map((file) => ({
|
||||
localId: `local-${crypto.randomUUID()}`,
|
||||
file,
|
||||
onProgress: () => undefined,
|
||||
}));
|
||||
onUpload(requests);
|
||||
return;
|
||||
}
|
||||
|
|
@ -98,18 +88,6 @@ export function MediaUploader({
|
|||
onChange?.(uploads.filter((u) => u.id !== id));
|
||||
};
|
||||
|
||||
const cancel = (id: string) => {
|
||||
controllers[id]?.abort();
|
||||
onCancel?.(id);
|
||||
};
|
||||
|
||||
const retry = (id: string) => {
|
||||
controllers[id]?.abort();
|
||||
const controller = new AbortController();
|
||||
setControllers((prev) => ({ ...prev, [id]: controller }));
|
||||
onRetry?.(id, controller.signal);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
|
|
@ -121,8 +99,8 @@ export function MediaUploader({
|
|||
pending={pending}
|
||||
onRemove={remove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry ? retry : undefined}
|
||||
onCancel={cancel}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
|
@ -13,7 +13,13 @@ type UploadMutate = (input: {
|
|||
onProgress?: (percent: number) => void;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
||||
type SessionUploadRequest = {
|
||||
localId: string;
|
||||
file: File;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
||||
const classified = classifyUpload(request.file);
|
||||
return {
|
||||
id: request.localId,
|
||||
|
|
@ -29,7 +35,7 @@ function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
|||
};
|
||||
}
|
||||
|
||||
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
|
||||
function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
|
||||
return item.id === request.localId && item.signal === request.signal;
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +44,7 @@ export function useMediaUploadSession(
|
|||
uploadMutateAsync: UploadMutate,
|
||||
) {
|
||||
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
||||
const controllersRef = useRef(new Map<string, AbortController>());
|
||||
|
||||
const serverUploads = useMemo(
|
||||
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
||||
|
|
@ -52,10 +59,27 @@ export function useMediaUploadSession(
|
|||
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
|
||||
}, [serverUploads, localUploads]);
|
||||
|
||||
function clearController(localId: string) {
|
||||
controllersRef.current.delete(localId);
|
||||
}
|
||||
|
||||
function replaceController(localId: string): AbortController {
|
||||
controllersRef.current.get(localId)?.abort();
|
||||
const controller = new AbortController();
|
||||
controllersRef.current.set(localId, controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
function startUploads(requests: MediaUploadRequest[]) {
|
||||
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
|
||||
localId: request.localId,
|
||||
file: request.file,
|
||||
signal: replaceController(request.localId).signal,
|
||||
}));
|
||||
|
||||
setLocalUploads((prev) => {
|
||||
const next = [...prev];
|
||||
for (const request of requests) {
|
||||
for (const request of sessionRequests) {
|
||||
const entry = toLocalUpload(request);
|
||||
const index = next.findIndex((item) => item.id === request.localId);
|
||||
if (index >= 0) {
|
||||
|
|
@ -71,7 +95,7 @@ export function useMediaUploadSession(
|
|||
return next;
|
||||
});
|
||||
|
||||
for (const request of requests) {
|
||||
for (const request of sessionRequests) {
|
||||
void uploadMutateAsync({
|
||||
file: request.file,
|
||||
signal: request.signal,
|
||||
|
|
@ -84,13 +108,16 @@ export function useMediaUploadSession(
|
|||
},
|
||||
})
|
||||
.then(() => {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error.name === "AbortError") {
|
||||
clearController(request.localId);
|
||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||
return;
|
||||
}
|
||||
// Keep controller entry only while retryable; replaceController on retry.
|
||||
setLocalUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
isSameAttempt(item, request)
|
||||
|
|
@ -107,20 +134,32 @@ export function useMediaUploadSession(
|
|||
}
|
||||
}
|
||||
|
||||
function retryUpload(localId: string, signal: AbortSignal) {
|
||||
function retryUpload(localId: string) {
|
||||
const target = localUploads.find((item) => item.id === localId);
|
||||
if (!target?.sourceFile) return;
|
||||
startUploads([
|
||||
{
|
||||
localId,
|
||||
file: target.sourceFile,
|
||||
signal,
|
||||
onProgress: () => undefined,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function cancelUpload(localId: string) {
|
||||
controllersRef.current.get(localId)?.abort();
|
||||
clearController(localId);
|
||||
setLocalUploads((prev) => {
|
||||
const target = prev.find((item) => item.id === localId);
|
||||
if (target?.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(target.url);
|
||||
}
|
||||
return prev.filter((item) => item.id !== localId);
|
||||
});
|
||||
}
|
||||
|
||||
function removeLocal(id: string) {
|
||||
clearController(id);
|
||||
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
|
|
@ -128,5 +167,5 @@ export function useMediaUploadSession(
|
|||
return localUploads.some((item) => item.id === id);
|
||||
}
|
||||
|
||||
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
|
||||
return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,17 +397,6 @@ export function mapWorkOrderFormToUpdateFormData(
|
|||
return formData;
|
||||
}
|
||||
|
||||
/** Partial EditWorkorder payload — only Id + CompletedDate (avoids legacy GetWorkorderById). */
|
||||
export function mapCompletedDateUpdateFormData(
|
||||
id: string | number,
|
||||
completedDate: string,
|
||||
): FormData {
|
||||
const formData = new FormData();
|
||||
formData.append("Id", String(id));
|
||||
appendIfPresent(formData, "CompletedDate", completedDate);
|
||||
return formData;
|
||||
}
|
||||
|
||||
export function mapLocationOption(raw: unknown): LocationOption {
|
||||
const item = asRecord(raw);
|
||||
const name = readString(item, "name", "Name", "locationName", "LocationName");
|
||||
|
|
|
|||
|
|
@ -170,4 +170,28 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
|||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
|
||||
// Regression for the prior order (status then date): if date fails, Yes must not have been sent.
|
||||
// With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
|
||||
const callOrder: string[] = [];
|
||||
onDateCompletedChange.mockImplementation(async () => {
|
||||
callOrder.push("date");
|
||||
throw new Error("completedDate conflict");
|
||||
});
|
||||
onSave.mockImplementation(async () => {
|
||||
callOrder.push("status");
|
||||
});
|
||||
|
||||
const row = baseRow({ completedDate: "2026-07-01" });
|
||||
renderDialog(row);
|
||||
changeDateCompleted("2026-07-20");
|
||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDateCompletedChange).toHaveBeenCalled();
|
||||
});
|
||||
expect(callOrder).toEqual(["date"]);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||
const firstController = new AbortController();
|
||||
const localId = "local-1";
|
||||
const file = makeFile();
|
||||
|
||||
|
|
@ -29,7 +28,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
{
|
||||
localId,
|
||||
file,
|
||||
signal: firstController.signal,
|
||||
onProgress: () => undefined,
|
||||
},
|
||||
]);
|
||||
|
|
@ -39,6 +37,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
expect(result.current.uploads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
|
||||
|
||||
act(() => {
|
||||
rejectFirst(new Error("network"));
|
||||
});
|
||||
|
|
@ -47,9 +47,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||
});
|
||||
|
||||
const retryController = new AbortController();
|
||||
act(() => {
|
||||
result.current.retryUpload(localId, retryController.signal);
|
||||
result.current.retryUpload(localId);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -58,11 +57,15 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||
});
|
||||
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
||||
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
|
||||
expect(retrySignal).toBeInstanceOf(AbortSignal);
|
||||
expect(retrySignal).not.toBe(firstSignal);
|
||||
expect(retrySignal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
|
||||
it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
|
||||
let rejectFirst!: (error: Error) => void;
|
||||
let resolveRetry!: (value: unknown) => void;
|
||||
const uploadMutateAsync = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
|
|
@ -73,7 +76,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
)
|
||||
.mockImplementation(
|
||||
({ signal }: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
new Promise((resolve, reject) => {
|
||||
resolveRetry = resolve;
|
||||
signal?.addEventListener("abort", () => {
|
||||
const error = new Error("aborted");
|
||||
error.name = "AbortError";
|
||||
|
|
@ -91,7 +95,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
{
|
||||
localId,
|
||||
file,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => undefined,
|
||||
},
|
||||
]);
|
||||
|
|
@ -105,25 +108,28 @@ describe("useMediaUploadSession retry / cancel", () => {
|
|||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||
});
|
||||
|
||||
const retryController = new AbortController();
|
||||
act(() => {
|
||||
result.current.retryUpload(localId, retryController.signal);
|
||||
result.current.retryUpload(localId);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||
});
|
||||
|
||||
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
|
||||
|
||||
act(() => {
|
||||
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
|
||||
retryController.abort();
|
||||
result.current.removeLocal(localId);
|
||||
result.current.cancelUpload(localId);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads).toHaveLength(0);
|
||||
});
|
||||
expect(retryController.signal.aborted).toBe(true);
|
||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
||||
expect(retrySignal.aborted).toBe(true);
|
||||
// Cancelling must not leave a successful settle path.
|
||||
expect(() => resolveRetry({})).not.toThrow();
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mapCompletedDateUpdateFormData,
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdateFormData,
|
||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
|
|
@ -137,24 +136,3 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
|
|||
expect(formData.getAll("CategoryIds")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCompletedDateUpdateFormData", () => {
|
||||
it("builds FormData with only Id and CompletedDate", () => {
|
||||
const formData = mapCompletedDateUpdateFormData(42, "2026-07-30");
|
||||
|
||||
expect(formData.get("Id")).toBe("42");
|
||||
expect(formData.get("CompletedDate")).toBe("2026-07-30");
|
||||
expect(formData.get("Status")).toBeNull();
|
||||
expect(formData.get("WorkerOrderTitle")).toBeNull();
|
||||
expect(formData.get("Description")).toBeNull();
|
||||
expect(formData.getAll("ContactIds")).toEqual([]);
|
||||
expect(formData.getAll("CategoryIds")).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits empty CompletedDate", () => {
|
||||
const formData = mapCompletedDateUpdateFormData(1, " ");
|
||||
|
||||
expect(formData.get("Id")).toBe("1");
|
||||
expect(formData.get("CompletedDate")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue