mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number
This commit is contained in:
commit
e6f5199da9
4 changed files with 141 additions and 2 deletions
45
src/domain/work-orders/errors/board-create-errors.ts
Normal file
45
src/domain/work-orders/errors/board-create-errors.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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 <QueryClientProvider client={createTestQueryClient()}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
async function createAndGetToast(error: unknown): Promise<unknown> {
|
||||
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.");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue