diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts index 00779894..2335502b 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -24,10 +24,20 @@ export class BoardPatchValidationError extends Error { export const DISPATCH_REQUIRED_MESSAGE = "Select a vendor company before saving."; -/** why: backend field names must never be exposed through user-facing board toasts. */ +export const BOARD_PATCH_FAILED_MESSAGE = "Unable to save the work order. Please try again."; + +export const NO_PERSISTABLE_FIELDS_MESSAGE = + "These changes cannot be saved on the board. Refresh and edit a supported field."; + +const KNOWN_VALIDATION_COPY: ReadonlyMap = new Map([ + ["DispatchRequired", DISPATCH_REQUIRED_MESSAGE], + ["NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE], +]); + +/** + * why: board PATCH validation text ("vendorId is required…", object ids, backend codes) + * must never reach the screen. Unknown codes fall back to generic copy, not `error.message`. + */ export function boardPatchValidationMessage(error: BoardPatchValidationError): string { - if (error.code === "DispatchRequired") { - return DISPATCH_REQUIRED_MESSAGE; - } - return error.message; + return KNOWN_VALIDATION_COPY.get(error.code) ?? BOARD_PATCH_FAILED_MESSAGE; } diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index a02950d3..0b17b328 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -6,6 +6,7 @@ import { boardPatchValidationMessage, BoardPatchConflictError, BoardPatchValidationError, + NO_PERSISTABLE_FIELDS_MESSAGE, } from "@/domain/work-orders/errors/board-patch-errors"; import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper"; import { @@ -58,10 +59,7 @@ async function applyBoardFieldOps( ): Promise { const ops = expandTablePatchToBoardFieldOps(patch); if (ops.length === 0) { - throw new BoardPatchValidationError( - "NoPersistableFields", - "These changes cannot be saved on the board. Refresh and edit a supported field.", - ); + throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE); } const current = getRow?.(id); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx new file mode 100644 index 00000000..b9664f4b --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-errors.test.tsx @@ -0,0 +1,86 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BOARD_PATCH_FAILED_MESSAGE, + DISPATCH_REQUIRED_MESSAGE, +} from "@/domain/work-orders/errors/board-patch-errors"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +import { createTestQueryClient } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +const ROW = { id: 7, rowVersion: "v1" } as WorkOrderTableRow; + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +/** Board PATCH goes through the real ky client; only the network response is faked. */ +function respondWith(status: number, body: Record) { + globalThis.fetch = vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ) as typeof fetch; +} + +async function patchAndGetToast(): Promise { + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch: () => undefined, + clearPatch: () => undefined, + getRow: () => ROW, + }), + { wrapper }, + ); + act(() => { + result.current.patchField(7, { woNumber: "WO-100" }); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("useWorkOrderTableMutations edit error copy", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("shows generic copy instead of backend text for an unknown 422 code", async () => { + respondWith(422, { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus cannot move from Completed for workOrderId 7.", + }); + + const message = await patchAndGetToast(); + + expect(message).toBe(BOARD_PATCH_FAILED_MESSAGE); + expect(message).not.toMatch(/lifecycleStatus|workOrderId|InvalidLifecycleStatus/); + }); + + it("keeps specific copy for DispatchRequired", async () => { + respondWith(422, { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }); + + const message = await patchAndGetToast(); + + expect(message).toBe(DISPATCH_REQUIRED_MESSAGE); + }); +});