From 19987570be9151292c280021377e78ec360c79c0 Mon Sep 17 00:00:00 2001 From: WEB3NOVA Date: Sat, 29 Aug 2026 16:42:27 +0100 Subject: [PATCH] test(panel): fix context-client harness, add submit-guard and Enter-key coverage (Closes #555) --- src/components/TransactionPanel.test.tsx | 183 ++++++++++++++++++----- 1 file changed, 149 insertions(+), 34 deletions(-) diff --git a/src/components/TransactionPanel.test.tsx b/src/components/TransactionPanel.test.tsx index d109556..5a2d934 100644 --- a/src/components/TransactionPanel.test.tsx +++ b/src/components/TransactionPanel.test.tsx @@ -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"; @@ -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 = {}, +): ReturnType { + return { + ...overrides, + get client() { + return "client" in overrides ? overrides.client : getClient(); + }, + } as unknown as ReturnType; +} + function mockGetClient( submitImpl: ReturnType, feeImpl: ReturnType = vi @@ -45,10 +65,9 @@ async function reviewAndConfirm() { describe("TransactionPanel", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - } as unknown as ReturnType); + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ address: "GABC", isConnected: true }), + ); }); it("opens a confirmation modal before submitting, showing the operation, fee, and source account", async () => { @@ -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); + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ address: null, isConnected: true }), + ); render(); @@ -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); + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ + address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + isConnected: true, + }), + ); render(); @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -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); + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ address: "GABC", isConnected: true, balances }), + ); render(); @@ -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); + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ address: "GABC", isConnected: true, balances }), + ); render(); @@ -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); + it("disables the asset selector while the account is loading", () => { + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ + address: "GABC", + isConnected: true, + balances: [], + isLoadingAccount: true, + }), + ); render(); const select = screen.getByLabelText("Asset"); expect(select).toBeDisabled(); - expect(select).toHaveValue("XLM"); + expect(select).toHaveTextContent("Loading assets…"); }); }); @@ -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); + vi.mocked(useSorokit).mockReturnValue( + mockUseSorokitValue({ + address: "GABC", + isConnected: true, + network: { name: "testnet", passphrase: "x", rpcUrl: "x", horizonUrl: "x" }, + }), + ); render();