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
10 changes: 7 additions & 3 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" },
Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" },
Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" },
};
const { color, icon } = config[status];
return (
Expand Down Expand Up @@ -202,10 +203,13 @@ export default function GovernancePage() {
}, []);

useEffect(() => {
load();
const timeout = window.setTimeout(load, 0);
// Refresh every 30 s for real-time vote counts
const interval = setInterval(load, 30_000);
return () => clearInterval(interval);
const interval = window.setInterval(load, 30_000);
return () => {
window.clearTimeout(timeout);
window.clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
114 changes: 61 additions & 53 deletions app/pay/[id]/__tests__/PayInvoice.test.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,104 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import PayInvoicePage from '../page';
import * as soroban from '../../../../utils/soroban';
import { useWallet } from '../../../../context/WalletContext';
import { useToast } from '../../../../context/ToastContext';
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PayInvoicePage from "../page";
import * as soroban from "@/utils/soroban";
import { useWallet } from "@/context/WalletContext";
import { useToast } from "@/context/ToastContext";
import type { Invoice } from "@/utils/soroban";

// Mock context and utils
vi.mock('../../../../context/WalletContext', () => ({
vi.mock("@/context/WalletContext", () => ({
useWallet: vi.fn(),
}));

vi.mock('../../../../context/ToastContext', () => ({
vi.mock("@/context/ToastContext", () => ({
useToast: vi.fn(),
}));

vi.mock('../../../../utils/soroban', () => ({
vi.mock("@/utils/soroban", () => ({
getInvoice: vi.fn(),
markPaid: vi.fn(),
disputeInvoice: vi.fn(),
submitSignedTransaction: vi.fn(),
}));

describe('PayInvoicePage', () => {
const mockInvoice = {
const PAYER = "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV";
const OTHER_WALLET = "GDIEC472DEK3S5UWVKYDBXG74R53KMHGXGFIURLJUF6P6JJ352HLLJED";

type TestParams = Promise<{ id: string }> & { _resolvedValue?: { id: string } };

function createParams(): TestParams {
const params = Promise.resolve({ id: "1" }) as TestParams;
params._resolvedValue = { id: "1" };
return params;
}

describe("PayInvoicePage", () => {
const mockInvoice: Invoice = {
id: 1n,
freelancer: 'GFREELANCER',
payer: 'GPAYER',
freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
payer: PAYER,
amount: 1000000000n,
amount_paid: 0n,
due_date: 1713960000n,
status: 'Funded',
discount_rate: 300,
status: "Funded",
};

const mockToast = {
addToast: vi.fn().mockReturnValue('toast-id'),
addToast: vi.fn().mockReturnValue("toast-id"),
updateToast: vi.fn(),
};

beforeEach(() => {
vi.clearAllMocks();
(useToast as any).mockReturnValue(mockToast);
(soroban.getInvoice as any).mockResolvedValue(mockInvoice);
vi.mocked(useToast).mockReturnValue(mockToast as ReturnType<typeof useToast>);
vi.mocked(soroban.getInvoice).mockResolvedValue(mockInvoice);
});

it('should render invoice summary without wallet connection', async () => {
(useWallet as any).mockReturnValue({
it("should render invoice summary without wallet connection", async () => {
vi.mocked(useWallet).mockReturnValue({
address: null,
connect: vi.fn(),
});
} as unknown as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText(/100\s+USDC/)).toBeInTheDocument();
expect(screen.getByText('Connect Wallet and Pay')).toBeInTheDocument();
expect(screen.getByText("Connect Wallet and Pay")).toBeInTheDocument();
});
});

it('should show warning if connected wallet is not the payer', async () => {
(useWallet as any).mockReturnValue({
address: 'GWRONGWALLET',
it("should show warning if connected wallet is not the payer", async () => {
vi.mocked(useWallet).mockReturnValue({
address: OTHER_WALLET,
connect: vi.fn(),
});
} as unknown as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText('Address Mismatch')).toBeInTheDocument();
expect(screen.getByText('Restricted to Registered Payer')).toBeInTheDocument();
expect(screen.getByText("Address Mismatch")).toBeInTheDocument();
expect(screen.getByText("Restricted to Registered Payer")).toBeInTheDocument();
});
});

it('should show confirmation if invoice is already paid', async () => {
(soroban.getInvoice as any).mockResolvedValue({
it("should show confirmation if invoice is already paid", async () => {
vi.mocked(soroban.getInvoice).mockResolvedValue({
...mockInvoice,
status: 'Paid',
status: "Paid",
});

(useWallet as any).mockReturnValue({
address: 'GPAYER',
});
vi.mocked(useWallet).mockReturnValue({
address: PAYER,
} as unknown as ReturnType<typeof useWallet>);

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

await waitFor(() => {
expect(screen.getByText('Invoice settled')).toBeInTheDocument();
expect(screen.getByText('Settlement Complete')).toBeInTheDocument();
expect(screen.getByText("Invoice settled")).toBeInTheDocument();
expect(screen.getByText("Settlement Complete")).toBeInTheDocument();
});
});

Expand Down Expand Up @@ -130,17 +138,17 @@ describe('PayInvoicePage', () => {

it('should call markPaid with correct amount when payment is confirmed', async () => {
const mockSignTx = vi.fn();
(useWallet as any).mockReturnValue({
address: 'GPAYER',
vi.mocked(useWallet).mockReturnValue({
address: PAYER,
signTx: mockSignTx,
});
} as unknown as ReturnType<typeof useWallet>);

(soroban.markPaid as any).mockResolvedValue('mock-tx');
(soroban.submitSignedTransaction as any).mockResolvedValue({ txHash: 'hash123' });
vi.mocked(soroban.markPaid).mockResolvedValue(
"mock-tx" as unknown as Awaited<ReturnType<typeof soroban.markPaid>>,
);
vi.mocked(soroban.submitSignedTransaction).mockResolvedValue({ txHash: "hash123" });

const params = Promise.resolve({ id: '1' }) as any;
params._resolvedValue = { id: '1' };
render(<PayInvoicePage params={params} />);
render(<PayInvoicePage params={createParams()} />);

// Open modal
await waitFor(() => {
Expand All @@ -158,7 +166,7 @@ describe('PayInvoicePage', () => {
await waitFor(() => {
expect(soroban.markPaid).toHaveBeenCalledWith('GPAYER', 1n, 500000000n); // 50 USDC in stroops
expect(soroban.submitSignedTransaction).toHaveBeenCalled();
expect(mockToast.updateToast).toHaveBeenCalledWith('toast-id', expect.objectContaining({ type: 'success' }));
expect(mockToast.updateToast).toHaveBeenCalledWith("toast-id", expect.objectContaining({ type: "success" }));
});
});

Expand Down
31 changes: 27 additions & 4 deletions app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

import { use, useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { getInvoice, markPaid, submitSignedTransaction, type Invoice } from "@/utils/soroban";
import {
disputeInvoice,
getInvoice,
markPaid,
submitSignedTransaction,
type Invoice,
} from "@/utils/soroban";
import { formatAddress } from "@/utils/format";
import { formatUsdcFromStroops } from "@/utils/invoiceSubmission";
import { useWallet } from "@/context/WalletContext";
import { useToast } from "@/context/ToastContext";
import { TESTNET_USDC_TOKEN_ID, NETWORK_NAME } from "@/constants";
import { NETWORK_NAME } from "@/constants";
import ActivityFeed from "@/components/ActivityFeed";
import PartialPaymentModal from "@/components/PartialPaymentModal";

Expand Down Expand Up @@ -39,7 +45,8 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
}, [invoiceId]);

useEffect(() => {
fetchInvoice();
const timeout = window.setTimeout(fetchInvoice, 0);
return () => window.clearTimeout(timeout);
}, [fetchInvoice]);

const handlePaymentConfirm = async (amount: bigint) => {
Expand Down Expand Up @@ -69,13 +76,29 @@ export default function PayInvoicePage({ params }: { params: Promise<{ id: strin
updateToast(toastId, {
type: "error",
title: "Payment Failed",
message: err.message || "An unexpected error occurred during payment."
message: err instanceof Error ? err.message : "An unexpected error occurred during payment."
});
} finally {
setIsPaying(false);
}
};

const handleDispute = async (reasonHash: string) => {
if (!address || !invoice) return;

setDisputeError(null);
try {
await disputeTransaction.runTransaction(async () => {
const tx = await disputeInvoice(address, invoice.id, reasonHash);
return submitSignedTransaction({ tx, signTx });
});
setInvoice({ ...invoice, status: "Disputed" });
setIsDisputeModalOpen(false);
} catch (err) {
setDisputeError(err instanceof Error ? err.message : "Failed to raise dispute.");
}
};

if (loadState === "loading") {
return (
<main className="min-h-screen flex items-center justify-center">
Expand Down
4 changes: 2 additions & 2 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: Pick<TokenLike, "logo" | "symbol">): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: Pick<TokenLike, "iconLabel" | "symbol">): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand Down
72 changes: 72 additions & 0 deletions src/components/__tests__/DisputeInvoiceModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import DisputeInvoiceModal from "../DisputeInvoiceModal";
import type { Invoice } from "@/utils/soroban";

const invoice: Invoice = {
id: 16n,
freelancer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
payer: "GCSXPYZSTPKX2GDVW6XSJDBE3PVSNSXCCLTGSPJXNF57IJU5EDU6IUDV",
amount: 1_000_000_000n,
due_date: 1_900_000_000n,
discount_rate: 300,
status: "Funded",
};

describe("DisputeInvoiceModal", () => {
it("shows the required evidence warning", () => {
render(
<DisputeInvoiceModal
invoice={invoice}
isSubmitting={false}
onClose={vi.fn()}
onDispute={vi.fn()}
/>,
);

expect(
screen.getByText(
"Your evidence description will be hashed and recorded on-chain. Save this text — you will need to share it with governance.",
),
).toBeInTheDocument();
});

it("requires evidence text", async () => {
render(
<DisputeInvoiceModal
invoice={invoice}
isSubmitting={false}
onClose={vi.fn()}
onDispute={vi.fn()}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Raise Dispute" }));

expect(await screen.findByText("Evidence description is required.")).toBeInTheDocument();
});

it("hashes evidence before submitting", async () => {
const onDispute = vi.fn().mockResolvedValue(undefined);
render(
<DisputeInvoiceModal
invoice={invoice}
isSubmitting={false}
onClose={vi.fn()}
onDispute={onDispute}
/>,
);

fireEvent.change(screen.getByLabelText("Evidence description"), {
target: { value: "hello" },
});
fireEvent.click(screen.getByRole("button", { name: "Raise Dispute" }));

await waitFor(() =>
expect(onDispute).toHaveBeenCalledWith(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"hello",
),
);
});
});
Loading