From 3ad07c9e10de72b93c6c328a92dfcf3e3e091315 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:17:37 -0300 Subject: [PATCH] fix(work-orders): keep backend validation text out of create error toasts Board create failures toasted the backend message verbatim, exposing field names such as siteCode and vendorId from the inline row and the wizard. Map known codes to product copy and fall back to generic copy for every other failure. --- .../work-orders/errors/board-create-errors.ts | 45 ++++++++++ .../work-orders/errors/board-patch-errors.ts | 4 +- .../use-create-work-order-from-wizard.ts | 4 +- ...ate-work-order-from-wizard-errors.test.tsx | 90 +++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/domain/work-orders/errors/board-create-errors.ts create mode 100644 src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx diff --git a/src/domain/work-orders/errors/board-create-errors.ts b/src/domain/work-orders/errors/board-create-errors.ts new file mode 100644 index 00000000..446eaf5f --- /dev/null +++ b/src/domain/work-orders/errors/board-create-errors.ts @@ -0,0 +1,45 @@ +import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; +import { mapHttpStatusToMessage } from "@/api/api-error"; +import { DISPATCH_REQUIRED_MESSAGE } from "@/domain/work-orders/errors/board-patch-errors"; + +export const BOARD_CREATE_FAILED_MESSAGE = "Unable to create the work order. Please try again."; + +const DUPLICATE_WO_NUMBER_MESSAGE = + "This work order number is already assigned to another work order."; + +/** Statuses whose mapped copy is fixed product text, never the response body. */ +const FIXED_COPY_STATUSES = new Set([401, 403, 404, 500]); + +function readErrorCode(data: unknown): string | undefined { + if (typeof data !== "object" || data === null) { + return undefined; + } + const record = data as Record; + const code = record.code ?? record.Code; + return typeof code === "string" ? code : undefined; +} + +/** + * why: board create returns backend validation text ("siteCode is required.", + * "vendorId is required when setting appointment fields.") that must never reach the + * screen. Unknown failures fall back to generic copy, not to `error.message`. + */ +export function boardCreateErrorMessage(error: unknown): string { + if (isHTTPError(error)) { + const code = readErrorCode(error.data); + if (code === "DispatchRequired") { + return DISPATCH_REQUIRED_MESSAGE; + } + if (code === "DuplicateWoNumber") { + return DUPLICATE_WO_NUMBER_MESSAGE; + } + const status = error.response.status; + return FIXED_COPY_STATUSES.has(status) + ? mapHttpStatusToMessage(status) + : BOARD_CREATE_FAILED_MESSAGE; + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return BOARD_CREATE_FAILED_MESSAGE; +} diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts index d4e9fdbd..00779894 100644 --- a/src/domain/work-orders/errors/board-patch-errors.ts +++ b/src/domain/work-orders/errors/board-patch-errors.ts @@ -22,10 +22,12 @@ 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 function boardPatchValidationMessage(error: BoardPatchValidationError): string { if (error.code === "DispatchRequired") { - return "Select a vendor company before saving."; + return DISPATCH_REQUIRED_MESSAGE; } return error.message; } diff --git a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts index 205f010e..4aa4c8b8 100644 --- a/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts +++ b/src/domain/work-orders/use-cases/use-create-work-order-from-wizard.ts @@ -4,6 +4,8 @@ import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; +import { boardCreateErrorMessage } from "@/domain/work-orders/errors/board-create-errors"; + import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; @@ -27,7 +29,7 @@ export function useCreateWorkOrderFromWizard( }, onError: (error: Error) => { - toast.error(error.message || "Failed to create work order"); + toast.error(boardCreateErrorMessage(error)); }, }); } diff --git a/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx b/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx new file mode 100644 index 00000000..59392029 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-create-work-order-from-wizard-errors.test.tsx @@ -0,0 +1,90 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HTTPError } from "ky"; +import type { ReactNode } from "react"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeApiRequestError } from "@/api/api-error"; +import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard"; +import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +import { createTestQueryClient } from "@/test/test-utils"; + +const createFromBoard = vi.fn(); + +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { createFromBoard: (...args: unknown[]) => createFromBoard(...args) }, +})); + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +/** Mirrors the ky client: HTTPError with parsed body, message normalized from it. */ +function backendError(status: number, body: Record): HTTPError { + const error = new HTTPError( + new Response(JSON.stringify(body), { status }), + new Request("https://api.example.test/workorders/board"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +async function createAndGetToast(error: unknown): Promise { + createFromBoard.mockRejectedValueOnce(error); + const { result } = renderHook(() => useCreateWorkOrderFromWizard(), { wrapper }); + act(() => { + result.current.mutate(EMPTY_WIZARD_DRAFT); + }); + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("useCreateWorkOrderFromWizard error copy", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + createFromBoard.mockReset(); + }); + + it("never surfaces raw backend validation text from a 400", async () => { + const message = await createAndGetToast( + backendError(400, { status: "Validation Error", message: "siteCode is required." }), + ); + + expect(message).toBe("Unable to create the work order. Please try again."); + }); + + it("maps a coded 422 to product copy instead of the backend field name", async () => { + const message = await createAndGetToast( + backendError(422, { + code: "DispatchRequired", + message: "vendorId is required when setting appointment fields.", + }), + ); + + expect(message).toBe("Select a vendor company before saving."); + }); + + it("falls back to generic copy for unknown 422 codes", async () => { + const message = await createAndGetToast( + backendError(422, { + code: "InvalidLifecycleStatus", + message: "lifecycleStatus must be Incomplete, Pending, or Scheduled at create.", + }), + ); + + expect(message).toBe("Unable to create the work order. Please try again."); + }); + + it("keeps fixed permission copy for a 403", async () => { + const message = await createAndGetToast( + backendError(403, { code: "Forbidden", message: "accountId not permitted" }), + ); + + expect(message).toBe("You do not have permission to perform this action."); + }); +});