fix(uplifts): restrict revoke to admins and quiet permission failures (SH-207)

- Gate the Revoke action on the approved queue row and the detail modal
  to admin users; non-admins get a disabled button with a plain
  'Only admins can revoke uplifts' tooltip and the dialog cannot open.
- Stop nesting an h2 inside DialogTitle in the uplift detail modal
  (hydration error) by rendering the title text as a non-heading.
- Handle work-order detail/uplift query 403s quietly in the detail
  modal: sections fall back to 'Unavailable' and the queries suppress
  the global error toast (QueryCache now honors suppressErrorToast).
- Extract UpliftDecisionDialogs and openUpliftEvidence from the page to
  meet the changed-file maintainability gate (complexity 23 -> pass).
This commit is contained in:
Codex Review Integration 2026-09-17 00:13:54 -03:00
parent 6a2b9eb80c
commit 4bffdac175
13 changed files with 412 additions and 79 deletions

View file

@ -0,0 +1,13 @@
import { toast } from "react-toastify";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
export function openUpliftEvidence(row: UpliftQueueItem): void {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
});
}

View file

@ -20,6 +20,7 @@ import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "
export type UpliftApprovalTab = "pending" | "approved";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
function workOrderLabel(row: UpliftQueueItem): string {
return row.woNumber || row.dispatchNumber || "—";
@ -119,23 +120,26 @@ function PendingRowActions({
function ApprovedRowActions({
row,
canRevoke,
onRevoke,
isDecisionPending,
}: {
row: UpliftQueueItem;
canRevoke: boolean;
onRevoke: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const closed = row.workOrderClosed === true;
const tooltip = !canRevoke ? ADMIN_ONLY_REVOKE_TOOLTIP : closed ? CLOSED_WO_TOOLTIP : "";
return (
<Box sx={{ display: "flex", gap: 1 }} onClick={(e) => e.stopPropagation()}>
<Tooltip title={closed ? CLOSED_WO_TOOLTIP : ""}>
<Tooltip title={tooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={isDecisionPending || closed || row.workOrderId == null}
disabled={!canRevoke || isDecisionPending || closed || row.workOrderId == null}
onClick={() => onRevoke(row)}
>
Revoke
@ -149,6 +153,7 @@ function ApprovedRowActions({
function UpliftApprovalRow({
row,
tab,
canRevoke,
onOpenDetail,
onApprove,
onReject,
@ -158,6 +163,7 @@ function UpliftApprovalRow({
}: {
row: UpliftQueueItem;
tab: UpliftApprovalTab;
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
@ -242,7 +248,12 @@ function UpliftApprovalRow({
isDecisionPending={isDecisionPending}
/>
) : (
<ApprovedRowActions row={row} onRevoke={onRevoke} isDecisionPending={isDecisionPending} />
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)}
</TableCell>
</TableRow>
@ -278,6 +289,7 @@ export function UpliftApprovalsTable({
tab,
isLoading,
rows,
canRevoke,
onOpenDetail,
onApprove,
onReject,
@ -288,6 +300,7 @@ export function UpliftApprovalsTable({
tab: UpliftApprovalTab;
isLoading: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
@ -338,6 +351,7 @@ export function UpliftApprovalsTable({
key={String(row.id)}
row={row}
tab={tab}
canRevoke={canRevoke}
onOpenDetail={onOpenDetail}
onApprove={onApprove}
onReject={onReject}

View file

@ -0,0 +1,42 @@
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import type { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
export type UpliftApprovalController = ReturnType<typeof useUpliftApprovalController>;
function woFallbackNumber(target: UpliftApprovalController["rejectTarget"]): string {
return target?.woNumber || String(target?.workOrderId ?? "");
}
export function UpliftDecisionDialogs({ controller }: { controller: UpliftApprovalController }) {
const {
rejectTarget,
revokeTarget,
rejectPending,
revokePending,
handleRejectConfirm,
handleRevokeConfirm,
closeReject,
closeRevoke,
} = controller;
return (
<>
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
woNumber={woFallbackNumber(rejectTarget)}
onClose={closeReject}
onConfirm={handleRejectConfirm}
isPending={rejectPending}
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
woNumber={woFallbackNumber(revokeTarget)}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}
isPending={revokePending}
/>
</>
);
}

View file

@ -19,6 +19,8 @@ import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
const UNAVAILABLE_LABEL = "Unavailable";
type ModalCallbacks = {
onClose: () => void;
@ -81,7 +83,16 @@ function UpliftAttachments({
);
}
function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOrderTableRow }) {
function WorkOrderSection({
item,
info,
unavailable,
}: {
item: UpliftQueueItem;
info?: WorkOrderTableRow;
unavailable: boolean;
}) {
const statusFallback = unavailable ? UNAVAILABLE_LABEL : "—";
return (
<DetailSection title="Work order">
<DetailField label="Work order #">
@ -91,10 +102,12 @@ function WorkOrderSection({ item, info }: { item: UpliftQueueItem; info?: WorkOr
<Text variant="body">{info?.site || item.site || "—"}</Text>
</DetailField>
<DetailField label="Status">
<Text variant="body">{info?.status || "—"}</Text>
<Text variant="body">{info?.status || statusFallback}</Text>
</DetailField>
<DetailField label="Scheduled">
<Text variant="body">{info?.scheduledOn || "Unscheduled"}</Text>
<Text variant="body">
{info?.scheduledOn || (unavailable ? UNAVAILABLE_LABEL : "Unscheduled")}
</Text>
</DetailField>
<DetailField label="Vendor">
<Text variant="body">{item.vendorCompanyName || "—"}</Text>
@ -142,10 +155,12 @@ function ApprovedOnWoBreakdown({
auto,
admin,
total,
unavailable,
}: {
auto: number | null;
admin: number | null;
total: number | null;
unavailable: boolean;
}) {
const rows: Array<{ label: string; value: number | null; strong: boolean }> = [
{ label: "Auto-approved", value: auto, strong: false },
@ -155,7 +170,7 @@ function ApprovedOnWoBreakdown({
return (
<Box sx={{ display: "grid", gap: 0.5 }}>
{rows.map((row) => (
<BreakdownRow key={row.label} {...row} />
<BreakdownRow key={row.label} unavailable={unavailable} {...row} />
))}
</Box>
);
@ -165,10 +180,12 @@ function BreakdownRow({
label,
value,
strong,
unavailable,
}: {
label: string;
value: number | null;
strong: boolean;
unavailable: boolean;
}) {
return (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
@ -183,7 +200,11 @@ function BreakdownRow({
variant={strong ? "body" : "description"}
className={strong ? "font-medium" : undefined}
>
{value != null ? formatUpliftMoney(value) : "—"}
{value != null
? formatUpliftMoney(value)
: unavailable
? UNAVAILABLE_LABEL
: "—"}
</Text>
</Box>
);
@ -236,6 +257,7 @@ function PendingModalActions({
function UpliftModalActions({
item,
closed,
canRevoke,
approvePending,
revokePending,
onApprove,
@ -244,12 +266,18 @@ function UpliftModalActions({
}: {
item: UpliftQueueItem;
closed: boolean;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
onApprove: (row: UpliftQueueItem) => void;
onReject: (row: UpliftQueueItem) => void;
onRevoke: (row: UpliftQueueItem) => void;
}) {
const revokeTooltip = !canRevoke
? ADMIN_ONLY_REVOKE_TOOLTIP
: closed
? CLOSED_WO_TOOLTIP
: "";
return (
<>
{item.status === "Pending" && (
@ -261,13 +289,13 @@ function UpliftModalActions({
/>
)}
{item.status === "Approved" && (
<Tooltip title={closed ? CLOSED_WO_TOOLTIP : ""}>
<Tooltip title={revokeTooltip}>
<span>
<Button
size="small"
variant="outlined"
color="error"
disabled={closed || item.workOrderId == null || revokePending}
disabled={!canRevoke || closed || item.workOrderId == null || revokePending}
onClick={() => onRevoke(item)}
>
Revoke
@ -281,6 +309,7 @@ function UpliftModalActions({
export function UpliftDetailModal({
item,
canRevoke,
approvePending,
revokePending,
onClose,
@ -290,15 +319,22 @@ export function UpliftDetailModal({
onOpenAttachment,
}: {
item: UpliftQueueItem | null;
canRevoke: boolean;
approvePending: boolean;
revokePending: boolean;
} & ModalCallbacks) {
const open = item != null;
// Permission failures (e.g. 403 for account-scoped users) are handled quietly
// in this modal: dependent sections fall back to "Unavailable" instead of toasting.
const quietMeta = { suppressErrorToast: true } as const;
const boardDetail = useWorkOrderBoardDetail(
item != null && item.workOrderId != null ? item.workOrderId : undefined,
true,
quietMeta,
);
const woUpliftsQuery = useWorkOrderUplifts(
item != null && item.workOrderId != null ? item.workOrderId : null,
quietMeta,
);
const info = boardDetail.data?.info;
@ -337,20 +373,27 @@ export function UpliftDetailModal({
aria-label="Uplift request details"
>
<DialogTitle>
<Text variant="title">Uplift request</Text>
<Text variant="title" as="div">
Uplift request
</Text>
<Text variant="description" tone="muted">
{item.woNumber || item.dispatchNumber || "Work order"} · {item.status}
</Text>
</DialogTitle>
<DialogContent>
<Box sx={{ display: "grid", gap: 3 }}>
<WorkOrderSection item={item} info={info} />
<WorkOrderSection
item={item}
info={info}
unavailable={boardDetail.isError}
/>
<RequestSection item={item} onOpenAttachment={onOpenAttachment} />
<DetailSection title="Approved on work order">
<ApprovedOnWoBreakdown
auto={breakdown.auto}
admin={breakdown.admin}
total={breakdown.total}
unavailable={woUpliftsQuery.isError}
/>
</DetailSection>
</Box>
@ -362,6 +405,7 @@ export function UpliftDetailModal({
<UpliftModalActions
item={item}
closed={closed}
canRevoke={canRevoke}
approvePending={approvePending}
revokePending={revokePending}
onApprove={onApprove}

View file

@ -1,18 +1,17 @@
import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import { toast } from "react-toastify";
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
import {
UpliftApprovalsTable,
type UpliftApprovalTab,
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { openUpliftEvidence } from "@/app/(protected)/uplifts/_components/open-uplift-evidence";
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import { Text } from "@/components/ui/text";
const PAGE_SIZE = 25;
@ -109,6 +108,9 @@ export default function UpliftQueuePage() {
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const pendingQuery = useUpliftsQueue({
status: "Pending",
page: pendingPage + 1,
@ -120,23 +122,18 @@ export default function UpliftQueuePage() {
pageSize: PAGE_SIZE,
});
const controller = useUpliftApprovalController();
const {
detailItem,
rejectTarget,
revokeTarget,
approvePending,
rejectPending,
revokePending,
handleApprove,
handleRejectRequest,
handleRevokeRequest,
handleRejectConfirm,
handleRevokeConfirm,
closeDetail,
closeReject,
closeRevoke,
openDetail,
} = useUpliftApprovalController();
} = controller;
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const rows = activeQuery.data?.items ?? [];
@ -149,16 +146,6 @@ export default function UpliftQueuePage() {
}
};
const handleOpenAttachment = (row: UpliftQueueItem) => {
void upliftsApi
.downloadEvidence(row.id, row.evidenceFileName || "uplift-evidence")
.catch((error: unknown) => {
toast.error(
error instanceof Error ? error.message : "Unable to download evidence right now.",
);
});
};
return (
<Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
@ -168,11 +155,12 @@ export default function UpliftQueuePage() {
tab={tab}
isLoading={activeQuery.isLoading}
rows={rows}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail}
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={handleOpenAttachment}
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
<UpliftApprovalsPagination
@ -186,31 +174,17 @@ export default function UpliftQueuePage() {
{detailItem != null && (
<UpliftDetailModal
item={detailItem}
canRevoke={canRevokeUplifts}
approvePending={approvePending}
revokePending={revokePending}
onClose={closeDetail}
onApprove={handleApprove}
onReject={handleRejectRequest}
onRevoke={handleRevokeRequest}
onOpenAttachment={handleOpenAttachment}
onOpenAttachment={openUpliftEvidence}
/>
)}
<RejectDialog
open={rejectTarget != null}
amount={rejectTarget?.requestedNTE}
woNumber={rejectTarget?.woNumber || String(rejectTarget?.workOrderId ?? "")}
onClose={closeReject}
onConfirm={handleRejectConfirm}
isPending={rejectPending}
/>
<RevokeDialog
open={revokeTarget != null}
amount={revokeTarget?.requestedNTE}
woNumber={revokeTarget?.woNumber || String(revokeTarget?.workOrderId ?? "")}
onClose={closeRevoke}
onConfirm={handleRevokeConfirm}
isPending={revokePending}
/>
<UpliftDecisionDialogs controller={controller} />
</Box>
);
}

View file

@ -27,11 +27,13 @@ export function useWorkOrderDetail(
export function useWorkOrderBoardDetail(
id: string | number | undefined,
enabled = true,
meta?: { suppressErrorToast?: boolean },
): UseQueryResult<WorkOrderBoardDetail, Error> {
return useQuery({
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
enabled: enabled && id !== undefined && id !== "",
meta,
// Do not keepPreviousData — placeholder detail from another WO leaks into the slide-over.
});
}

View file

@ -24,12 +24,14 @@ function invalidateUpliftQueries(
export function useWorkOrderUplifts(
workOrderId: string | number | null | undefined,
meta?: { suppressErrorToast?: boolean },
): UseQueryResult<WorkOrderUplift[], Error> {
return useQuery({
queryKey: queryKeys.workOrders.uplifts(workOrderId ?? ""),
queryFn: () =>
workOrderUpliftsApi.list(requireQueryParam(workOrderId ?? undefined, "Work order id")),
enabled: workOrderId != null && workOrderId !== "",
meta,
});
}

View file

@ -1,33 +1,39 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
console.error("Query error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while fetching data";
toast.error(message);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.meta?.suppressErrorToast) return;
export function createAppQueryClient(): QueryClient {
return new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.meta?.suppressErrorToast) return;
console.error("Mutation error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while updating data";
toast.error(message);
console.error("Query error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while fetching data";
toast.error(message);
},
}),
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
if (mutation.options.meta?.suppressErrorToast) return;
console.error("Mutation error:", error);
const message =
error instanceof Error ? error.message : "An error occurred while updating data";
toast.error(message);
},
}),
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
});
}
export const queryClient = createAppQueryClient();
export default queryClient;

View file

@ -8,6 +8,9 @@ const revokeMutate = vi.fn();
const queueState = vi.hoisted(() => ({
data: null as UpliftsQueueResult | null,
}));
const authState = vi.hoisted(() => ({
userRoles: "Admin",
}));
const approvedItem: UpliftQueueItem = {
id: 77,
@ -68,9 +71,15 @@ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: authState.userRoles } }),
}));
describe("Uplift Approvals approved tab", () => {
beforeEach(() => {
revokeMutate.mockReset();
authState.userRoles = "Admin";
queueState.data = {
items: [approvedItem],
totalCount: 1,
@ -121,6 +130,30 @@ describe("Uplift Approvals approved tab", () => {
).toBeInTheDocument();
});
it("disables Revoke with the admin-only tooltip for a Dispatcher and never opens the dialog", async () => {
authState.userRoles = "Dispatcher";
queueState.data = {
items: [approvedOpenItem],
totalCount: 1,
page: 1,
pageSize: 25,
pendingExposureTotal: null,
};
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
renderWithProviders(<UpliftQueuePage />);
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
const revoke = await screen.findByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument();
fireEvent.click(revoke);
expect(
screen.queryByRole("heading", { name: "Revoke this approval?" }),
).not.toBeInTheDocument();
expect(revokeMutate).not.toHaveBeenCalled();
});
it("shows the approved empty copy when nothing is archived", async () => {
queueState.data = {
items: [],

View file

@ -0,0 +1,132 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import { ApiError } from "@/api/api-error";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
import { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
const boardDetailMock = vi.hoisted(() => ({ calls: 0 }));
const woUpliftsMock = vi.hoisted(() => ({ calls: 0 }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error },
}));
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getBoardDetail: vi.fn(() => {
boardDetailMock.calls += 1;
return Promise.reject(
new ApiError("You do not have permission to perform this action.", 403),
);
}),
},
}));
vi.mock("@/domain/work-orders/api/work-order-uplifts-api", () => ({
workOrderUpliftsApi: {
list: vi.fn(() => {
woUpliftsMock.calls += 1;
return Promise.reject(
new ApiError("You do not have permission to perform this action.", 403),
);
}),
},
}));
vi.mock("@/domain/auth/api/auth-api", async (importOriginal) => ({
...(await importOriginal<object>()),
getSession: vi.fn(() =>
Promise.resolve({
token: "t",
expiration: "",
email: "dispatch@seahaven.test",
userRoles: "Dispatcher",
phoneNumber: "",
fullname: "Dispatch User",
id: "u1",
}),
),
}));
const dispatcherItem: UpliftQueueItem = {
id: 41,
status: "Approved",
currentNTE: 100,
requestedNTE: 250,
delta: 150,
vendorReason: "",
requestedAt: "2026-01-15T10:00:00Z",
requestedByVendorName: "Gateway",
decidedAt: "2026-01-16T10:00:00Z",
decidedByName: "Admin User",
decisionNote: "",
requiredTier: 1,
canDecide: false,
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: null,
workOrderClosed: false,
};
function renderWithGovernedClient() {
const queryClient = createAppQueryClient();
queryClient.setDefaultOptions({ queries: { retry: false } });
return renderWithProviders(
<UpliftDetailModal
item={dispatcherItem}
canRevoke={false}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
onApprove={vi.fn()}
onReject={vi.fn()}
onRevoke={vi.fn()}
onOpenAttachment={vi.fn()}
/>,
{ queryClient },
);
}
describe("UpliftDetailModal permission failures (Dispatcher 403)", () => {
beforeEach(() => {
toastMocks.error.mockReset();
boardDetailMock.calls = 0;
woUpliftsMock.calls = 0;
});
it("shows unavailable sections without an error toast and keeps Revoke admin-only", async () => {
renderWithGovernedClient();
await waitFor(() => {
expect(boardDetailMock.calls).toBeGreaterThan(0);
expect(woUpliftsMock.calls).toBeGreaterThan(0);
});
await waitFor(() => {
expect(screen.getAllByText("Unavailable").length).toBeGreaterThanOrEqual(3);
});
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
expect(toastMocks.error).not.toHaveBeenCalled();
});
});

View file

@ -51,7 +51,7 @@ const baseItem: UpliftQueueItem = {
workOrderClosed: null,
};
function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
function renderModal(overrides: Partial<UpliftQueueItem> = {}, canRevoke = true) {
const onApprove = vi.fn();
const onReject = vi.fn();
const onRevoke = vi.fn();
@ -59,6 +59,7 @@ function renderModal(overrides: Partial<UpliftQueueItem> = {}) {
renderWithProviders(
<UpliftDetailModal
item={{ ...baseItem, ...overrides }}
canRevoke={canRevoke}
approvePending={false}
revokePending={false}
onClose={vi.fn()}
@ -133,4 +134,27 @@ describe("UpliftDetailModal", () => {
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
it("disables Revoke with the admin-only tooltip for non-admin users", async () => {
const { onRevoke } = renderModal({ status: "Approved", workOrderClosed: false }, false);
const revoke = screen.getByRole("button", { name: "Revoke" });
expect(revoke).toBeDisabled();
fireEvent.mouseOver(revoke);
expect(await screen.findByText("Only admins can revoke uplifts")).toBeInTheDocument();
fireEvent.click(revoke);
expect(onRevoke).not.toHaveBeenCalled();
});
it("does not render a heading nested inside the dialog title heading", () => {
renderModal();
const dialog = screen.getByRole("dialog");
const headings = dialog.querySelectorAll("h1, h2, h3, h4, h5, h6");
expect(headings.length).toBeGreaterThan(0);
const nestedHeadings = [...headings].filter((heading) =>
heading.querySelector("h1, h2, h3, h4, h5, h6"),
);
expect(nestedHeadings).toEqual([]);
});
});

View file

@ -64,6 +64,11 @@ vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
useRevokeUplift: () => ({ isPending: false, mutate: revokeMutate }),
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<object>()),
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
}));
describe("Uplift Approvals decision flow", () => {
beforeEach(() => {
rejectMutate.mockReset();

View file

@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAppQueryClient } from "@/lib/query/query-client";
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("react-toastify", () => ({
toast: { error: toastMocks.error },
}));
describe("createAppQueryClient query cache", () => {
beforeEach(() => {
toastMocks.error.mockReset();
});
it("does not toast for queries that suppress the error toast", async () => {
const queryClient = createAppQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["suppressed-403"],
queryFn: () =>
Promise.reject(new Error("You do not have permission to perform this action.")),
meta: { suppressErrorToast: true },
}),
).rejects.toThrow("You do not have permission");
expect(toastMocks.error).not.toHaveBeenCalled();
});
it("still toasts for queries without suppression", async () => {
const queryClient = createAppQueryClient();
await expect(
queryClient.fetchQuery({
queryKey: ["unsuppressed-failure"],
queryFn: () => Promise.reject(new Error("A server error occurred.")),
}),
).rejects.toThrow("A server error occurred.");
expect(toastMocks.error).toHaveBeenCalledWith("A server error occurred.");
});
});