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
114 changes: 114 additions & 0 deletions app/i/[id]/__tests__/InvoiceDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import InvoiceDetailPage from "../page";
import { getInvoice, type Invoice } from "@/utils/soroban";

vi.mock("@/components/ActivityFeed", () => ({
default: () => <div>Activity feed</div>,
}));

vi.mock("@/components/ChangeInvoiceTokenModal", () => ({
default: ({ onSuccess }: { onSuccess: (token: string) => void }) => (
<div role="dialog" aria-label="Change Token">
<button onClick={() => onSuccess("CEURC")}>Mock change token success</button>
</div>
),
}));

const connect = vi.fn();
let walletState = {
address: "",
connect,
};

vi.mock("@/context/WalletContext", () => ({
useWallet: () => walletState,
}));

vi.mock("@/hooks/useApprovedTokens", () => ({
useApprovedTokens: () => ({
tokens: [
{ contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
{ contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" },
],
tokenMap: new Map([
["CUSDC", { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" }],
["CEURC", { contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" }],
]),
defaultToken: { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
}),
}));

vi.mock("@/utils/soroban", async () => {
const actual = await vi.importActual<typeof import("@/utils/soroban")>("@/utils/soroban");
return {
...actual,
getInvoice: vi.fn(),
};
});

const invoice: Invoice = {
id: 9n,
status: "Pending",
freelancer: "GFREELANCER",
payer: "GPAYER",
amount: 10_000_000n,
due_date: 1_900_000_000n,
discount_rate: 300,
token: "CUSDC",
};

function params(id = "9") {
const value = Promise.resolve({ id }) as Promise<{ id: string }> & { _resolvedValue?: { id: string } };
value._resolvedValue = { id };
return value;
}

describe("InvoiceDetailPage", () => {
beforeEach(() => {
vi.clearAllMocks();
walletState = {
address: "GFREELANCER",
connect,
};
vi.mocked(getInvoice).mockResolvedValue(invoice);
});

it("shows Change Token only for the submitting freelancer while pending", async () => {
render(<InvoiceDetailPage params={params()} />);

expect(await screen.findByRole("heading", { name: "Invoice #9" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Change Token" })).toBeInTheDocument();
});

it("does not show Change Token for non-submitters", async () => {
walletState = {
address: "GOTHER",
connect,
};

render(<InvoiceDetailPage params={params()} />);

expect(await screen.findByRole("heading", { name: "Invoice #9" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Change Token" })).not.toBeInTheDocument();
expect(screen.getByText("Only the submitting freelancer can change the invoice token before funding.")).toBeInTheDocument();
});

it("does not show Change Token after the invoice leaves Pending", async () => {
vi.mocked(getInvoice).mockResolvedValue({ ...invoice, status: "Funded" });

render(<InvoiceDetailPage params={params()} />);

expect(await screen.findByRole("heading", { name: "Invoice #9" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Change Token" })).not.toBeInTheDocument();
});

it("updates the visible token immediately after a successful modal change", async () => {
render(<InvoiceDetailPage params={params()} />);

fireEvent.click(await screen.findByRole("button", { name: "Change Token" }));
fireEvent.click(screen.getByText("Mock change token success"));

await waitFor(() => expect(screen.getByText("EURC")).toBeInTheDocument());
});
});
111 changes: 111 additions & 0 deletions src/components/__tests__/ChangeInvoiceTokenModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ChangeInvoiceTokenModal from "@/components/ChangeInvoiceTokenModal";
import { buildConvertInvoiceTokenTransaction, type Invoice } from "@/utils/soroban";

const runTransaction = vi.fn();
const addToast = vi.fn(() => "toast-id");
const updateToast = vi.fn();

vi.mock("@/hooks/useTransaction", () => ({
useTransaction: () => ({
isPending: false,
runTransaction,
}),
}));

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

vi.mock("@/hooks/useApprovedTokens", () => ({
useApprovedTokens: () => ({
tokens: [
{ contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
{ contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" },
],
tokenMap: new Map([
["CUSDC", { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" }],
["CEURC", { contractId: "CEURC", name: "Euro Coin", symbol: "EURC", decimals: 7, iconLabel: "EU" }],
]),
defaultToken: { contractId: "CUSDC", name: "USD Coin", symbol: "USDC", decimals: 7, iconLabel: "US" },
isLoading: false,
error: null,
}),
}));

vi.mock("@/utils/soroban", async () => {
const actual = await vi.importActual<typeof import("@/utils/soroban")>("@/utils/soroban");
return {
...actual,
buildConvertInvoiceTokenTransaction: vi.fn().mockResolvedValue("tx"),
};
});

const invoice: Invoice = {
id: 12n,
status: "Pending",
freelancer: "GFREELANCER",
payer: "GPAYER",
amount: 10_000_000n,
due_date: 1_900_000_000n,
discount_rate: 300,
token: "CUSDC",
};

describe("ChangeInvoiceTokenModal", () => {
beforeEach(() => {
vi.clearAllMocks();
runTransaction.mockImplementation(async (builder: () => Promise<unknown>) => {
await builder();
return { txHash: "hash123" };
});
});

it("shows the current token, allowlisted token selector, and denomination warning", () => {
render(
<ChangeInvoiceTokenModal
invoice={invoice}
submitter="GFREELANCER"
onClose={vi.fn()}
onSuccess={vi.fn()}
/>
);

expect(screen.getByRole("heading", { name: "Change Token" })).toBeInTheDocument();
expect(screen.getByText("This changes the currency your invoice is denominated in.")).toBeInTheDocument();
expect(screen.getByText("USDC")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: /new token/i })).toHaveValue("CEURC");
});

it("builds and submits convert_invoice_token through useTransaction", async () => {
const onClose = vi.fn();
const onSuccess = vi.fn();

render(
<ChangeInvoiceTokenModal
invoice={invoice}
submitter="GFREELANCER"
onClose={onClose}
onSuccess={onSuccess}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Change Token" }));

await waitFor(() =>
expect(buildConvertInvoiceTokenTransaction).toHaveBeenCalledWith({
submitter: "GFREELANCER",
invoiceId: 12n,
newToken: "CEURC",
})
);
expect(runTransaction).toHaveBeenCalledTimes(1);
expect(updateToast).toHaveBeenCalledWith(
"toast-id",
expect.objectContaining({ type: "success", txHash: "hash123" })
);
expect(onSuccess).toHaveBeenCalledWith("CEURC");
expect(onClose).toHaveBeenCalled();
});
});
45 changes: 45 additions & 0 deletions src/hooks/useTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { useState } from "react";
import { useWallet } from "@/context/WalletContext";
import { submitSignedTransaction } from "@/utils/soroban";
import type { Transaction } from "@stellar/stellar-sdk";

export type TransactionState = "idle" | "preparing" | "signing" | "success" | "error";

export function useTransaction() {
const { signTx } = useWallet();
const [state, setState] = useState<TransactionState>("idle");
const [error, setError] = useState<string | null>(null);

const runTransaction = async (buildTransaction: () => Promise<Transaction>) => {
setState("preparing");
setError(null);

try {
const tx = await buildTransaction();
setState("signing");
const result = await submitSignedTransaction({ tx, signTx });
setState("success");
return result;
} catch (transactionError) {
const message = transactionError instanceof Error ? transactionError.message : "Transaction failed.";
setError(message);
setState("error");
throw transactionError;
}
};

const reset = () => {
setState("idle");
setError(null);
};

return {
state,
error,
isPending: state === "preparing" || state === "signing",
runTransaction,
reset,
};
}
38 changes: 38 additions & 0 deletions src/utils/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,44 @@ export async function buildApproveUsdcTransaction(args: {
return buildApproveTokenTransaction(args);
}

// ─── Write: invoice token conversion ─────────────────────────────────────────

export async function buildConvertInvoiceTokenTransaction({
submitter,
invoiceId,
newToken,
}: {
submitter: string;
invoiceId: bigint;
newToken: string;
}) {
const account = await server.getAccount(submitter);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(
Operation.invokeContractFunction({
contract: CONTRACT_ID,
function: "convert_invoice_token",
args: [
nativeToScVal(invoiceId, { type: "u64" }),
Address.fromString(newToken).toScVal(),
],
})
)
.setTimeout(60)
.build();

const simulated = await server.simulateTransaction(tx);
if (!rpc.Api.isSimulationSuccess(simulated)) {
const message =
"error" in simulated ? simulated.error : "Unable to simulate invoice token conversion.";
throw new Error(`Simulation failed: ${message}`);
}
return rpc.assembleTransaction(tx, simulated).build();
}

// ─── Write: generic signed transaction dispatcher ─────────────────────────────

export async function submitSignedTransaction({
Expand Down