Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions app/admin/invoices/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import AdminInvoicesPage from "../page";
import * as api from "@/lib/api";
import * as AuthContextModule from "@/context/AuthContext";

vi.mock("@/lib/api");
vi.mock("@/context/AuthContext");
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
usePathname: () => "/admin/invoices",
useSearchParams: () => new URLSearchParams(),
}));

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}

// Helper to create a fake JWT with a specific role
function makeToken(role: string) {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = btoa(JSON.stringify({ sub: "user-1", role }));
const signature = "signature";
return `${header}.${payload}.${signature}`;
}

const mockPendingInvoices = [
{
invoiceId: "inv-101",
sellerName: "Acme Supplies",
faceValue: 5000,
submittedAt: "2026-08-20T10:00:00.000Z",
documentUrl: "/docs/inv-101.pdf",
status: "pending",
},
{
invoiceId: "inv-102",
sellerName: "Stellar Logistics",
faceValue: 12000,
submittedAt: "2026-08-21T14:30:00.000Z",
documentUrl: "/docs/inv-102.pdf",
status: "pending",
},
];

describe("Admin Invoices Page", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("denies access when user JWT has no admin role", () => {
const userToken = makeToken("user");
vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({
jwt: userToken,
address: "GABC123",
isConnecting: false,
loginWithWallet: vi.fn(),
logout: vi.fn(),
});

render(<AdminInvoicesPage />, { wrapper: createWrapper() });
expect(screen.getByTestId("unauthorized-card")).toBeInTheDocument();
expect(screen.getByText("Access Denied")).toBeInTheDocument();
});

it("allows access and displays pending invoices with all required fields when user is admin", async () => {
const adminToken = makeToken("admin");
vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({
jwt: adminToken,
address: "GADMIN123",
isConnecting: false,
loginWithWallet: vi.fn(),
logout: vi.fn(),
});

vi.mocked(api.fetchAdminInvoices).mockResolvedValue({
invoices: mockPendingInvoices,
has_more: false,
next_cursor: null,
});

render(<AdminInvoicesPage />, { wrapper: createWrapper() });

await waitFor(() => {
expect(screen.getByTestId("admin-invoices-table")).toBeInTheDocument();
});

expect(screen.getByText("Acme Supplies")).toBeInTheDocument();
expect(screen.getByText("inv-101")).toBeInTheDocument();
expect(screen.getByText("5,000 XLM")).toBeInTheDocument();

expect(screen.getByText("Stellar Logistics")).toBeInTheDocument();
expect(screen.getByText("inv-102")).toBeInTheDocument();
expect(screen.getByText("12,000 XLM")).toBeInTheDocument();
});

it("'View Document' opens document modal", async () => {
const adminToken = makeToken("admin");
vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({
jwt: adminToken,
address: "GADMIN123",
isConnecting: false,
loginWithWallet: vi.fn(),
logout: vi.fn(),
});

vi.mocked(api.fetchAdminInvoices).mockResolvedValue({
invoices: [mockPendingInvoices[0]],
has_more: false,
next_cursor: null,
});

render(<AdminInvoicesPage />, { wrapper: createWrapper() });

await waitFor(() => {
expect(screen.getByTestId("view-doc-btn-inv-101")).toBeInTheDocument();
});

fireEvent.click(screen.getByTestId("view-doc-btn-inv-101"));

expect(screen.getByTestId("document-modal")).toBeInTheDocument();
});

it("Approve action updates invoice status and removes row", async () => {
const adminToken = makeToken("admin");
vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({
jwt: adminToken,
address: "GADMIN123",
isConnecting: false,
loginWithWallet: vi.fn(),
logout: vi.fn(),
});

vi.mocked(api.fetchAdminInvoices).mockResolvedValue({
invoices: [mockPendingInvoices[0]],
has_more: false,
next_cursor: null,
});
vi.mocked(api.approveAdminInvoice).mockResolvedValue({ success: true });

render(<AdminInvoicesPage />, { wrapper: createWrapper() });

await waitFor(() => {
expect(screen.getByTestId("approve-btn-inv-101")).toBeInTheDocument();
});

fireEvent.click(screen.getByTestId("approve-btn-inv-101"));

await waitFor(() => {
expect(api.approveAdminInvoice).toHaveBeenCalledWith("inv-101", adminToken);
expect(screen.queryByTestId("invoice-row-inv-101")).not.toBeInTheDocument();
});
});

it("Reject action requires a reason and updates status with reason", async () => {
const adminToken = makeToken("admin");
vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({
jwt: adminToken,
address: "GADMIN123",
isConnecting: false,
loginWithWallet: vi.fn(),
logout: vi.fn(),
});

vi.mocked(api.fetchAdminInvoices).mockResolvedValue({
invoices: [mockPendingInvoices[0]],
has_more: false,
next_cursor: null,
});
vi.mocked(api.rejectAdminInvoice).mockResolvedValue({ success: true });

render(<AdminInvoicesPage />, { wrapper: createWrapper() });

await waitFor(() => {
expect(screen.getByTestId("reject-btn-inv-101")).toBeInTheDocument();
});

fireEvent.click(screen.getByTestId("reject-btn-inv-101"));

const confirmBtn = screen.getByTestId("confirm-reject-btn-inv-101");
expect(confirmBtn).toBeDisabled();

const reasonInput = screen.getByTestId("reject-reason-input-inv-101");
fireEvent.change(reasonInput, { target: { value: "Invalid documentation" } });

expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);

await waitFor(() => {
expect(api.rejectAdminInvoice).toHaveBeenCalledWith("inv-101", "Invalid documentation", adminToken);
expect(screen.queryByTestId("invoice-row-inv-101")).not.toBeInTheDocument();
});
});
});
77 changes: 73 additions & 4 deletions app/admin/invoices/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,80 @@
"use client";

import { AdminInvoiceReview } from "@/components/admin/AdminInvoiceReview";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { AdminInvoicesReview } from "@/components/admin/AdminInvoicesReview";
import { Card, CardContent } from "@/components/ui/card";
import { ShieldAlert } from "lucide-react";

interface JwtPayload {
role?: string;
exp?: number;
[key: string]: any;
}

function parseJwt(token: string): JwtPayload | null {
try {
const base64Url = token.split(".")[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(jsonPayload);
} catch {
return null;
}
}

export default function AdminInvoicesPage() {
const { jwt, isConnecting } = useAuth();
const router = useRouter();
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);

useEffect(() => {
if (isConnecting) return;

if (!jwt) {
setIsAdmin(false);
return;
}

const payload = parseJwt(jwt);
if (payload && payload.role === "admin") {
setIsAdmin(true);
} else {
setIsAdmin(false);
}
}, [jwt, isConnecting]);

if (isConnecting || isAdmin === null) {
return null;
}

if (!isAdmin) {
return (
<main className="container mx-auto px-4 py-16 flex justify-center">
<Card className="max-w-md w-full" data-testid="unauthorized-card">
<CardContent className="pt-6 flex flex-col items-center text-center space-y-3">
<ShieldAlert className="size-12 text-destructive" />
<h1 className="text-xl font-bold">Access Denied</h1>
<p className="text-sm text-muted-foreground">
You must be logged in as an admin user to access this page.
</p>
</CardContent>
</Card>
</main>
);
}

return (
<div className="container mx-auto max-w-4xl px-4 py-8">
<AdminInvoiceReview />
</div>
<main className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Admin Invoice Management</h1>
<AdminInvoicesReview />
</main>
);
}
Loading