fix(work-orders): show generic copy for unknown edit errors (SH-375)

This commit is contained in:
Codex Review Integration 2026-09-16 21:14:25 -03:00
parent 440a3d0112
commit fc25d8f7c7
3 changed files with 103 additions and 9 deletions

View file

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

View file

@ -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<WorkOrderTableRow> {
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);

View file

@ -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 <QueryClientProvider client={createTestQueryClient()}>{children}</QueryClientProvider>;
}
/** Board PATCH goes through the real ky client; only the network response is faked. */
function respondWith(status: number, body: Record<string, unknown>) {
globalThis.fetch = vi.fn(
async () =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
) as typeof fetch;
}
async function patchAndGetToast(): Promise<unknown> {
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);
});
});