Skip to content
Open
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
204 changes: 204 additions & 0 deletions frontend/__tests__/InvoicesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Tests for pages/invoices.tsx — Invoice Dashboard.
* @jest-environment jsdom
*/
import React from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/router";
import InvoicesPage from "../pages/invoices";
import { INVOICES_STORAGE_KEY, type Invoice } from "@/lib/invoices";

jest.mock("next/router", () => ({
useRouter: jest.fn(),
}));

// Mock i18n — return the key's last segment as a readable label.
jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const seg = key.split(".").pop() || key;
const map: Record<string, string> = {
filterAll: "All",
filterDraft: "Draft",
filterSent: "Sent",
filterPaid: "Paid",
filterOverdue: "Overdue",
title: "Invoices",
subtitle: "Invoices subtitle",
empty: "No invoices yet. Create one to get started.",
emptyFilter: "No invoices match this filter.",
createNew: "New Invoice",
createFirst: "Create your first invoice",
loading: "Loading",
statOutstanding: "Outstanding",
statPaid: "Paid",
statTotal: "Total",
created: "Invoice created",
reminderSent: "Reminder sent",
downloadFailed: "Failed",
filterAllKey: "All",
};
return map[seg] ?? seg;
},
}),
}));

jest.mock("next/head", () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

jest.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: React.ComponentProps<"div">) => <div {...props}>{children}</div>,
},
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

// Toast context: simple no-op provider.
const ToastCtx = require("@/lib/ToastContext");
jest.spyOn(ToastCtx, "useToastContext").mockReturnValue({
addToast: jest.fn(),
removeToast: jest.fn(),
toasts: [],
});

const mockReplace = jest.fn();
(useRouter as jest.Mock).mockReturnValue({
query: {},
replace: mockReplace,
pathname: "/invoices",
});

const mkInvoice = (over: Partial<Invoice>): Invoice => ({
id: "inv-1",
invoiceNumber: "INV-00001",
status: "sent",
clientName: "Acme Corp",
clientEmail: "billing@acme.test",
description: "Consulting",
amount: "250.5",
asset: "USDC",
createdAt: "2026-08-01T00:00:00.000Z",
dueDate: "2026-09-01",
...over,
});

beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});

describe("InvoicesPage", () => {
it("renders empty state when no invoices exist", () => {
render(<InvoicesPage />);
expect(screen.getByRole("heading", { name: /Invoices/i })).toBeInTheDocument();
expect(screen.getByText(/No invoices yet/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /New Invoice/i })).toBeInTheDocument();
});

it("lists invoices and shows summary cards", async () => {
localStorage.setItem(
INVOICES_STORAGE_KEY,
JSON.stringify([
mkInvoice({ id: "a", clientName: "Alpha Corp", amount: "100", asset: "XLM" }),
mkInvoice({ id: "b", clientName: "Beta Inc", status: "paid", amount: "200", asset: "XLM" }),
]),
);
render(<InvoicesPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
});
// Summary cards (Outstanding 100, Paid 200, Total 300) — invoice cards also
// render amounts, so assert these appear at least once.
expect(screen.getAllByText("100 XLM").length).toBeGreaterThan(0);
expect(screen.getAllByText("200 XLM").length).toBeGreaterThan(0);
expect(screen.getAllByText("300 XLM").length).toBeGreaterThan(0);
});

it("filters invoices by status", async () => {
const user = userEvent.setup();
localStorage.setItem(
INVOICES_STORAGE_KEY,
JSON.stringify([
mkInvoice({ id: "a", clientName: "Alpha", status: "draft" }),
mkInvoice({ id: "b", clientName: "Beta", status: "paid" }),
]),
);
render(<InvoicesPage />);
await waitFor(() => expect(screen.getByText("Alpha")).toBeInTheDocument());

await user.click(screen.getByRole("tab", { name: /^Paid/ }));
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();

await user.click(screen.getByRole("tab", { name: /^Draft/ }));
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.queryByText("Beta")).not.toBeInTheDocument();
});
it("opens the detail panel from a deep link (?invoice=id)", async () => {
localStorage.setItem(
INVOICES_STORAGE_KEY,
JSON.stringify([mkInvoice({ id: "deep-1", amount: "100", asset: "USDC" })]),
);
(useRouter as jest.Mock).mockReturnValue({
query: { invoice: "deep-1" },
replace: mockReplace,
pathname: "/invoices",
});
render(<InvoicesPage />);
await waitFor(() => {
expect(screen.getByText("Update Status")).toBeInTheDocument();
});
});

it("opens detail on card click from the list", async () => {
const user = userEvent.setup();
localStorage.setItem(
INVOICES_STORAGE_KEY,
JSON.stringify([mkInvoice({ id: "a" })]),
);
render(<InvoicesPage />);
await waitFor(() => expect(screen.getByText("Acme Corp")).toBeInTheDocument());

await user.click(screen.getByRole("button", { name: /View Details/i }));
await waitFor(() => {
expect(screen.getByText("Update Status")).toBeInTheDocument();
});
expect(mockReplace).toHaveBeenCalledWith(
expect.objectContaining({ query: expect.objectContaining({ invoice: "a" }) }),
undefined,
{ shallow: true },
);
});

it("renders the reminder flow from the detail panel", async () => {
const user = userEvent.setup();
localStorage.setItem(
INVOICES_STORAGE_KEY,
JSON.stringify([mkInvoice({ id: "a" })]),
);
render(<InvoicesPage />);
await waitFor(() => expect(screen.getByText("Acme Corp")).toBeInTheDocument());

await user.click(screen.getByRole("button", { name: /View Details/i }));
await waitFor(() => {
expect(screen.getByText("Update Status")).toBeInTheDocument();
});
// Detail panel renders the status controls and detail actions.
expect(screen.getByRole("button", { name: /Draft/i })).toBeInTheDocument();
});

it("shows a filtered empty state", async () => {
const user = userEvent.setup();
localStorage.setItem(
INVOICES_STORAGE_KEY,
JSON.stringify([mkInvoice({ id: "a", status: "draft" })]),
);
render(<InvoicesPage />);
await waitFor(() => expect(screen.getByText("Acme Corp")).toBeInTheDocument());
await user.click(screen.getByRole("tab", { name: /^Overdue/ }));
expect(screen.getByText(/No invoices match/i)).toBeInTheDocument();
});
});
169 changes: 169 additions & 0 deletions frontend/__tests__/invoices-lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Tests for lib/invoices.ts — localStorage persistence + summary helpers.
* @jest-environment jsdom
*/
import {
buildInvoiceNumber,
createInvoice,
deleteInvoice,
INVOICES_STORAGE_KEY,
loadInvoices,
sendInvoiceReminder,
summarizeInvoices,
updateInvoiceStatus,
type Invoice,
} from "@/lib/invoices";

const BASE_FORM = {
recipient: "GABCDEF123",
clientName: "Acme Corp",
clientEmail: "billing@acme.test",
description: "Consulting",
amount: "250.5",
asset: "USDC",
dueDate: "2026-09-01",
};

describe("invoice persistence", () => {
beforeEach(() => {
localStorage.clear();
});

it("loadInvoices returns [] when nothing stored / on corrupt data", () => {
expect(loadInvoices()).toEqual([]);
localStorage.setItem(INVOICES_STORAGE_KEY, "{not-json");
expect(loadInvoices()).toEqual([]);
localStorage.setItem(INVOICES_STORAGE_KEY, JSON.stringify({ nope: true }));
expect(loadInvoices()).toEqual([]);
});

it("createInvoice persists and assigns number + draft status", async () => {
const inv = await createInvoice(BASE_FORM);
expect(inv.status).toBe("draft");
expect(inv.invoiceNumber).toBe("INV-00001");
expect(inv.clientName).toBe("Acme Corp");
expect(inv.amount).toBe("250.5");
expect(inv.asset).toBe("USDC");
expect(loadInvoices()).toHaveLength(1);
});

it("createInvoice increments invoice numbers and normalizes asset code", async () => {
await createInvoice(BASE_FORM);
const second = await createInvoice({ ...BASE_FORM, amount: "10", asset: " xlm " });
expect(second.invoiceNumber).toBe("INV-00002");
expect(second.asset).toBe("XLM");
});

it("createInvoice tolerates missing optional fields", async () => {
const inv = await createInvoice({ recipient: "GX", amount: "5" });
expect(inv.clientName).toBe("GX");
expect(inv.description).toBe("Invoice");
expect(inv.asset).toBe("XLM");
expect(inv.dueDate).toBeTruthy();
expect(String(inv.createdAt)).toBeTruthy();
});

it("updateInvoiceStatus persists the new status", async () => {
const inv = await createInvoice(BASE_FORM);
expect(updateInvoiceStatus(inv.id, "sent")).toBe(true);
expect(loadInvoices()[0].status).toBe("sent");
expect(updateInvoiceStatus("missing-id", "paid")).toBe(false);
});

it("deleteInvoice removes the record", async () => {
const a = await createInvoice(BASE_FORM);
const b = await createInvoice({ ...BASE_FORM, clientName: "Other" });
expect(deleteInvoice(a.id)).toBe(true);
const left = loadInvoices();
expect(left).toHaveLength(1);
expect(left[0].id).toBe(b.id);
expect(deleteInvoice(a.id)).toBe(false);
});

it("buildInvoiceNumber scans existing numbers", () => {
expect(buildInvoiceNumber([])).toBe("INV-00001");
const items: Invoice[] = [
{
id: "1",
invoiceNumber: "INV-00007",
status: "draft",
clientName: "A",
clientEmail: "",
description: "",
amount: "1",
asset: "XLM",
createdAt: "",
dueDate: "",
},
{
id: "2",
invoiceNumber: "INV-00003",
status: "paid",
clientName: "B",
clientEmail: "",
description: "",
amount: "2",
asset: "XLM",
createdAt: "",
dueDate: "",
},
];
expect(buildInvoiceNumber(items)).toBe("INV-00008");
});
});

describe("summarizeInvoices", () => {
const mk = (over: Partial<Invoice>): Invoice => ({
id: over.id ?? "x",
invoiceNumber: "INV-00001",
status: (over.status as Invoice["status"]) ?? "draft",
clientName: "A",
clientEmail: "",
description: "",
amount: over.amount ?? "0",
asset: "XLM",
createdAt: "",
dueDate: "",
});

it("aggregates totals per status and outstanding amounts", () => {
const invoices = [
mk({ id: "1", status: "draft", amount: "100" }),
mk({ id: "2", status: "sent", amount: "200" }),
mk({ id: "3", status: "paid", amount: "300" }),
mk({ id: "4", status: "overdue", amount: "50.5" }),
];
const s = summarizeInvoices(invoices);
expect(s.total).toBeCloseTo(650.5);
expect(s.byStatus.draft).toBeCloseTo(100);
expect(s.byStatus.sent).toBeCloseTo(200);
expect(s.byStatus.paid).toBeCloseTo(300);
expect(s.byStatus.overdue).toBeCloseTo(50.5);
// outstanding = sent + overdue (unpaid issued amounts)
expect(s.outstanding).toBeCloseTo(250.5);
});

it("is resilient to non-numeric amounts", () => {
const s = summarizeInvoices([mk({ status: "paid", amount: "abc" })]);
expect(s.total).toBe(0);
expect(s.byStatus.paid).toBe(0);
});
});

describe("sendInvoiceReminder", () => {
beforeEach(() => {
localStorage.clear();
});

it("records lastReminderAt on the invoice", async () => {
const inv = await createInvoice(BASE_FORM);
expect(sendInvoiceReminder(inv)).toBe(true);
const stored = loadInvoices();
expect(stored[0].lastReminderAt).toBeTruthy();
});

it("falls back to a generic mailto when no client email", async () => {
const inv = await createInvoice({ recipient: "GX", amount: "1" });
expect(sendInvoiceReminder(inv)).toBe(true);
});
});
Loading
Loading