mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
fix: map protected uplift create denial safely
This commit is contained in:
parent
c6e2704175
commit
7b30895aca
4 changed files with 107 additions and 80 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof useQueryClient>,
|
||||
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));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(<WorkOrderUpliftsDialog row={row} open onClose={vi.fn()} />, {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
<UpliftRequestsSection
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
currentNte={100}
|
||||
upliftRequests={[]}
|
||||
locked={false}
|
||||
onChanged={vi.fn()}
|
||||
pollDocument={vi.fn(async () => 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.",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue