diff --git a/src/components/TransactionPanel.test.tsx b/src/components/TransactionPanel.test.tsx index d109556..16ceec5 100644 --- a/src/components/TransactionPanel.test.tsx +++ b/src/components/TransactionPanel.test.tsx @@ -552,6 +552,243 @@ describe("TransactionPanel", () => { }); }); + // ── Acceptance criteria ──────────────────────────────────────────────────── + describe("acceptance criteria", () => { + const VALID_DEST = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; + + it("AC1: Send button is disabled when destination field is empty", () => { + render(); + + const sendBtn = screen.getByRole("button", { name: /^Send (XLM|USDC)/ }); + // destination is empty by default — button must be disabled + expect(sendBtn).toBeDisabled(); + + // filling the destination (and a valid amount) re-enables it + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "1" }, + }); + expect(sendBtn).not.toBeDisabled(); + }); + + it("AC2: Send button is disabled when amount is 0", () => { + render(); + + const sendBtn = screen.getByRole("button", { name: /^Send (XLM|USDC)/ }); + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + + // amount = 0 → below the 0.0000001 minimum → canSubmit is false + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "0" }, + }); + expect(sendBtn).toBeDisabled(); + }); + + it("AC2: Send button is disabled when amount is negative", () => { + render(); + + const sendBtn = screen.getByRole("button", { name: /^Send (XLM|USDC)/ }); + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + + // negative amount → isAmountValid is false → canSubmit is false + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "-5" }, + }); + expect(sendBtn).toBeDisabled(); + }); + + it("AC3: Successful submit renders the transaction hash in the success panel", async () => { + const TX_HASH = "abc123def456abc123def456abc123def456abc123def456abc123def456"; + const mockSubmit = vi + .fn() + .mockResolvedValue({ data: { hash: TX_HASH, ledger: 42 }, error: null }); + const mockClient = { + transaction: { + submit: mockSubmit, + estimateFee: vi.fn().mockResolvedValue({ data: DEFAULT_FEE, error: null }), + }, + } as unknown as ReturnType; + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + client: mockClient, + } as unknown as ReturnType); + + render(); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "5" }, + }); + + await reviewAndConfirm(); + + // Success panel must display the transaction hash + expect(await screen.findByText("Transaction submitted")).toBeInTheDocument(); + expect(screen.getByText(TX_HASH)).toBeInTheDocument(); + }); + + it("AC4: Failed submit renders the error message in the error panel", async () => { + const ERROR_MSG = "op_underfunded"; + const mockSubmit = vi + .fn() + .mockResolvedValue({ data: null, error: ERROR_MSG }); + const mockClient = { + transaction: { + submit: mockSubmit, + estimateFee: vi.fn().mockResolvedValue({ data: DEFAULT_FEE, error: null }), + }, + } as unknown as ReturnType; + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + client: mockClient, + } as unknown as ReturnType); + + render(); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "5" }, + }); + + await reviewAndConfirm(); + + expect(await screen.findByText("Transaction failed")).toBeInTheDocument(); + expect(screen.getByText(ERROR_MSG)).toBeInTheDocument(); + }); + + it("AC5: clicking 'New Transaction' after success resets the form back to idle state", async () => { + const mockSubmit = vi + .fn() + .mockResolvedValue({ data: { hash: "somehash", ledger: 1 }, error: null }); + const mockClient = { + transaction: { + submit: mockSubmit, + estimateFee: vi.fn().mockResolvedValue({ data: DEFAULT_FEE, error: null }), + }, + } as unknown as ReturnType; + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + client: mockClient, + } as unknown as ReturnType); + + render(); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "5" }, + }); + + await reviewAndConfirm(); + await screen.findByText("Transaction submitted"); + + // The idle form (destination input) should NOT be present yet + expect(screen.queryByLabelText("Destination Address")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "New Transaction" })); + + // After reset the idle form is shown again + expect(screen.getByLabelText("Destination Address")).toBeInTheDocument(); + // Success panel is gone + expect(screen.queryByText("Transaction submitted")).not.toBeInTheDocument(); + // Send button is disabled because fields are empty + expect(screen.getByRole("button", { name: /^Send (XLM|USDC)/ })).toBeDisabled(); + }); + + it("AC5: clicking 'New Transaction' after error resets the form back to idle state", async () => { + const mockSubmit = vi + .fn() + .mockResolvedValue({ data: null, error: "Insufficient balance" }); + const mockClient = { + transaction: { + submit: mockSubmit, + estimateFee: vi.fn().mockResolvedValue({ data: DEFAULT_FEE, error: null }), + }, + } as unknown as ReturnType; + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + client: mockClient, + } as unknown as ReturnType); + + render(); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "5" }, + }); + + await reviewAndConfirm(); + await screen.findByText("Transaction failed"); + + fireEvent.click(screen.getByRole("button", { name: "New Transaction" })); + + // Error panel is gone, idle form is shown + expect(screen.queryByText("Transaction failed")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Destination Address")).toBeInTheDocument(); + }); + + it("AC6: pressing Enter inside the destination field submits the form (opens review modal)", async () => { + mockGetClient( + vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }), + ); + + render(); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "5" }, + }); + + // Enter key on the destination field should trigger form onSubmit → handleReview + fireEvent.submit(screen.getByLabelText("Destination Address").closest("form")!); + + expect( + await screen.findByRole("dialog", { name: /confirm transaction/i }), + ).toBeInTheDocument(); + }); + + it("AC6: pressing Enter inside the amount field submits the form (opens review modal)", async () => { + mockGetClient( + vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }), + ); + + render(); + + fireEvent.change(screen.getByLabelText("Destination Address"), { + target: { value: VALID_DEST }, + }); + fireEvent.change(screen.getByLabelText("Amount (XLM)"), { + target: { value: "5" }, + }); + + // Enter key on the amount field triggers form onSubmit → handleReview + fireEvent.submit(screen.getByLabelText("Amount (XLM)").closest("form")!); + + expect( + await screen.findByRole("dialog", { name: /confirm transaction/i }), + ).toBeInTheDocument(); + }); + }); + // ── previewMode (#315) ────────────────────────────────────────────────────── describe("previewMode", () => { it("submits directly without a confirmation modal when previewMode is false", async () => {