diff --git a/app/i/[id]/__tests__/InvoiceDetailPage.test.tsx b/app/i/[id]/__tests__/InvoiceDetailPage.test.tsx new file mode 100644 index 0000000..04b926b --- /dev/null +++ b/app/i/[id]/__tests__/InvoiceDetailPage.test.tsx @@ -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: () =>
Activity feed
, +})); + +vi.mock("@/components/ChangeInvoiceTokenModal", () => ({ + default: ({ onSuccess }: { onSuccess: (token: string) => void }) => ( +
+ +
+ ), +})); + +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("@/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(); + + 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(); + + 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(); + + 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(); + + fireEvent.click(await screen.findByRole("button", { name: "Change Token" })); + fireEvent.click(screen.getByText("Mock change token success")); + + await waitFor(() => expect(screen.getByText("EURC")).toBeInTheDocument()); + }); +}); diff --git a/src/components/__tests__/ChangeInvoiceTokenModal.test.tsx b/src/components/__tests__/ChangeInvoiceTokenModal.test.tsx new file mode 100644 index 0000000..d66c3a3 --- /dev/null +++ b/src/components/__tests__/ChangeInvoiceTokenModal.test.tsx @@ -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("@/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) => { + await builder(); + return { txHash: "hash123" }; + }); + }); + + it("shows the current token, allowlisted token selector, and denomination warning", () => { + render( + + ); + + 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( + + ); + + 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(); + }); +}); diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts new file mode 100644 index 0000000..aae4305 --- /dev/null +++ b/src/hooks/useTransaction.ts @@ -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("idle"); + const [error, setError] = useState(null); + + const runTransaction = async (buildTransaction: () => Promise) => { + 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, + }; +} diff --git a/src/utils/soroban.ts b/src/utils/soroban.ts index 02aa7a8..3bcd3f3 100644 --- a/src/utils/soroban.ts +++ b/src/utils/soroban.ts @@ -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({