Skip to content
Open
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
183 changes: 149 additions & 34 deletions src/components/TransactionPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { act,fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach,describe, expect, it, vi } from "vitest";

import { useSorokit } from "@/context/useSorokit";
Expand All @@ -16,6 +17,25 @@ vi.mock("@/lib/client", () => ({

const DEFAULT_FEE = { baseFee: "100", recommended: "100" };

/**
* Builds a useSorokit() mock return value with `client` wired to the
* getClient mock. Since #452/#453/#454/#455 the panel reads `client` from
* the useSorokit() context (via `const { client } = useSorokit()`), so a
* plain object literal with no `client` key makes submitTransaction short-
* circuit at `if (!address || !client)` and report "Wallet not connected".
* Every override in this file must go through this helper.
*/
function mockUseSorokitValue(
overrides: Record<string, unknown> = {},
): ReturnType<typeof useSorokit> {
return {
...overrides,
get client() {
return "client" in overrides ? overrides.client : getClient();
},
} as unknown as ReturnType<typeof useSorokit>;
}

function mockGetClient(
submitImpl: ReturnType<typeof vi.fn>,
feeImpl: ReturnType<typeof vi.fn> = vi
Expand Down Expand Up @@ -45,10 +65,9 @@ async function reviewAndConfirm() {
describe("TransactionPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useSorokit).mockReturnValue({
address: "GABC",
isConnected: true,
} as unknown as ReturnType<typeof useSorokit>);
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({ address: "GABC", isConnected: true }),
);
});

it("opens a confirmation modal before submitting, showing the operation, fee, and source account", async () => {
Expand Down Expand Up @@ -174,10 +193,9 @@ describe("TransactionPanel", () => {
});

it("shows error if address is null at submit time", async () => {
vi.mocked(useSorokit).mockReturnValue({
address: null,
isConnected: true,
} as unknown as ReturnType<typeof useSorokit>);
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({ address: null, isConnected: true }),
);

render(<TransactionPanel />);

Expand All @@ -200,10 +218,12 @@ describe("TransactionPanel", () => {
});

it("shows self-payment warning when destination equals source address", async () => {
vi.mocked(useSorokit).mockReturnValue({
address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
isConnected: true,
} as unknown as ReturnType<typeof useSorokit>);
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({
address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
isConnected: true,
}),
);

render(<TransactionPanel />);

Expand All @@ -222,6 +242,100 @@ describe("TransactionPanel", () => {
expect(submitBtn).not.toBeDisabled();
});

// ── Submit guard and keyboard submission (#555) ───────────────────────────
describe("submit guard (#555)", () => {
const validDest =
"GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC";

it("disables the Send button when the destination is empty", () => {
render(<TransactionPanel />);

const submitBtn = screen.getByRole("button", { name: /^Send (XLM|USDC)/ });
expect(submitBtn).toBeDisabled();

// Even a valid amount is not enough — destination must be filled in.
fireEvent.change(screen.getByLabelText("Amount (XLM)"), {
target: { value: "10" },
});
expect(submitBtn).toBeDisabled();

fireEvent.change(screen.getByLabelText("Destination Address"), {
target: { value: validDest },
});
expect(submitBtn).not.toBeDisabled();
});

it("disables the Send button when the amount is zero", () => {
render(<TransactionPanel />);

const submitBtn = screen.getByRole("button", { name: /^Send (XLM|USDC)/ });
fireEvent.change(screen.getByLabelText("Destination Address"), {
target: { value: validDest },
});

// Zero is below the 0.0000001 minimum, so the guard rejects it.
fireEvent.change(screen.getByLabelText("Amount (XLM)"), {
target: { value: "0" },
});
expect(screen.getByText("Amount must be greater than 0")).toBeInTheDocument();
expect(submitBtn).toBeDisabled();

fireEvent.change(screen.getByLabelText("Amount (XLM)"), {
target: { value: "0.0000001" },
});
expect(submitBtn).not.toBeDisabled();
});

it("opens the confirmation modal when Enter is pressed on the submit button", async () => {
// jsdom does not implement implicit form submission (Enter inside a
// text field), so the keyboard path is exercised the way a browser's
// implicit submission lands: Enter on the focused submit button.
const user = userEvent.setup();
mockGetClient(
vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }),
);
render(<TransactionPanel />);

fireEvent.change(screen.getByLabelText("Destination Address"), {
target: { value: validDest },
});
fireEvent.change(screen.getByLabelText("Amount (XLM)"), {
target: { value: "10" },
});

screen.getByRole("button", { name: /^Send (XLM|USDC)/ }).focus();
await user.keyboard("{Enter}");

expect(
await screen.findByRole("dialog", { name: /confirm transaction/i }),
).toBeInTheDocument();
});

it("submits directly without a modal when Enter is pressed in previewMode=false", async () => {
const user = userEvent.setup();
const mockSubmit = vi
.fn()
.mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null });
mockGetClient(mockSubmit);
render(<TransactionPanel previewMode={false} />);

fireEvent.change(screen.getByLabelText("Destination Address"), {
target: { value: validDest },
});
fireEvent.change(screen.getByLabelText("Amount (XLM)"), {
target: { value: "10" },
});

screen.getByRole("button", { name: /^Send (XLM|USDC)/ }).focus();
await user.keyboard("{Enter}");

await screen.findByText("Transaction submitted");
expect(mockSubmit).toHaveBeenCalledWith(
expect.objectContaining({ destination: validDest, amount: "10" }),
);
});
});

it("shows error for amount below minimum threshold", async () => {
render(<TransactionPanel />);

Expand Down Expand Up @@ -300,11 +414,9 @@ describe("TransactionPanel", () => {
];

it("populates the asset selector with the correct asset codes from context balances", () => {
vi.mocked(useSorokit).mockReturnValue({
address: "GABC",
isConnected: true,
balances,
} as unknown as ReturnType<typeof useSorokit>);
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({ address: "GABC", isConnected: true, balances }),
);

render(<TransactionPanel />);

Expand All @@ -318,11 +430,9 @@ describe("TransactionPanel", () => {
.fn()
.mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null });
mockGetClient(mockSubmit);
vi.mocked(useSorokit).mockReturnValue({
address: "GABC",
isConnected: true,
balances,
} as unknown as ReturnType<typeof useSorokit>);
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({ address: "GABC", isConnected: true, balances }),
);

render(<TransactionPanel />);

Expand All @@ -348,18 +458,21 @@ describe("TransactionPanel", () => {
);
});

it("disables the asset selector when no balances are loaded", () => {
vi.mocked(useSorokit).mockReturnValue({
address: "GABC",
isConnected: true,
balances: [],
} as unknown as ReturnType<typeof useSorokit>);
it("disables the asset selector while the account is loading", () => {
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({
address: "GABC",
isConnected: true,
balances: [],
isLoadingAccount: true,
}),
);

render(<TransactionPanel />);

const select = screen.getByLabelText("Asset");
expect(select).toBeDisabled();
expect(select).toHaveValue("XLM");
expect(select).toHaveTextContent("Loading assets…");
});
});

Expand Down Expand Up @@ -393,11 +506,13 @@ describe("TransactionPanel", () => {
.fn()
.mockResolvedValue({ data: { hash: "txhash123", ledger: 100 }, error: null });
mockGetClient(mockSubmit);
vi.mocked(useSorokit).mockReturnValue({
address: "GABC",
isConnected: true,
network: { name: "testnet", passphrase: "x", rpcUrl: "x", horizonUrl: "x" },
} as unknown as ReturnType<typeof useSorokit>);
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokitValue({
address: "GABC",
isConnected: true,
network: { name: "testnet", passphrase: "x", rpcUrl: "x", horizonUrl: "x" },
}),
);

render(<TransactionPanel />);

Expand Down
Loading