Merge branch 'dev' into fix/ab/sh-320-inline-sh-prefix-wo-number

This commit is contained in:
Alexandre Brandizzi 2026-09-16 21:56:20 -03:00 • committed by GitHub
commit e6f5199da9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 141 additions and 2 deletions

View 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;
}

View file

@ -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;
}

View file

@ -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));
},
});
}

View file

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