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 @@ -264,22 +272,12 @@ export default function AuditLogsPage() { {/* Date Range */}
- + Date Range diff --git a/app/[locale]/admin/reconciliation/page.jsx b/app/[locale]/admin/reconciliation/page.jsx index e846027c..dc101e3d 100644 --- a/app/[locale]/admin/reconciliation/page.jsx +++ b/app/[locale]/admin/reconciliation/page.jsx @@ -34,7 +34,6 @@ import { XCircle, AlertTriangle, ExternalLink, - RefreshCw, Download, Loader2, Info, @@ -127,6 +126,14 @@ const getStellarExplorerUrl = (txHash) => { return `https://stellar.expert/explorer/public/tx/${txHash}`; }; +function formatDateRange(range) { + if (!range?.from) return "Select date range"; + if (range.to) { + return `${format(range.from, "LLL dd, y")} - ${format(range.to, "LLL dd, y")}`; + } + return format(range.from, "LLL dd, y"); +} + export default function PayoutReconciliationPage() { const [dateRange, setDateRange] = useState({ from: subDays(new Date(), 30), @@ -188,7 +195,7 @@ export default function PayoutReconciliationPage() { const csvContent = [ headers.join(","), ...rows.map((row) => - row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",") + row.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(",") ), ].join("\n"); @@ -236,22 +243,12 @@ export default function PayoutReconciliationPage() {
- + Date Range diff --git a/components/organisms/AnnouncementHistoryTable.jsx b/components/organisms/AnnouncementHistoryTable.jsx index 854563e0..ab5a40fa 100644 --- a/components/organisms/AnnouncementHistoryTable.jsx +++ b/components/organisms/AnnouncementHistoryTable.jsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect } from "react"; import { PageShell } from "@/components/ui/page-shell"; import { PageHeader } from "@/components/ui/page-header"; import { @@ -55,7 +55,7 @@ import { Users, } 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"; const STATE_CONFIG = { @@ -361,9 +361,9 @@ export default function AnnouncementHistoryTable() {
- +
{["all", "draft", "scheduled", "sent", "cancelled"].map( (state) => ( @@ -381,9 +381,9 @@ export default function AnnouncementHistoryTable() {
- +
{["all", "students", "educators", "admins"].map( (audience) => ( diff --git a/components/providers/AppProviders.jsx b/components/providers/AppProviders.jsx index 520fb9ff..48bac832 100644 --- a/components/providers/AppProviders.jsx +++ b/components/providers/AppProviders.jsx @@ -9,7 +9,6 @@ import StellarProvider from "@/components/stellar/StellarProvider"; import AdminIdleGuard from "@/components/auth/AdminIdleGuard"; import MaintenanceGate from "@/components/maintenance/MaintenanceGate"; import EmergencyBroadcastBanner from "@/components/broadcast/EmergencyBroadcastBanner"; -import AdminIdleGuard from "@/components/auth/AdminIdleGuard"; import AdminShortcutsProvider from "@/components/admin/AdminShortcutsProvider"; export default function AppProviders({ children }) { diff --git a/lib/actions/admin-reports.js b/lib/actions/admin-reports.js index 21d2f839..68e3f930 100644 --- a/lib/actions/admin-reports.js +++ b/lib/actions/admin-reports.js @@ -227,7 +227,7 @@ export async function fetchReportRows(datasetId, filters = {}, options = {}) { } const limit = options.limit || rows.length; - return Promise.resolve({ rows: rows.slice(0, limit) }); + return { rows: rows.slice(0, limit) }; } function readSavedQueries(userId) { @@ -268,7 +268,7 @@ function generateQueryId() { * @returns {Promise<{queries: Array}>} */ export async function listSavedQueries(userId) { - return Promise.resolve({ queries: readSavedQueries(userId) }); + return { queries: readSavedQueries(userId) }; } /** @@ -303,7 +303,7 @@ export async function saveQuery(userId, payload) { id: generateQueryId(), name, datasetId, - filters: { ...(payload.filters || {}) }, + filters: { ...payload.filters }, columns, createdAt: new Date().toISOString(), }; @@ -312,7 +312,7 @@ export async function saveQuery(userId, payload) { queries.unshift(query); writeSavedQueries(userId, queries); - return Promise.resolve({ query }); + return { query }; } /** @@ -329,7 +329,10 @@ export async function saveQuery(userId, payload) { export async function deleteSavedQuery(userId, queryId) { const queries = readSavedQueries(userId).filter((query) => query.id !== queryId); writeSavedQueries(userId, queries); - return Promise.resolve({ deleted: true, queryId }); + return { deleted: true, queryId }; +} + +/** * Admin moderation-reports service — list, read, and act on user reports. * --------------------------------------------------------------------------- * **STUBBED (#289).** The moderation-reports backend does not exist yet, so @@ -509,7 +512,9 @@ function getStore() { /** Deep-ish clone so callers can't mutate the store by reference. */ function clone(value) { - return JSON.parse(JSON.stringify(value)); + return typeof structuredClone === "function" + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); } /** @@ -521,12 +526,12 @@ function clone(value) { */ export async function listReports({ status = "queue" } = {}) { const all = clone(getStore()); - const filtered = - status === "all" - ? all - : status === "open" - ? all.filter((r) => r.status === "open") - : all.filter((r) => r.status === "open" || r.status === "escalated"); + let filtered = all; + if (status === "open") { + filtered = all.filter((r) => r.status === "open"); + } else if (status !== "all") { + filtered = all.filter((r) => r.status === "open" || r.status === "escalated"); + } filtered.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); return withMockDelay({ reports: filtered }); diff --git a/lib/admin/messages/common.js b/lib/admin/messages/common.js index 7fea2383..4b076e24 100644 --- a/lib/admin/messages/common.js +++ b/lib/admin/messages/common.js @@ -98,7 +98,7 @@ export const ERROR_TIMEOUT = "Request timed out. Please try again."; export const EMPTY_NO_DATA = "No data available"; export const EMPTY_NO_RESULTS = "No results found"; export const EMPTY_NO_ITEMS = "No {items} yet"; -export const EMPTY_SEARCH_NO_RESULTS = "No results for "{query}""; +export const EMPTY_SEARCH_NO_RESULTS = 'No results for "{query}"'; // ───────────────────────────────────────────────────────────────────────────── // Pagination diff --git a/vitest.config.js b/vitest.config.js index aa611c12..deea95ca 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,6 +1,6 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; -import { resolve } from "path"; +import { resolve } from "node:path"; export default defineConfig({ plugins: [react()], diff --git a/vitest.setup.js b/vitest.setup.js index f22f75dd..f640cea8 100644 --- a/vitest.setup.js +++ b/vitest.setup.js @@ -3,14 +3,33 @@ import { vi } from "vitest"; // next/font is a Next.js build-time feature that doesn't work in jsdom. // Mock it so components that import fonts (Button, etc.) don't crash. -vi.mock("next/font/google", () => ({ - Poppins: () => ({ className: "mock-poppins", style: {} }), - Inter: () => ({ className: "mock-inter", style: {} }), - Roboto: () => ({ className: "mock-roboto", style: {} }), - Lato: () => ({ className: "mock-lato", style: {} }), - Nunito: () => ({ className: "mock-nunito", style: {} }), -})); +vi.mock("next/font/google", () => { + return new Proxy( + {}, + { + get: (_target, prop) => () => ({ + className: `mock-${String(prop).toLowerCase()}`, + style: {}, + variable: `--font-${String(prop).toLowerCase()}`, + }), + } + ); +}); vi.mock("next/font/local", () => ({ default: () => ({ className: "mock-local-font", style: {} }), })); + +if (typeof window !== "undefined") { + const store = new Map(); + const localStorageMock = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + writable: true, + }); +}