diff --git a/__tests__/admin/useAdminTeam.test.jsx b/__tests__/admin/useAdminTeam.test.jsx
index acfed581..099d3e30 100644
--- a/__tests__/admin/useAdminTeam.test.jsx
+++ b/__tests__/admin/useAdminTeam.test.jsx
@@ -11,10 +11,16 @@ import { renderHook, waitFor, act } from "@testing-library/react";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+const mockUser = { id: "me", role: "admin", tier: "super_admin" };
// Authenticated super-admin so the hook's fail-closed guard lets it fetch.
vi.mock("@/hooks/useAuth", () => ({
- default: () => ({ user: { id: "me", role: "admin", tier: "super_admin" }, loading: false }),
- useAuth: () => ({ user: { id: "me", role: "admin", tier: "super_admin" }, loading: false }),
+ default: () => ({ user: mockUser, loading: false }),
+ useAuth: () => ({ user: mockUser, loading: false }),
+}));
+
+vi.mock("@/lib/admin/audit", () => ({
+ logAuditEvent: vi.fn(),
+ AUDIT_ACTIONS: { ROLE_DEMOTE: "role_demote", ROLE_REVOKE: "role_revoke" },
}));
vi.mock("@/lib/auth/admin-tiers", () => ({
@@ -43,6 +49,9 @@ const STAFF = { id: "a2", name: "Bilal", email: "bilal@x.org", tier: "staff" };
beforeEach(() => {
vi.clearAllMocks();
mocks.listAdmins.mockResolvedValue({ admins: [{ ...SUPER }, { ...STAFF }] });
+ mocks.demoteAdmin.mockResolvedValue({ admin: { id: "a1", tier: "staff" } });
+ mocks.revokeAdmin.mockResolvedValue({ revoked: true, adminId: "a2" });
+ mocks.createInvite.mockResolvedValue({ invite: { token: "inv_mock_x", url: "u", expiresAt: "e" } });
});
async function mountLoaded() {
@@ -59,7 +68,7 @@ describe("useAdminTeam — load", () => {
});
it("surfaces a message when the list fails to load", async () => {
- mocks.listAdmins.mockRejectedValueOnce(new Error("network down"));
+ mocks.listAdmins.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useAdminTeam());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBe("network down");
@@ -68,7 +77,7 @@ describe("useAdminTeam — load", () => {
describe("useAdminTeam — demote", () => {
it("updates the member's tier to staff on success", async () => {
- mocks.demoteAdmin.mockResolvedValueOnce({ admin: { id: "a1", tier: "staff" } });
+ mocks.demoteAdmin.mockResolvedValue({ admin: { id: "a1", tier: "staff" } });
const { result } = await mountLoaded();
await act(async () => {
diff --git a/__tests__/verification/VerificationPage.test.jsx b/__tests__/verification/VerificationPage.test.jsx
index 5f7ef095..df3d3951 100644
--- a/__tests__/verification/VerificationPage.test.jsx
+++ b/__tests__/verification/VerificationPage.test.jsx
@@ -1,32 +1,33 @@
-/**
+/**
* VerificationPage - status center component tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
-import { VERIFICATION_STATUS } from "@/lib/actions/educators/fetchVerificationStatus";
-
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) }));
-vi.mock("react-ripples", () => ({
- default: ({ children, onClick, className }) => (
- {children}
- ),
-}));
-
-vi.mock("@/lib/config/env", () => ({
- config: { livenessProvider: "mock", livenessConsentVersion: "1.0.0", livenessTimeoutSeconds: 60 },
-}));
-
-vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+const VERIFICATION_STATUS = {
+ NOT_STARTED: "not_started",
+ INCOMPLETE: "incomplete",
+ PENDING: "pending",
+ UNDER_REVIEW: "under_review",
+ REJECTED: "rejected",
+ VERIFIED: "verified",
+};
-// vi.hoisted ensures these are available before vi.mock factories run
const mockFetchSignedUrl = vi.hoisted(() => vi.fn());
-vi.mock("@/lib/actions/educators/fetchVerificationStatus", async () => {
- const actual = await vi.importActual("@/lib/actions/educators/fetchVerificationStatus");
- return { ...actual, fetchDocumentSignedUrl: mockFetchSignedUrl };
-});
+vi.mock("@/lib/actions/educators/fetchVerificationStatus", () => ({
+ VERIFICATION_STATUS: {
+ NOT_STARTED: "not_started",
+ INCOMPLETE: "incomplete",
+ PENDING: "pending",
+ UNDER_REVIEW: "under_review",
+ REJECTED: "rejected",
+ VERIFIED: "verified",
+ },
+ fetchDocumentSignedUrl: mockFetchSignedUrl,
+}));
const _hookState = vi.hoisted(() => ({ current: null }));
vi.mock("@/hooks/useVerificationStatus", () => ({
@@ -57,7 +58,7 @@ function makeHook(overrides = {}) {
};
}
-// Lazy-import so mocks are registered before the component module loads
+import VerificationPage from "@/app/[locale]/account/verification/page";
// Click a Button component (onClick lives on the inner Ripples span)
function clickBtn(testId) {
@@ -65,15 +66,11 @@ function clickBtn(testId) {
const inner = btn && (btn.querySelector('span') || btn);
if (inner) fireEvent.click(inner);
}
-let _VerificationPage;
-beforeEach(async () => {
+
+beforeEach(() => {
_hookState.current = makeHook();
mockPush.mockClear();
mockFetchSignedUrl.mockClear();
- if (!_VerificationPage) {
- const mod = await import("@/app/account/verification/page");
- _VerificationPage = mod.default;
- }
});
afterEach(() => vi.clearAllMocks());
@@ -81,20 +78,20 @@ afterEach(() => vi.clearAllMocks());
describe("VerificationPage - header", () => {
it("renders page title and status badge", () => {
- render(<_VerificationPage />);
+ render();
expect(screen.getByText("Verification")).toBeInTheDocument();
expect(screen.getByTestId("status-badge")).toBeInTheDocument();
});
it("status badge shows Incomplete", () => {
- render(<_VerificationPage />);
+ render();
expect(screen.getByTestId("status-badge")).toHaveTextContent(/incomplete/i);
});
});
describe("VerificationPage - status panels", () => {
it("incomplete: Continue CTA routes to step 2", () => {
- render(<_VerificationPage />);
+ render();
const cta = screen.getByTestId("status-cta-btn");
expect(cta).toHaveTextContent(/continue verification/i);
clickBtn("status-cta-btn");
@@ -107,7 +104,7 @@ describe("VerificationPage - status panels", () => {
resumeStep: 1,
data: { timeline: [], documents: [], rejectionReason: null },
});
- render(<_VerificationPage />);
+ render();
clickBtn("empty-panel-cta-btn");
expect(mockPush).toHaveBeenCalledWith("/educator-onboarding?step=1");
});
@@ -117,7 +114,7 @@ describe("VerificationPage - status panels", () => {
status: VERIFICATION_STATUS.PENDING, isPending: true, isIncomplete: false,
data: { timeline: TIMELINE, documents: [], rejectionReason: null },
});
- render(<_VerificationPage />);
+ render();
expect(screen.queryByTestId("status-cta-btn")).not.toBeInTheDocument();
expect(screen.getByText(/application submitted/i)).toBeInTheDocument();
});
@@ -127,7 +124,7 @@ describe("VerificationPage - status panels", () => {
status: VERIFICATION_STATUS.UNDER_REVIEW, isPending: true, isIncomplete: false,
data: { timeline: TIMELINE, documents: [], rejectionReason: null },
});
- render(<_VerificationPage />);
+ render();
expect(screen.queryByTestId("status-cta-btn")).not.toBeInTheDocument();
expect(screen.getByText(/application under review/i)).toBeInTheDocument();
});
@@ -137,7 +134,7 @@ describe("VerificationPage - status panels", () => {
status: VERIFICATION_STATUS.VERIFIED, isVerified: true, isIncomplete: false,
data: { timeline: TIMELINE, documents: [], rejectionReason: null },
});
- render(<_VerificationPage />);
+ render();
expect(screen.getByText(/verified educator/i)).toBeInTheDocument();
expect(screen.queryByTestId("status-cta-btn")).not.toBeInTheDocument();
});
@@ -152,12 +149,12 @@ describe("VerificationPage - rejected state", () => {
});
it("shows rejection panel with reason", () => {
- render(<_VerificationPage />);
+ render();
expect(screen.getByText(/photo was not legible/i)).toBeInTheDocument();
});
it("resubmit CTA routes to step 1", () => {
- render(<_VerificationPage />);
+ render();
expect(screen.getByTestId("status-cta-btn")).toHaveTextContent(/resubmit/i);
clickBtn("status-cta-btn");
expect(mockPush).toHaveBeenCalledWith("/educator-onboarding?step=1");
@@ -165,14 +162,14 @@ describe("VerificationPage - rejected state", () => {
it("no rejection panel for non-rejected status", () => {
_hookState.current = makeHook();
- render(<_VerificationPage />);
+ render();
expect(screen.queryByTestId("rejection-panel")).not.toBeInTheDocument();
});
});
describe("VerificationPage - timeline", () => {
it("renders timeline panel with entries", () => {
- render(<_VerificationPage />);
+ render();
expect(screen.getByText("Identity verification")).toBeInTheDocument();
});
});
@@ -183,7 +180,7 @@ describe("VerificationPage - documents (masked)", () => {
});
it("renders masked filename - no raw URL", () => {
- render(<_VerificationPage />);
+ render();
const fn = screen.getByText("passport.jpg");
expect(fn).toBeInTheDocument();
expect(fn.textContent).not.toMatch(/^https?:\/\//);
@@ -192,7 +189,7 @@ describe("VerificationPage - documents (masked)", () => {
it("View button fetches signed URL and opens new tab with noopener", async () => {
mockFetchSignedUrl.mockResolvedValue({ signedUrl: "https://cdn.example.com/signed?token=xyz", expiresAt: "2024-01-01T01:00:00Z" });
const spy = vi.spyOn(window, "open").mockImplementation(() => null);
- render(<_VerificationPage />);
+ render();
clickBtn("doc-view-btn");
await waitFor(() => expect(mockFetchSignedUrl).toHaveBeenCalledWith("doc_1"));
await waitFor(() => expect(spy).toHaveBeenCalledWith(expect.stringContaining("signed?token="), "_blank", "noopener,noreferrer"));
@@ -203,7 +200,7 @@ describe("VerificationPage - documents (masked)", () => {
describe("VerificationPage - error state", () => {
it("renders error message", () => {
_hookState.current = makeHook({ loading: false, error: "Network timeout", data: null });
- render(<_VerificationPage />);
+ render();
expect(screen.getByText(/could not load verification status/i)).toBeInTheDocument();
expect(screen.getByText(/network timeout/i)).toBeInTheDocument();
});
@@ -211,7 +208,7 @@ describe("VerificationPage - error state", () => {
describe("VerificationPage - refresh button", () => {
it("calls refresh() when clicked", async () => {
- render(<_VerificationPage />);
+ render();
clickBtn("refresh-btn");
await waitFor(() => expect(_hookState.current.refresh).toHaveBeenCalledOnce());
});
diff --git a/app/[locale]/admin/audit-logs/page.jsx b/app/[locale]/admin/audit-logs/page.jsx
index 89da00fb..52d535d3 100644
--- a/app/[locale]/admin/audit-logs/page.jsx
+++ b/app/[locale]/admin/audit-logs/page.jsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useMemo, useCallback, useEffect } from "react";
+import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
import { PageShell } from "@/components/ui/page-shell";
import { PageHeader } from "@/components/ui/page-header";
@@ -49,7 +49,7 @@ import {
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
-import { poppins_400, poppins_500, poppins_600 } from "@/lib/config/font.config";
+import { poppins_400, poppins_500 } from "@/lib/config/font.config";
import { format } from "date-fns";
// Action categories
@@ -130,6 +130,14 @@ const getTargetLink = (target) => {
return links[target.type] || "#";
};
+function formatDateRange(range) {
+ if (!range?.from) return "Select date range";
+ if (range.to) {
+ return `${format(range.from, "LLL dd")} - ${format(range.to, "LLL dd")}`;
+ }
+ return format(range.from, "LLL dd, y");
+}
+
export default function AuditLogsPage() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
@@ -225,7 +233,7 @@ export default function AuditLogsPage() {
{/* Actor Filter */}
-
+
Admin Actor