From 2eff67241d15dfcdd22975b146bd271bc64c46f6 Mon Sep 17 00:00:00 2001 From: Michelle Nifemi Date: Sat, 30 May 2026 22:06:55 +0100 Subject: [PATCH 1/2] feat: add pending approvals badge --- package-lock.json | 32 +++---- package.json | 4 +- public/mockServiceWorker.js | 2 +- .../ApproveRejectButtons.test.tsx | 4 + .../badges/PendingApprovalBadge.tsx | 25 ++++++ .../__tests__/PendingApprovalBadge.test.tsx | 89 +++++++++++++++++++ .../__tests__/CompleteAdoptionButton.test.tsx | 4 + src/components/layout/ApprovalBanner.tsx | 16 ++-- src/components/layout/Navbar.tsx | 35 +++++++- .../__tests__/SettlementFailureState.test.tsx | 14 +++ src/hooks/usePendingApprovalsCount.ts | 48 +++++++--- src/hooks/useRoleGuard.ts | 25 +++--- src/main.tsx | 2 +- src/mocks/handlers/approval.ts | 16 ++++ 14 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 src/components/badges/PendingApprovalBadge.tsx create mode 100644 src/components/badges/__tests__/PendingApprovalBadge.test.tsx diff --git a/package-lock.json b/package-lock.json index 1fd70b3..b1d49b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "license": "MIT-0", "dependencies": { + "@mswjs/interceptors": "^0.41.9", "@tailwindcss/vite": "^4.2.0", "@tanstack/react-query": "^5.100.14", "axios": "^1.16.1", "date-fns": "^4.1.0", + "graphql": "^16.13.2", "lucide-react": "^0.575.0", "react": "^19.2.4", "react-copy-to-clipboard": "^5.1.1", @@ -40,7 +42,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^29.0.1", - "msw": "^2.12.14", + "msw": "^2.14.6", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1", @@ -1422,10 +1424,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.41.6", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.6.tgz", - "integrity": "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==", - "dev": true, + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", "license": "MIT", "dependencies": { "@open-draft/deferred-promise": "^2.2.0", @@ -1443,7 +1444,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, "license": "MIT" }, "node_modules/@open-draft/deferred-promise": { @@ -1457,7 +1457,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, "license": "MIT", "dependencies": { "is-node-process": "^1.2.0", @@ -1468,7 +1467,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, "license": "MIT" }, "node_modules/@playwright/test": { @@ -4062,7 +4060,6 @@ "version": "16.13.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", - "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -4255,7 +4252,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, "license": "MIT" }, "node_modules/is-potential-custom-element-name": { @@ -5022,9 +5018,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.13.6", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.6.tgz", - "integrity": "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g==", + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5040,7 +5036,7 @@ "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.11.7", + "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", @@ -5150,7 +5146,6 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, "license": "MIT" }, "node_modules/p-limit": { @@ -5554,9 +5549,9 @@ } }, "node_modules/rettime": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz", - "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==", + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", "dev": true, "license": "MIT" }, @@ -5753,7 +5748,6 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, "license": "MIT" }, "node_modules/string-width": { diff --git a/package.json b/package.json index 52a18d1..f40c315 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "e2e": "playwright test" }, "dependencies": { + "@mswjs/interceptors": "^0.41.9", "@tailwindcss/vite": "^4.2.0", "@tanstack/react-query": "^5.100.14", "axios": "^1.16.1", "date-fns": "^4.1.0", + "graphql": "^16.13.2", "lucide-react": "^0.575.0", "react": "^19.2.4", "react-copy-to-clipboard": "^5.1.1", @@ -47,7 +49,7 @@ "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^29.0.1", - "msw": "^2.12.14", + "msw": "^2.14.6", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1", diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index 80f1930..33dde9e 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.13.6' +const PACKAGE_VERSION = '2.14.6' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx b/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx index a5b70ed..7ed394f 100644 --- a/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx +++ b/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx @@ -29,7 +29,9 @@ describe('ApproveRejectButtons', () => { mockUseRoleGuard.mockReturnValue({ role: 'admin', isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); @@ -61,7 +63,9 @@ describe('ApproveRejectButtons', () => { mockUseRoleGuard.mockReturnValue({ role: 'user', isAdmin: false, + isShelter: false, isUser: true, + canApprove: false, hasAccess: vi.fn().mockReturnValue(false), }); diff --git a/src/components/badges/PendingApprovalBadge.tsx b/src/components/badges/PendingApprovalBadge.tsx new file mode 100644 index 0000000..e9c727e --- /dev/null +++ b/src/components/badges/PendingApprovalBadge.tsx @@ -0,0 +1,25 @@ +import { usePendingApprovalsCount } from "../../hooks/usePendingApprovalsCount"; +import { useRoleGuard } from "../../hooks/useRoleGuard"; + +interface PendingApprovalBadgeProps { + className?: string; +} + +export function PendingApprovalBadge({ className = "" }: PendingApprovalBadgeProps) { + const { canApprove } = useRoleGuard(); + const { count, displayCount } = usePendingApprovalsCount(); + + if (!canApprove || count === 0) { + return null; + } + + return ( + + {displayCount} + + ); +} diff --git a/src/components/badges/__tests__/PendingApprovalBadge.test.tsx b/src/components/badges/__tests__/PendingApprovalBadge.test.tsx new file mode 100644 index 0000000..6aa9f6e --- /dev/null +++ b/src/components/badges/__tests__/PendingApprovalBadge.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PendingApprovalBadge } from "../PendingApprovalBadge"; + +const mockUsePendingApprovalsCount = vi.fn(); +const mockUseRoleGuard = vi.fn(); + +vi.mock("../../../hooks/usePendingApprovalsCount", () => ({ + usePendingApprovalsCount: () => mockUsePendingApprovalsCount(), +})); + +vi.mock("../../../hooks/useRoleGuard", () => ({ + useRoleGuard: () => mockUseRoleGuard(), +})); + +describe("PendingApprovalBadge", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseRoleGuard.mockReturnValue({ + role: "ADMIN", + isAdmin: true, + isShelter: false, + isUser: false, + canApprove: true, + hasAccess: vi.fn().mockReturnValue(true), + }); + }); + + it("renders the exact count when it is below 10", () => { + mockUsePendingApprovalsCount.mockReturnValue({ + count: 4, + displayCount: "4", + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.getByTestId("pending-approval-badge")).toHaveTextContent("4"); + }); + + it('caps the visible label at "9+" above 9', () => { + mockUsePendingApprovalsCount.mockReturnValue({ + count: 12, + displayCount: "9+", + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.getByTestId("pending-approval-badge")).toHaveTextContent("9+"); + }); + + it("hides the badge for non-approval roles", () => { + mockUseRoleGuard.mockReturnValue({ + role: "USER", + isAdmin: false, + isShelter: false, + isUser: true, + canApprove: false, + hasAccess: vi.fn().mockReturnValue(false), + }); + + mockUsePendingApprovalsCount.mockReturnValue({ + count: 6, + displayCount: "6", + isLoading: false, + isError: false, + }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("hides the badge when the count is zero", () => { + mockUsePendingApprovalsCount.mockReturnValue({ + count: 0, + displayCount: "0", + isLoading: false, + isError: false, + }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx b/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx index 2b497d1..4c44e68 100644 --- a/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx +++ b/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx @@ -25,7 +25,9 @@ beforeEach(() => { vi.mocked(useRoleGuard).mockReturnValue({ role: "admin", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); @@ -49,7 +51,9 @@ describe("CompleteAdoptionButton", () => { vi.mocked(useRoleGuard).mockReturnValue({ role: "user", isAdmin: false, + isShelter: false, isUser: true, + canApprove: false, hasAccess: vi.fn().mockReturnValue(false), }); const { container } = render(); diff --git a/src/components/layout/ApprovalBanner.tsx b/src/components/layout/ApprovalBanner.tsx index d9e69e9..055552f 100644 --- a/src/components/layout/ApprovalBanner.tsx +++ b/src/components/layout/ApprovalBanner.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { usePendingApprovalsCount } from "../../hooks/usePendingApprovalsCount"; +import { useRoleGuard } from "../../hooks/useRoleGuard"; import { setBannerDismissed, shouldShowBanner, @@ -8,18 +9,21 @@ import { Link } from "react-router-dom"; export default function ApprovalBanner() { const { count, isLoading } = usePendingApprovalsCount(); + const { canApprove } = useRoleGuard(); const [visible, setVisible] = useState(false); - const role = localStorage.getItem("role"); - const allowed = role === "ADMIN" || role === "SHELTER"; - useEffect(() => { - if (!allowed || isLoading) return; + if (!canApprove || isLoading) return; + + if (count === 0) { + setVisible(false); + return; + } if (count > 0 && shouldShowBanner(count)) { setVisible(true); } - }, [count, isLoading, allowed]); + }, [count, isLoading, canApprove]); if (!visible) return null; @@ -43,4 +47,4 @@ export default function ApprovalBanner() { ); -} \ No newline at end of file +} diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 23ea757..7acdd33 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -1,17 +1,41 @@ import { Link, useLocation } from "react-router-dom"; -import { House, Eye, List, Heart, ChevronDown } from "lucide-react"; +import { House, Eye, List, Heart, ChevronDown, ClipboardList } from "lucide-react"; import logo from "../../assets/logo.svg"; import owner from "../../assets/owner.png"; +import { PendingApprovalBadge } from "../badges/PendingApprovalBadge"; +import { useRoleGuard } from "../../hooks/useRoleGuard"; import { NotificationCentreDropdown } from "../notifications"; -const navLinks = [ +const baseNavLinks = [ { label: "Home", path: "/home", icon: House }, { label: "Interests", path: "/interests", icon: Eye }, { label: "Listings", path: "/listings", icon: List }, ]; +type NavLink = { + label: string; + path: string; + icon: typeof House; + hasBadge?: boolean; +}; + export function Navbar() { const location = useLocation(); + const { canApprove, isAdmin } = useRoleGuard(); + + const navLinks: NavLink[] = [ + ...baseNavLinks, + ...(canApprove + ? [ + { + label: "Approvals", + path: isAdmin ? "/admin/approvals" : "/shelter/approvals", + icon: ClipboardList, + hasBadge: true, + }, + ] + : []), + ]; return ( ); -} \ No newline at end of file +} diff --git a/src/components/listings/__tests__/SettlementFailureState.test.tsx b/src/components/listings/__tests__/SettlementFailureState.test.tsx index bf4ef08..a76cba1 100644 --- a/src/components/listings/__tests__/SettlementFailureState.test.tsx +++ b/src/components/listings/__tests__/SettlementFailureState.test.tsx @@ -56,7 +56,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "user", isAdmin: false, + isShelter: false, isUser: true, + canApprove: false, hasAccess: vi.fn().mockReturnValue(false), }); mockRetrySettlement.mockResolvedValue(undefined); @@ -82,7 +84,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "user", isAdmin: false, + isShelter: false, isUser: true, + canApprove: false, hasAccess: vi.fn().mockReturnValue(false), }); renderComponent(); @@ -93,7 +97,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "admin", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); renderComponent(); @@ -104,7 +110,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "admin", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); @@ -123,7 +131,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "admin", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); @@ -142,7 +152,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "admin", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); @@ -160,7 +172,9 @@ describe("SettlementFailureState", () => { mockUseRoleGuard.mockReturnValue({ role: "admin", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); diff --git a/src/hooks/usePendingApprovalsCount.ts b/src/hooks/usePendingApprovalsCount.ts index f88a157..4fb2aed 100644 --- a/src/hooks/usePendingApprovalsCount.ts +++ b/src/hooks/usePendingApprovalsCount.ts @@ -1,16 +1,38 @@ -import { useEffect, useState } from "react"; +import { useApiQuery } from "./useApiQuery"; +import { useRoleGuard } from "./useRoleGuard"; +import { apiClient } from "../lib/api-client"; -export const usePendingApprovalsCount = () => { - const [count, setCount] = useState(0); - const [isLoading, setLoading] = useState(true); +const POLL_INTERVAL_MS = 300_000; +const MAX_DISPLAY = 9; - useEffect(() => { - fetch("/shelter/approvals?status=PENDING&limit=0") - .then((res) => res.json()) - .then((data) => setCount(data?.count || 0)) - .catch(() => setCount(0)) - .finally(() => setLoading(false)); - }, []); +interface PendingApprovalsResponse { + count?: number; + total?: number; +} - return { count, isLoading }; -}; \ No newline at end of file +export function usePendingApprovalsCount() { + const { canApprove } = useRoleGuard(); + + const query = useApiQuery( + ["pending-approvals-count"], + () => + apiClient.get( + "/shelter/approvals?status=PENDING&limit=0", + ), + { + enabled: canApprove, + refetchInterval: POLL_INTERVAL_MS, + refetchIntervalInBackground: true, + staleTime: 0, + }, + ); + + const count = query.data?.count ?? query.data?.total ?? 0; + + return { + count, + displayCount: count > MAX_DISPLAY ? `${MAX_DISPLAY}+` : String(count), + isLoading: query.isLoading, + isError: query.isError, + }; +} diff --git a/src/hooks/useRoleGuard.ts b/src/hooks/useRoleGuard.ts index e244a15..b3c98d8 100644 --- a/src/hooks/useRoleGuard.ts +++ b/src/hooks/useRoleGuard.ts @@ -1,27 +1,32 @@ +import type { UserRole } from "../types/auth"; + /** * useRoleGuard * * Reads the current user's role and exposes convenience booleans. - * Role is stored under the "petad_user_role" key in localStorage so it can be - * swapped for a React context / auth provider once one is wired up. - * - * Supported role values: "admin" | "user" + * We normalize the stored value so older lowercase role flags and the current + * uppercase auth roles can coexist safely during the transition. */ export function useRoleGuard() { - const role = + const storedRole = typeof window !== "undefined" - ? localStorage.getItem("petad_user_role") + ? localStorage.getItem("petad_user_role") ?? localStorage.getItem("role") : null; + const normalizedRole = storedRole?.toUpperCase() ?? ""; + const role = normalizedRole as UserRole | ""; + const hasAccess = (roles: string[]) => { if (!role) return false; - return roles.includes(role); + return roles.map((value) => value.toUpperCase()).includes(role); }; return { - role: role || "", - isAdmin: role === "admin", - isUser: role === "user", + role, + isAdmin: role === "ADMIN", + isShelter: role === "SHELTER", + isUser: role === "USER", + canApprove: role === "ADMIN" || role === "SHELTER", hasAccess, }; } diff --git a/src/main.tsx b/src/main.tsx index 124de81..8d7d52a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -52,7 +52,7 @@ async function bootstrap() { return } - if (import.meta.env.VITE_MSW === 'true') { + if (import.meta.env.DEV && import.meta.env.VITE_MSW === 'true') { const { worker } = await import('./mocks/browser') await worker.start({ onUnhandledRequest: 'warn', diff --git a/src/mocks/handlers/approval.ts b/src/mocks/handlers/approval.ts index 637d2dd..47c16f7 100644 --- a/src/mocks/handlers/approval.ts +++ b/src/mocks/handlers/approval.ts @@ -30,6 +30,22 @@ export const approvalHandlers = [ ]); }), + // GET /api/shelter/approvals?status=PENDING&limit=0 — pending approval count + http.get(`${BASE_URL}/shelter/approvals`, async ({ request }: { request: Request }) => { + await delay(500); + const url = new URL(request.url); + const status = url.searchParams.get("status"); + const limit = url.searchParams.get("limit"); + + if (status !== "PENDING" || limit !== "0") { + return HttpResponse.json({ count: 0 }); + } + + return HttpResponse.json({ + count: 12, + }); + }), + // GET /api/admin/approvals — admin approval queue http.get(`${BASE_URL}/admin/approvals`, async ({ request }: { request: Request }) => { await delay(1000); From 7feefbd41bcbf2b96722fc3e20dfd0158a2a8b81 Mon Sep 17 00:00:00 2001 From: Michelle Nifemi Date: Thu, 4 Jun 2026 18:54:02 +0100 Subject: [PATCH 2/2] fix: align role mocks with auth contract --- package-lock.json | 3 +- package.json | 1 + .../ApproveRejectButtons.test.tsx | 10 +-- .../__tests__/CompleteAdoptionButton.test.tsx | 4 +- .../__tests__/SettlementFailureState.test.tsx | 26 +++--- .../hooks/__tests__/useDisputeCount.test.tsx | 83 ++++++++++++++++--- 6 files changed, 98 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1d49b6..14e542c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.0", + "type-fest": "^5.6.0", "undici": "^8.1.0" }, "devDependencies": { @@ -5828,7 +5829,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -5981,7 +5981,6 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" diff --git a/package.json b/package.json index f40c315..ee7a525 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.1", "tailwindcss": "^4.2.0", + "type-fest": "^5.6.0", "undici": "^8.1.0" }, "devDependencies": { diff --git a/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx b/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx index 7ed394f..f97ddf5 100644 --- a/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx +++ b/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx @@ -27,7 +27,7 @@ describe('ApproveRejectButtons', () => { vi.clearAllMocks(); mockUseRoleGuard.mockReturnValue({ - role: 'admin', + role: 'ADMIN', isAdmin: true, isShelter: false, isUser: false, @@ -37,7 +37,7 @@ describe('ApproveRejectButtons', () => { mockUseAdoptionApprovals.mockReturnValue({ hasDecided: false, - requiredRoles: ['admin'], + requiredRoles: ['ADMIN'], mutateApprovalDecision: mockMutateApprovalDecision, isPending: false, }); @@ -50,7 +50,7 @@ describe('ApproveRejectButtons', () => { it('does NOT render when user already decided', () => { mockUseAdoptionApprovals.mockReturnValue({ hasDecided: true, - requiredRoles: ['admin'], + requiredRoles: ['ADMIN'], mutateApprovalDecision: mockMutateApprovalDecision, isPending: false, }); @@ -61,7 +61,7 @@ describe('ApproveRejectButtons', () => { it('does NOT render when user role not in requiredRoles', () => { mockUseRoleGuard.mockReturnValue({ - role: 'user', + role: 'USER', isAdmin: false, isShelter: false, isUser: true, @@ -122,7 +122,7 @@ describe('ApproveRejectButtons', () => { it('Buttons disabled during loading and Spinner visible when isPending === true', () => { mockUseAdoptionApprovals.mockReturnValue({ hasDecided: false, - requiredRoles: ['admin'], + requiredRoles: ['ADMIN'], mutateApprovalDecision: mockMutateApprovalDecision, isPending: true, }); diff --git a/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx b/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx index 4c44e68..44fc8cc 100644 --- a/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx +++ b/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx @@ -23,7 +23,7 @@ beforeEach(() => { // must include all fields useRoleGuard returns: role, isAdmin, isUser vi.mocked(useRoleGuard).mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, isShelter: false, isUser: false, @@ -49,7 +49,7 @@ const defaultProps = { describe("CompleteAdoptionButton", () => { it("is hidden for non-admins", () => { vi.mocked(useRoleGuard).mockReturnValue({ - role: "user", + role: "USER", isAdmin: false, isShelter: false, isUser: true, diff --git a/src/components/listings/__tests__/SettlementFailureState.test.tsx b/src/components/listings/__tests__/SettlementFailureState.test.tsx index a76cba1..c71134b 100644 --- a/src/components/listings/__tests__/SettlementFailureState.test.tsx +++ b/src/components/listings/__tests__/SettlementFailureState.test.tsx @@ -54,7 +54,7 @@ describe("SettlementFailureState", () => { beforeEach(() => { vi.clearAllMocks(); mockUseRoleGuard.mockReturnValue({ - role: "user", + role: "USER", isAdmin: false, isShelter: false, isUser: true, @@ -82,7 +82,7 @@ describe("SettlementFailureState", () => { it("hides the retry button for non-admin users", () => { mockUseRoleGuard.mockReturnValue({ - role: "user", + role: "USER", isAdmin: false, isShelter: false, isUser: true, @@ -95,7 +95,7 @@ describe("SettlementFailureState", () => { it("shows the retry button for admin users", () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, isShelter: false, isUser: false, @@ -108,7 +108,7 @@ describe("SettlementFailureState", () => { it("shows the confirmation modal when admin clicks retry, before mutation fires", () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, isShelter: false, isUser: false, @@ -129,7 +129,7 @@ describe("SettlementFailureState", () => { it("closes the confirmation modal when cancel is clicked without calling mutation", () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, isShelter: false, isUser: false, @@ -150,7 +150,7 @@ describe("SettlementFailureState", () => { it("calls retrySettlement with the correct escrowId after confirming", async () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, isShelter: false, isUser: false, @@ -170,7 +170,7 @@ describe("SettlementFailureState", () => { it("shows a spinner on the confirm button while the retry is in progress", async () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, isShelter: false, isUser: false, @@ -203,9 +203,11 @@ describe("SettlementFailureState", () => { it("shows a success toast after a successful retry", async () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); mockRetrySettlement.mockResolvedValue(undefined); @@ -226,9 +228,11 @@ describe("SettlementFailureState", () => { it("shows an error toast when the retry fails", async () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); mockRetrySettlement.mockRejectedValue( @@ -251,9 +255,11 @@ describe("SettlementFailureState", () => { it("calls the onRetry prop after a successful retry", async () => { mockUseRoleGuard.mockReturnValue({ - role: "admin", + role: "ADMIN", isAdmin: true, + isShelter: false, isUser: false, + canApprove: true, hasAccess: vi.fn().mockReturnValue(true), }); mockRetrySettlement.mockResolvedValue(undefined); diff --git a/src/lib/hooks/__tests__/useDisputeCount.test.tsx b/src/lib/hooks/__tests__/useDisputeCount.test.tsx index b01dc54..c864c00 100644 --- a/src/lib/hooks/__tests__/useDisputeCount.test.tsx +++ b/src/lib/hooks/__tests__/useDisputeCount.test.tsx @@ -43,7 +43,14 @@ describe("useDisputeCount", () => { }); it("admin sees total count of open and under_review disputes", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: true, + isShelter: false, + isUser: false, + canApprove: true, + role: "ADMIN", + hasAccess: vi.fn().mockReturnValue(true), + }); // open: 2 disputes, under_review: 1 dispute → total: 3 mockGet .mockResolvedValueOnce({ data: [{ id: "d1" }, { id: "d2" }] }) @@ -59,7 +66,14 @@ describe("useDisputeCount", () => { }); it("admin fetches both open and under_review endpoints", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: true, + isShelter: false, + isUser: false, + canApprove: true, + role: "ADMIN", + hasAccess: vi.fn().mockReturnValue(true), + }); mockGet .mockResolvedValueOnce({ data: [] }) .mockResolvedValueOnce({ data: [] }); @@ -75,7 +89,14 @@ describe("useDisputeCount", () => { }); it("user sees only their own open disputes (single endpoint)", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: false, + isShelter: false, + isUser: true, + canApprove: false, + role: "USER", + hasAccess: vi.fn().mockReturnValue(false), + }); mockGet.mockResolvedValueOnce({ data: [{ id: "d1" }] }); const queryClient = createTestQueryClient(); @@ -89,7 +110,14 @@ describe("useDisputeCount", () => { }); it("displays '9+' when count exceeds 9", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: true, + isShelter: false, + isUser: false, + canApprove: true, + role: "ADMIN", + hasAccess: vi.fn().mockReturnValue(true), + }); // open: 7, under_review: 5 → total: 12 mockGet .mockResolvedValueOnce({ data: Array.from({ length: 7 }, (_, i) => ({ id: `o${i}` })) }) @@ -105,7 +133,14 @@ describe("useDisputeCount", () => { }); it("displays exact count when count is exactly 9", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: false, + isShelter: false, + isUser: true, + canApprove: false, + role: "USER", + hasAccess: vi.fn().mockReturnValue(false), + }); mockGet.mockResolvedValueOnce({ data: Array.from({ length: 9 }, (_, i) => ({ id: `d${i}` })), }); @@ -120,7 +155,14 @@ describe("useDisputeCount", () => { }); it("resets count to 0 when user visits /disputes", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: false, + isShelter: false, + isUser: true, + canApprove: false, + role: "USER", + hasAccess: vi.fn().mockReturnValue(false), + }); mockGet.mockResolvedValue({ data: [] }); const queryClient = createTestQueryClient(); @@ -135,7 +177,14 @@ describe("useDisputeCount", () => { }); it("resets count to 0 when admin visits /admin/disputes", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: true, + isShelter: false, + isUser: false, + canApprove: true, + role: "ADMIN", + hasAccess: vi.fn().mockReturnValue(true), + }); mockGet.mockResolvedValue({ data: [] }); const queryClient = createTestQueryClient(); @@ -149,7 +198,14 @@ describe("useDisputeCount", () => { }); it("does not reset count when visiting an unrelated route", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: false, + isShelter: false, + isUser: true, + canApprove: false, + role: "USER", + hasAccess: vi.fn().mockReturnValue(false), + }); // refetchOnMount: false → cached value persists, no immediate fetch mockGet.mockResolvedValue({ data: [] }); @@ -166,7 +222,14 @@ describe("useDisputeCount", () => { }); it("returns count 0 when no disputes exist", async () => { - mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" }); + mockUseRoleGuard.mockReturnValue({ + isAdmin: false, + isShelter: false, + isUser: true, + canApprove: false, + role: "USER", + hasAccess: vi.fn().mockReturnValue(false), + }); mockGet.mockResolvedValueOnce({ data: [] }); const queryClient = createTestQueryClient(); @@ -178,4 +241,4 @@ describe("useDisputeCount", () => { expect(result.current.count).toBe(0); expect(result.current.displayCount).toBe("0"); }); -}); \ No newline at end of file +});