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