diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index e157b2bf..9fa98227 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -15,8 +15,6 @@ import { } from "@/app/v/[token]/dispatch/_components/use-uplift-evidence"; const ACTIVE_STATUSES = new Set(["Pending", "ChangesRequested"]); -const UPLIFT_REQUEST_PERMISSION_DENIAL = "Your role can't request uplifts on this work order."; -const UPLIFT_REQUEST_ERROR_FALLBACK = "Unable to request the NTE uplift. Please try again."; type UpliftRequestsSectionProps = { token: string; @@ -43,12 +41,6 @@ function scanErrorMessage(scan: EvidenceScanState): string { return "Security scan timed out. Please try again once the file finishes scanning."; } -function mapUpliftRequestError(error: unknown): string { - return error instanceof Error && error.message === UPLIFT_REQUEST_PERMISSION_DENIAL - ? UPLIFT_REQUEST_PERMISSION_DENIAL - : UPLIFT_REQUEST_ERROR_FALLBACK; -} - function useVendorUpliftActions({ token, dispatchId, @@ -104,8 +96,8 @@ function useVendorUpliftActions({ if (outcome.outcome === "NoApprovalRequired") { setInfoMessage("No approval required — the existing NTE already covers this amount."); } - } catch (error) { - setFormError(mapUpliftRequestError(error)); + } catch { + setFormError("Unable to request the NTE uplift. Please try again."); } finally { setSubmitting(false); } diff --git a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts index 3a94e733..eb16a041 100644 --- a/src/domain/work-orders/use-cases/use-work-order-uplifts.ts +++ b/src/domain/work-orders/use-cases/use-work-order-uplifts.ts @@ -5,7 +5,9 @@ import { type UseMutationResult, type UseQueryResult, } from "@tanstack/react-query"; +import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; import { toast } from "react-toastify"; +import { mapHttpStatusToMessage } from "@/api/api-error"; import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api"; import type { CreateWorkOrderUpliftInput, @@ -14,6 +16,26 @@ import type { import { queryKeys } from "@/infra/query-key/query-key"; import { requireQueryParam } from "@/lib/query/require-query-param"; +const CREATE_PERMISSION_DENIED_MESSAGE = "Your role can't request uplifts on this work order."; +const CREATE_UPLIFT_ERROR_FALLBACK = "Failed to create uplift"; +const CREATE_UPLIFT_FIXED_STATUS_COPY = new Set([401, 404, 500]); + +function createUpliftErrorMessage(error: unknown): string { + if (isHTTPError(error)) { + const status = error.response.status; + if (status === 403) { + return CREATE_PERMISSION_DENIED_MESSAGE; + } + return CREATE_UPLIFT_FIXED_STATUS_COPY.has(status) + ? mapHttpStatusToMessage(status) + : CREATE_UPLIFT_ERROR_FALLBACK; + } + if (isNetworkError(error) || isTimeoutError(error)) { + return error.message; + } + return CREATE_UPLIFT_ERROR_FALLBACK; +} + function invalidateUpliftQueries( queryClient: ReturnType, workOrderId: string | number, @@ -48,7 +70,7 @@ export function useCreateWorkOrderUplift( toast.success("Uplift request created"); }, onError: (error) => { - toast.error(error.message || "Failed to create uplift"); + toast.error(createUpliftErrorMessage(error)); }, }); } diff --git a/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx new file mode 100644 index 00000000..8859512c --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-uplift-create-permissions.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { toast } from "react-toastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, +})); + +const row = { + id: 42, + woNumber: "WO-42", + type: "PM", + status: "Scheduled", +} as WorkOrderTableRow; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function respondToCreate(status: number, message: string) { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.method === "POST") { + return new Response(JSON.stringify({ code: "Forbidden", message }), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ status: "Success", data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + return fetchMock; +} + +async function submitInternalCreate(status: number, serverMessage: string) { + const fetchMock = respondToCreate(status, serverMessage); + renderWithProviders(, { + withAuth: true, + }); + + fireEvent.change(await screen.findByLabelText("Amount"), { target: { value: "125" } }); + fireEvent.change(screen.getByLabelText("Notes"), { target: { value: "Additional work" } }); + fireEvent.click(screen.getByRole("button", { name: "Create uplift" })); + + await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled()); + expect( + fetchMock.mock.calls.some(([input, init]) => { + const request = input instanceof Request ? input : new Request(input, init); + return ( + request.method === "POST" && + new URL(request.url).pathname.endsWith("/api/workorders/42/uplifts") + ); + }), + ).toBe(true); + return vi.mocked(toast.error).mock.calls[0]?.[0]; +} + +describe("internal work-order uplift create error copy", () => { + it("shows the exact role denial for a 403", async () => { + const message = await submitInternalCreate(403, "accountId 42 is forbidden"); + + expect(message).toBe("Your role can't request uplifts on this work order."); + }); + + it("keeps unknown non-403 server diagnostics generic", async () => { + const diagnostic = "SqlException: workOrderId 42 row rejected"; + const message = await submitInternalCreate(422, diagnostic); + + expect(message).toBe("Failed to create uplift"); + expect(message).not.toContain(diagnostic); + }); +}); diff --git a/src/test/app/v/uplift-requests-section.test.tsx b/src/test/app/v/uplift-requests-section.test.tsx deleted file mode 100644 index 51416c19..00000000 --- a/src/test/app/v/uplift-requests-section.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section"; -import { renderWithProviders } from "@/test/test-utils"; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -function renderCreateForm() { - renderWithProviders( - undefined)} - />, - { withAuth: false }, - ); -} - -function submitCreateForm() { - fireEvent.change(screen.getByLabelText("New total NTE"), { target: { value: "100" } }); - fireEvent.click(screen.getByRole("button", { name: "Request uplift" })); -} - -describe("UpliftRequestsSection create workflow", () => { - it("renders the server denial message when requestUplifts is denied", async () => { - const denialMessage = "Your role can't request uplifts on this work order."; - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ message: denialMessage }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }), - ); - - renderCreateForm(); - submitCreateForm(); - - await waitFor(() => expect(screen.getByRole("alert").textContent).toBe(denialMessage)); - expect(fetchMock).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/vendor-portal\/dispatches\/7\/uplift-request$/), - expect.objectContaining({ method: "POST" }), - ); - }); - - it("keeps unexpected server messages behind the generic fallback", async () => { - const unexpectedMessage = "SqlException: work order row failed"; - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ message: unexpectedMessage }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }), - ); - - renderCreateForm(); - submitCreateForm(); - - await waitFor(() => - expect(screen.getByRole("alert").textContent).toBe( - "Unable to request the NTE uplift. Please try again.", - ), - ); - }); -});