shoc-frontend-new/src/test/app/(protected)/uplifts/uplift-queue-decision-flow.test.tsx
Codex Review Integration 8bc8ff30b6 fix(uplifts): gate Approve and Reject on the backend tier permission (SH-207)
Wire the backend's GET uplifts/can-approve tier decision into the
pending row and detail modal actions. Approve/Reject now stay disabled
with the plain 'Requires Tier N role' tooltip unless the backend says
the current user may decide at that tier, so roles the backend refuses
(Dispatcher) never get a clickable action or a refusal toast.
2026-09-17 02:33:29 -03:00

163 lines
5.8 KiB
TypeScript

import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpliftQueueItem, UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
import { renderWithProviders } from "@/test/test-utils";
const rejectMutate = vi.fn();
const approveMutate = vi.fn();
const revokeMutate = vi.fn();
const refetch = vi.fn();
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
const canApproveState = vi.hoisted(() => ({ data: true as boolean | undefined }));
const pendingItem: UpliftQueueItem = {
id: 41,
status: "Pending",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "Extra parts",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "",
decidedByName: "",
decisionNote: "",
requiredTier: 1,
canDecide: true,
expiresAt: "",
notificationStatus: "",
notificationError: "",
evidenceDocumentId: null,
evidenceFileName: "",
evidenceContentType: "",
evidenceSizeBytes: null,
dispatchNumber: "DSP-41",
poNumber: "PO-41",
vendorCompanyName: "Gateway Plumbing",
workOrderId: 99,
dispatchId: 7,
woNumber: "WO-99",
site: "Site A",
serviceName: "Plumbing repair",
attachmentCount: null,
approvedOnWoAuto: null,
approvedOnWoAdmin: null,
approvedOnWoTotal: 100,
workOrderClosed: false,
};
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
useUpliftsQueue: () => ({
data: queueState.data,
isLoading: false,
isFetching: false,
error: null,
refetch,
}),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }),
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
useUpliftsCanApprove: () => ({ data: canApproveState.data }),
}));
describe("Uplift Approvals decision flow", () => {
beforeEach(() => {
rejectMutate.mockReset();
approveMutate.mockReset();
revokeMutate.mockReset();
refetch.mockReset();
canApproveState.data = true;
queueState.data = {
items: [pendingItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: 250,
};
});
it("shows the Uplift Approvals heading, count pill and exposure total", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("heading", { name: "Uplift Approvals" })).toBeInTheDocument();
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
expect(screen.getByText("Pending exposure: $250.00")).toBeInTheDocument();
});
it("approves in one click without a confirmation dialog", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(approveMutate).toHaveBeenCalledWith({ id: 41, amount: 150, woNumber: "WO-99" });
expect(screen.queryByRole("button", { name: "Confirm" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens RejectDialog with the dispatcher copy and does not reject on Cancel", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
expect(screen.getByText("The dispatcher sees this reason on WO #WO-99.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(rejectMutate).not.toHaveBeenCalled();
});
it("shows the pending empty copy when nothing is waiting", async () => {
queueState.data = {
items: [],
totalCount: 0,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByText("No uplifts waiting for approval.")).toBeInTheDocument();
});
it("disables Approve and Reject for a Dispatcher the backend refuses, without dialogs or toasts", async () => {
canApproveState.data = false;
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
const approve = screen.getByRole("button", { name: "Approve" });
const reject = screen.getByRole("button", { name: "Reject" });
expect(approve).toBeDisabled();
expect(reject).toBeDisabled();
fireEvent.mouseOver(approve);
expect(await screen.findByText("Requires Tier 1 role")).toBeInTheDocument();
fireEvent.click(approve);
fireEvent.click(reject);
expect(approveMutate).not.toHaveBeenCalled();
expect(rejectMutate).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /reject uplift of/i })).not.toBeInTheDocument();
});
it("keeps Approve and Reject enabled when the backend allows the tier", async () => {
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
expect(screen.getByRole("button", { name: "Approve" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Reject" })).toBeEnabled();
});
});