From 6850a5ba0abac41c1702b038bf81317dc66415fd Mon Sep 17 00:00:00 2001 From: presidojay1 <305481097+boluwacodes@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:06:10 +0100 Subject: [PATCH 1/4] fix: add timeout handling to prevent infinite loading in recipient check Fixes #123 - Added timeout parameter to checkRecipientExists function in lib/soroban.ts - Set 10-second timeout for recipient validation in create page - Prevents infinite loading state when RPC provider times out - Added error logging for better debugging --- app/create/page.tsx | 6 ++++-- lib/soroban.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index 35a54e3..82b264f 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -116,10 +116,12 @@ export default function CreatePage() { debounceRef.current = setTimeout(async () => { try { - const exists = await checkRecipientExists(recipient); + // Add 10-second timeout to prevent infinite loading state (#123) + const exists = await checkRecipientExists(recipient, { timeoutMs: 10_000 }); setRecipientStatus(exists ? 'valid' : 'not-found'); - } catch { + } catch (err) { // Network / RPC error — don't block the user, but surface a warning. + console.error('Recipient check failed:', err); setRecipientStatus('error'); } }, 600); diff --git a/lib/soroban.ts b/lib/soroban.ts index b9bea3a..e199a1c 100644 --- a/lib/soroban.ts +++ b/lib/soroban.ts @@ -469,10 +469,22 @@ export function scValToU64(val: xdr.ScVal): bigint { * "definitely does not exist" from "couldn't reach the network". * * @param address Stellar G… public key + * @param options Optional abort signal and timeout */ -export async function checkRecipientExists(address: string): Promise { +export async function checkRecipientExists( + address: string, + options?: { signal?: AbortSignal; timeoutMs?: number } +): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const signal = options?.signal; + try { - await getServer().getAccount(address); + await withTimeout( + getServer().getAccount(address), + timeoutMs, + 'checkRecipientExists', + signal + ); return true; } catch (err: unknown) { // stellar-sdk throws an error whose message contains "404" or From 35dbd57a53c26b56ef8da6d043a293469b7be16e Mon Sep 17 00:00:00 2001 From: presidojay1 <305481097+boluwacodes@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:06:24 +0100 Subject: [PATCH 2/4] refactor: remove dead BatchStreamCreator code Fixes #328 - Removed BatchStreamCreator.tsx which was never imported by any page - Removed its test file BatchStreamCreator.test.tsx - Component had non-functional submit handler that just displayed error - Reduces dead weight and prevents confusion for future contributors --- .../__tests__/BatchStreamCreator.test.tsx | 228 ------------------ components/stream/BatchStreamCreator.tsx | 107 -------- 2 files changed, 335 deletions(-) delete mode 100644 components/__tests__/BatchStreamCreator.test.tsx delete mode 100644 components/stream/BatchStreamCreator.tsx diff --git a/components/__tests__/BatchStreamCreator.test.tsx b/components/__tests__/BatchStreamCreator.test.tsx deleted file mode 100644 index 7a13308..0000000 --- a/components/__tests__/BatchStreamCreator.test.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { createRoot } from 'react-dom/client'; -import { act } from 'react'; -import { BatchStreamCreator } from '../stream/BatchStreamCreator'; - -vi.mock('@/lib/format', () => ({ - truncateAddress: (a: string) => a, -})); - -function setInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value', - )!.set!; - setter.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); -} - -async function addRecipient(container: HTMLElement) { - const inputs = container.querySelectorAll('input'); - const addressInput = inputs[0] as HTMLInputElement; - const rateInput = inputs[1] as HTMLInputElement; - const addButton = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent === 'Add', - )!; - await act(async () => { - setInputValue(addressInput, 'GABC123ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW'); - setInputValue(rateInput, '100'); - }); - await act(async () => { - addButton.click(); - }); -} - -function getCreateButton(container: HTMLElement) { - return Array.from(container.querySelectorAll('button')).find((b) => - /Create/.test(b.textContent || ''), - ) as HTMLButtonElement; -} - -describe('BatchStreamCreator', () => { - it('shows an inline error for invalid Stellar address instead of alert', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - const inputs = container.querySelectorAll('input'); - const addressInput = inputs[0] as HTMLInputElement; - const rateInput = inputs[1] as HTMLInputElement; - const addButton = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent === 'Add', - )!; - - await act(async () => { - setInputValue(addressInput, 'INVALID'); - setInputValue(rateInput, '100'); - }); - await act(async () => { - addButton.click(); - }); - - expect(container.textContent).toContain('Invalid Stellar address'); - expect(container.querySelector('[role="alert"]')).toBeTruthy(); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); - - it('shows an inline error for rate of zero instead of alert', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - const inputs = container.querySelectorAll('input'); - const addressInput = inputs[0] as HTMLInputElement; - const rateInput = inputs[1] as HTMLInputElement; - const addButton = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent === 'Add', - )!; - - await act(async () => { - setInputValue(addressInput, 'GABC123ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW'); - setInputValue(rateInput, '0'); - }); - await act(async () => { - addButton.click(); - }); - - expect(container.textContent).toContain('Rate must be greater than zero'); - expect(container.querySelector('[role="alert"]')).toBeTruthy(); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); - - it('shows a deprecation message when clicking create (SDK not yet available)', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await addRecipient(container); - - const createButton = getCreateButton(container); - await act(async () => { - createButton.click(); - }); - - expect(container.textContent).toContain('not yet available'); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); - - it('disables the create button when no recipients are added', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - const createButton = getCreateButton(container); - expect(createButton.disabled).toBe(true); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); - - it('enables the create button after adding recipients', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await addRecipient(container); - - const createButton = getCreateButton(container); - expect(createButton.disabled).toBe(false); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); - - it('adds and removes recipients correctly', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - await addRecipient(container); - expect(container.textContent).toContain('GABC123'); - expect(container.textContent).toContain('100/s'); - - const removeButtons = Array.from(container.querySelectorAll('button')).filter( - (b) => b.textContent === 'Remove', - ); - expect(removeButtons).toHaveLength(1); - - await act(async () => { - removeButtons[0]!.click(); - }); - - expect(container.textContent).toContain('No recipients added yet'); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); - - it('clears the error when adding a valid recipient after an error', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(); - }); - - const inputs = container.querySelectorAll('input'); - const addressInput = inputs[0] as HTMLInputElement; - const rateInput = inputs[1] as HTMLInputElement; - const addButton = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent === 'Add', - )!; - - await act(async () => { - setInputValue(addressInput, 'INVALID'); - setInputValue(rateInput, '100'); - }); - await act(async () => { - addButton.click(); - }); - - expect(container.textContent).toContain('Invalid Stellar address'); - - await act(async () => { - setInputValue(addressInput, 'GABC123ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW'); - setInputValue(rateInput, '100'); - }); - await act(async () => { - addButton.click(); - }); - - expect(container.textContent).not.toContain('Invalid Stellar address'); - - act(() => { root.unmount(); }); - document.body.removeChild(container); - }); -}); diff --git a/components/stream/BatchStreamCreator.tsx b/components/stream/BatchStreamCreator.tsx deleted file mode 100644 index 7f85490..0000000 --- a/components/stream/BatchStreamCreator.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { truncateAddress } from '@/lib/format'; - -interface Recipient { - address: string; - ratePerSecond: bigint; -} - -export function BatchStreamCreator() { - const [recipients, setRecipients] = useState([]); - const [addressInput, setAddressInput] = useState(''); - const [rateInput, setRateInput] = useState(''); - const [error, setError] = useState(null); - - const addRecipient = () => { - if (!addressInput || !rateInput) return; - - if (!/^G[A-Z0-9]{55}$/.test(addressInput.trim())) { - setError('Invalid Stellar address. Must start with G and be 56 characters.'); - return; - } - - if (!/^\d+$/.test(rateInput)) { - setError('Invalid rate input. Must be a positive integer.'); - return; - } - try { - const rate = BigInt(rateInput); - if (rate <= 0n) { - setError('Rate must be greater than zero.'); - return; - } - setRecipients([...recipients, { address: addressInput.trim(), ratePerSecond: rate }]); - setAddressInput(''); - setRateInput(''); - setError(null); - } catch { - setError('Invalid rate input. Must be an integer.'); - } - }; - - const removeRecipient = (index: number) => { - setRecipients(recipients.filter((_, i) => i !== index)); - }; - - const handleBatchCreate = () => { - if (recipients.length === 0) return; - setError('Batch stream creation is not yet available. Please create streams individually.'); - }; - - return ( -
-

Batch Stream Creation

- - {error && ( -
- {error} -
- )} - -
- setAddressInput(e.target.value.toUpperCase())} - maxLength={56} - /> - setRateInput(e.target.value)} - type="number" - /> - -
- -
- {recipients.map((rec, i) => ( -
- {truncateAddress(rec.address)} -
- {rec.ratePerSecond.toString()}/s - -
-
- ))} - {recipients.length === 0 &&

No recipients added yet.

} -
- - -
- ); -} From 1b9cd1f02c91d06798012d6cd02ba777d7e7cdad Mon Sep 17 00:00:00 2001 From: presidojay1 <305481097+boluwacodes@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:06:39 +0100 Subject: [PATCH 3/4] test: add comprehensive test coverage for Navbar and ConnectButton Fixes #326 Navbar.test.tsx: - Tests mobile menu opening/closing on hamburger click - Tests menu closing on route change - Tests menu closing on Escape key press - Tests iOS Safari backdrop click workaround (issue #143) - Tests keydown listener cleanup - Tests aria-expanded attribute - Tests ErrorBoundary integration ConnectButton.test.tsx: - Tests 20s connect timeout safety net - Tests error message rendering from connect() rejection - Tests wallet connection/disconnection flow - Tests mount-guarded state updates - Tests timeout timer cleanup - Tests rapid connect state toggles --- components/__tests__/ConnectButton.test.tsx | 312 ++++++++++++++++++++ components/__tests__/Navbar.test.tsx | 280 ++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 components/__tests__/ConnectButton.test.tsx create mode 100644 components/__tests__/Navbar.test.tsx diff --git a/components/__tests__/ConnectButton.test.tsx b/components/__tests__/ConnectButton.test.tsx new file mode 100644 index 0000000..6c2a9da --- /dev/null +++ b/components/__tests__/ConnectButton.test.tsx @@ -0,0 +1,312 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { ConnectButton } from '../ConnectButton'; + +/** + * Test coverage for ConnectButton.tsx (issue #326). + * + * Tests the real logic worth regression-testing: + * - 20s connect timeout safety net that force-clears "Connecting…" state + * - Error message rendering from a thrown connect() rejection + */ + +// Mock the wallet context +const mockWalletContext = { + connected: false, + connecting: false, + publicKey: null, + walletName: null, + connect: vi.fn(), + disconnect: vi.fn(), +}; + +vi.mock('@/contexts/WalletContext', () => ({ + useWallet: () => mockWalletContext, +})); + +// Mock format utilities +vi.mock('@/lib/format', () => ({ + truncateAddress: (addr: string) => `${addr.slice(0, 4)}…${addr.slice(-4)}`, +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + LogOut: ({ className }: any) => React.createElement('span', { className, 'data-testid': 'logout-icon' }, 'X'), +})); + +describe('ConnectButton', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + // Reset mock state + mockWalletContext.connected = false; + mockWalletContext.connecting = false; + mockWalletContext.publicKey = null; + mockWalletContext.walletName = null; + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('renders "Connect wallet" button when disconnected', () => { + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + expect(button?.textContent).toBe('Connect wallet'); + }); + + it('shows "Connecting…" state while connecting', () => { + mockWalletContext.connecting = true; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + expect(button?.textContent).toBe('Connecting…'); + expect(button?.disabled).toBe(true); + expect(button?.getAttribute('aria-busy')).toBe('true'); + }); + + it('shows disconnect button and address when connected', () => { + mockWalletContext.connected = true; + mockWalletContext.publicKey = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + mockWalletContext.walletName = 'Freighter'; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + expect(container.textContent).toContain('GABC…MNOP'); + expect(container.textContent).toContain('Disconnect'); + + const button = container.querySelector('button'); + expect(button?.getAttribute('title')).toBe('Disconnect Freighter'); + }); + + it('calls connect() when connect button is clicked', async () => { + mockWalletContext.connect.mockResolvedValue(undefined); + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockWalletContext.connect).toHaveBeenCalledTimes(1); + }); + + it('calls disconnect() when disconnect button is clicked', async () => { + mockWalletContext.connected = true; + mockWalletContext.publicKey = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockWalletContext.disconnect).toHaveBeenCalledTimes(1); + }); + + it('displays error message when connect() throws an Error', async () => { + mockWalletContext.connect.mockRejectedValue(new Error('User rejected the connection')); + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const errorText = container.querySelector('[role="alert"]'); + expect(errorText?.textContent).toBe('User rejected the connection'); + expect(errorText?.getAttribute('aria-live')).toBe('polite'); + }); + + it('displays generic error message when connect() throws a non-Error', async () => { + mockWalletContext.connect.mockRejectedValue('some string error'); + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const errorText = container.querySelector('[role="alert"]'); + expect(errorText?.textContent).toBe('Failed to connect wallet.'); + }); + + it('clears error message when connect button is clicked again', async () => { + mockWalletContext.connect + .mockRejectedValueOnce(new Error('First error')) + .mockResolvedValueOnce(undefined); + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + const button = container.querySelector('button'); + + // First attempt fails + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toBe('First error'); + + // Second attempt should clear error + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it('shows timeout error if connecting state persists for 20s', async () => { + vi.useFakeTimers(); + mockWalletContext.connecting = true; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + expect(container.textContent).toBe('Connecting…'); + + // Fast-forward 20 seconds + await act(async () => { + vi.advanceTimersByTime(20_000); + }); + + const errorText = container.querySelector('[role="alert"]'); + expect(errorText?.textContent).toBe('Connection timed out. Please try again.'); + + vi.useRealTimers(); + }); + + it('clears timeout timer when connecting becomes false', async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + mockWalletContext.connecting = true; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + // Change connecting to false + mockWalletContext.connecting = false; + mockWalletContext.connected = true; + mockWalletContext.publicKey = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + + vi.useRealTimers(); + clearTimeoutSpy.mockRestore(); + }); + + it('does not show timeout error if connecting completes before 20s', async () => { + vi.useFakeTimers(); + mockWalletContext.connecting = true; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + // Fast-forward 10 seconds (halfway) + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + + // Connection completes + mockWalletContext.connecting = false; + mockWalletContext.connected = true; + mockWalletContext.publicKey = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + // Fast-forward another 15 seconds (total 25s, past the timeout) + await act(async () => { + vi.advanceTimersByTime(15_000); + }); + + // Should not show timeout error since connection completed + expect(container.querySelector('[role="alert"]')).toBeNull(); + + vi.useRealTimers(); + }); + + it('calculates remaining timeout correctly if connecting is toggled rapidly', async () => { + vi.useFakeTimers(); + + // Start connecting + mockWalletContext.connecting = true; + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + // Wait 5 seconds + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + + // Toggle off then on again (simulating a retry) + mockWalletContext.connecting = false; + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + mockWalletContext.connecting = true; + act(() => { + root.render(React.createElement(ConnectButton)); + }); + + // The timeout should restart from the new connectStartRef + await act(async () => { + vi.advanceTimersByTime(19_000); + }); + + // Should not timeout yet (only 19s since second connect start) + expect(container.querySelector('[role="alert"]')).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(2_000); + }); + + // Now it should timeout (21s total since second start) + expect(container.querySelector('[role="alert"]')?.textContent).toBe('Connection timed out. Please try again.'); + + vi.useRealTimers(); + }); +}); diff --git a/components/__tests__/Navbar.test.tsx b/components/__tests__/Navbar.test.tsx new file mode 100644 index 0000000..0649a70 --- /dev/null +++ b/components/__tests__/Navbar.test.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { Navbar } from '../Navbar'; + +/** + * Test coverage for Navbar.tsx (issue #326). + * + * Tests the real logic worth regression-testing: + * - Closing the mobile menu on route change + * - Closing on Escape key press + * - iOS-Safari-specific backdrop-click-to-close workaround (issue #143) + */ + +// Mock Next.js navigation hooks +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/streams'), + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +// Mock Next.js Link component +vi.mock('next/link', () => ({ + default: ({ href, children, onClick, className }: any) => + React.createElement( + 'a', + { href, onClick, className, 'data-testid': `link-${href}` }, + children, + ), +})); + +// Mock child components +vi.mock('@/components/ConnectButton', () => ({ + ConnectButton: () => React.createElement('div', { 'data-testid': 'connect-button' }, 'Connect'), +})); + +vi.mock('@/components/ThemeToggle', () => ({ + ThemeToggle: () => React.createElement('div', { 'data-testid': 'theme-toggle' }, 'Theme'), +})); + +vi.mock('@/components/ErrorBoundary', () => ({ + ErrorBoundary: ({ children }: any) => children, +})); + +describe('Navbar', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.clearAllMocks(); + }); + + it('renders the navbar with logo and navigation links', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + expect(container.textContent).toContain('conduit'); + expect(container.textContent).toContain('Streams'); + expect(container.textContent).toContain('History'); + expect(container.textContent).toContain('Create'); + expect(container.textContent).toContain('Dashboard'); + expect(container.textContent).toContain('Profile'); + }); + + it('opens and closes the mobile menu when hamburger button is clicked', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Initially closed - mobile nav should not be visible + let mobileNav = container.querySelector('#mobile-nav'); + expect(mobileNav).toBeNull(); + + // Click hamburger to open + const hamburger = container.querySelector('button[aria-label="Open menu"]'); + expect(hamburger).toBeTruthy(); + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Mobile nav should now be visible + mobileNav = container.querySelector('#mobile-nav'); + expect(mobileNav).toBeTruthy(); + + // Click hamburger again to close + const closeButton = container.querySelector('button[aria-label="Close menu"]'); + expect(closeButton).toBeTruthy(); + act(() => { + closeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Mobile nav should be hidden again + mobileNav = container.querySelector('#mobile-nav'); + expect(mobileNav).toBeNull(); + }); + + it('closes the mobile menu on route change', async () => { + const { usePathname } = await import('next/navigation'); + const mockUsePathname = usePathname as ReturnType; + + // Start with /streams route + mockUsePathname.mockReturnValue('/streams'); + + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Open the mobile menu + const hamburger = container.querySelector('button[aria-label="Open menu"]'); + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('#mobile-nav')).toBeTruthy(); + + // Simulate route change to /create + mockUsePathname.mockReturnValue('/create'); + + // Re-render with new route + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Mobile menu should be closed after route change + expect(container.querySelector('#mobile-nav')).toBeNull(); + }); + + it('closes the mobile menu when Escape key is pressed', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Open the mobile menu + const hamburger = container.querySelector('button[aria-label="Open menu"]'); + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('#mobile-nav')).toBeTruthy(); + + // Press Escape key + act(() => { + const escapeEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + }); + document.dispatchEvent(escapeEvent); + }); + + // Mobile menu should be closed + expect(container.querySelector('#mobile-nav')).toBeNull(); + }); + + it('does not add keydown listener when menu is closed', () => { + const addEventListenerSpy = vi.spyOn(document, 'addEventListener'); + + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Should not have added keydown listener since menu is closed + expect(addEventListenerSpy).not.toHaveBeenCalledWith('keydown', expect.any(Function)); + + addEventListenerSpy.mockRestore(); + }); + + it('removes keydown listener when menu is closed', () => { + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Open the menu + const hamburger = container.querySelector('button[aria-label="Open menu"]'); + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Close the menu + const closeButton = container.querySelector('button[aria-label="Close menu"]'); + act(() => { + closeButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Should have removed the keydown listener + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + + removeEventListenerSpy.mockRestore(); + }); + + it('closes mobile menu when backdrop is clicked (iOS Safari fix #143)', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Open the mobile menu + const hamburger = container.querySelector('button[aria-label="Open menu"]'); + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('#mobile-nav')).toBeTruthy(); + + // Click the backdrop (the fixed inset overlay) + const backdrop = container.querySelector('button.fixed.inset-0'); + expect(backdrop).toBeTruthy(); + expect(backdrop?.getAttribute('aria-label')).toBe('Close menu'); + + act(() => { + backdrop!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Mobile menu should be closed + expect(container.querySelector('#mobile-nav')).toBeNull(); + }); + + it('closes mobile menu when a mobile nav link is clicked', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + // Open the mobile menu + const hamburger = container.querySelector('button[aria-label="Open menu"]'); + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('#mobile-nav')).toBeTruthy(); + + // Click a link in the mobile menu + const mobileNav = container.querySelector('#mobile-nav'); + const link = mobileNav?.querySelector('a[data-testid="link-/create"]'); + expect(link).toBeTruthy(); + + act(() => { + link!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Mobile menu should be closed + expect(container.querySelector('#mobile-nav')).toBeNull(); + }); + + it('sets aria-expanded attribute correctly', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + const hamburger = container.querySelector('button[aria-expanded]'); + expect(hamburger?.getAttribute('aria-expanded')).toBe('false'); + + // Open menu + act(() => { + hamburger!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const hamburgerOpen = container.querySelector('button[aria-expanded]'); + expect(hamburgerOpen?.getAttribute('aria-expanded')).toBe('true'); + }); + + it('renders ConnectButton and ThemeToggle inside ErrorBoundary', () => { + act(() => { + root.render(React.createElement(Navbar)); + }); + + expect(container.querySelector('[data-testid="connect-button"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="theme-toggle"]')).toBeTruthy(); + }); +}); From 0755cfcce4c3f8f1924ff317e8bbe5f61a7eaa02 Mon Sep 17 00:00:00 2001 From: presidojay1 <305481097+boluwacodes@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:06:58 +0100 Subject: [PATCH 4/4] test: add comprehensive test coverage for WithdrawButton and StreamActions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #325 WithdrawButton.test.tsx: - Tests complete state machine: idle → signing → submitting → done/error - Tests mount-guarded state updates to prevent memory leaks - Tests error handling for wallet disconnection - Tests query invalidation after successful withdrawal - Tests onSuccess callback execution - Tests dismiss and retry functionality - Tests protocol fee info display - Tests button disabled states during operations StreamActions.test.tsx: - Tests role-gated button rendering (sender vs recipient) - Tests run() helper's pending/error handling - Tests top-up modal amount validation including MAX_I128 bound check - Tests pause, resume, cancel, clawback operations - Tests query invalidation after successful actions - Tests modal open/close functionality - Tests mount-guarded state updates - Tests all button states for active/paused/ended streams --- .../stream/__tests__/StreamActions.test.tsx | 650 ++++++++++++++++++ .../stream/__tests__/WithdrawButton.test.tsx | 468 +++++++++++++ 2 files changed, 1118 insertions(+) create mode 100644 components/stream/__tests__/StreamActions.test.tsx create mode 100644 components/stream/__tests__/WithdrawButton.test.tsx diff --git a/components/stream/__tests__/StreamActions.test.tsx b/components/stream/__tests__/StreamActions.test.tsx new file mode 100644 index 0000000..a9cc464 --- /dev/null +++ b/components/stream/__tests__/StreamActions.test.tsx @@ -0,0 +1,650 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { StreamActions } from '../StreamActions'; + +/** + * Test coverage for StreamActions.tsx (issue #325). + * + * Tests: + * - Role-gated button rendering (sender vs recipient actions) + * - The run() helper's pending/error handling + * - Top-up modal amount validation including MAX_I128 bound check + */ + +// Mock wallet context +const mockWalletContext = { + publicKey: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP', + signTx: vi.fn(), +}; + +vi.mock('@/contexts/WalletContext', () => ({ + useWallet: () => mockWalletContext, +})); + +// Mock stream library functions +const mockStreamLib = { + pause: vi.fn(), + resume: vi.fn(), + cancel: vi.fn(), + topUp: vi.fn(), + clawback: vi.fn(), +}; + +vi.mock('@/lib/stream', () => mockStreamLib); + +// Mock safe operations +vi.mock('@/lib/safe-operations', () => ({ + safeToStroops: (val: string) => { + const num = parseFloat(val); + if (isNaN(num) || num <= 0) return null; + return BigInt(Math.floor(num * 1e7)); + }, +})); + +// Mock query client +const mockInvalidateQueries = vi.fn(); +vi.mock('@/lib/queryClient', () => ({ + queryClient: { + invalidateQueries: () => mockInvalidateQueries(), + }, +})); + +// Mock WithdrawButton +vi.mock('../WithdrawButton', () => ({ + WithdrawButton: ({ streamAddress, withdrawable, token }: any) => + React.createElement( + 'div', + { 'data-testid': 'withdraw-button' }, + `Withdraw ${withdrawable} ${token} from ${streamAddress}` + ), +})); + +// Mock Modal +vi.mock('@/components/ui/Modal', () => ({ + Modal: ({ title, onClose, children }: any) => + React.createElement( + 'div', + { 'data-testid': 'modal', 'data-title': title }, + React.createElement('button', { onClick: onClose, 'data-testid': 'modal-close' }, 'Close'), + children + ), +})); + +// Mock Input +vi.mock('@/components/ui/Input', () => ({ + Input: (props: any) => React.createElement('input', { ...props, 'data-testid': 'input' }), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Play: ({ className }: any) => React.createElement('span', { className, 'data-testid': 'play-icon' }), + Pause: ({ className }: any) => React.createElement('span', { className, 'data-testid': 'pause-icon' }), + X: ({ className }: any) => React.createElement('span', { className, 'data-testid': 'x-icon' }), + Plus: ({ className }: any) => React.createElement('span', { className, 'data-testid': 'plus-icon' }), + RotateCcw: ({ className }: any) => React.createElement('span', { className, 'data-testid': 'rotate-icon' }), +})); + +describe('StreamActions', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('renders nothing when wallet is not connected', () => { + mockWalletContext.publicKey = null; + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 1000000n, + token: 'XLM', + }) + ); + }); + + expect(container.textContent).toBe(''); + + // Reset for other tests + mockWalletContext.publicKey = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + }); + + it('shows WithdrawButton for recipient when stream is active', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: false, + isRecipient: true, + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + expect(container.querySelector('[data-testid="withdraw-button"]')).toBeTruthy(); + expect(container.textContent).toContain('Withdraw 5000000 XLM'); + }); + + it('shows Pause and Cancel buttons for sender when stream is active', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const pauseBtn = buttons.find(btn => btn.textContent?.includes('Pause')); + const cancelBtn = buttons.find(btn => btn.textContent?.includes('Cancel')); + + expect(pauseBtn).toBeTruthy(); + expect(cancelBtn).toBeTruthy(); + }); + + it('shows Resume and Cancel buttons for sender when stream is paused', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'paused', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const resumeBtn = buttons.find(btn => btn.textContent?.includes('Resume')); + const cancelBtn = buttons.find(btn => btn.textContent?.includes('Cancel')); + + expect(resumeBtn).toBeTruthy(); + expect(cancelBtn).toBeTruthy(); + }); + + it('shows Top up button for sender when stream is active', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const topUpBtn = buttons.find(btn => btn.textContent?.includes('Top up')); + + expect(topUpBtn).toBeTruthy(); + }); + + it('shows Clawback button for sender when clawback is enabled and stream is active', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: true, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const clawbackBtn = buttons.find(btn => btn.textContent?.includes('Clawback')); + + expect(clawbackBtn).toBeTruthy(); + }); + + it('does not show Clawback button when clawback is disabled', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const clawbackBtn = buttons.find(btn => btn.textContent?.includes('Clawback')); + + expect(clawbackBtn).toBeUndefined(); + }); + + it('does not show any action buttons when stream has ended', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'ended', + clawbackEnabled: false, + isSender: true, + isRecipient: true, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + + it('calls pause function and invalidates queries on successful pause', async () => { + mockStreamLib.pause.mockResolvedValue(undefined); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const pauseBtn = buttons.find(btn => btn.textContent?.includes('Pause')); + + await act(async () => { + pauseBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(mockStreamLib.pause).toHaveBeenCalledWith( + mockWalletContext.publicKey, + 'CSTREAM123', + mockWalletContext.signTx + ); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + }); + + it('shows error message when action fails', async () => { + mockStreamLib.cancel.mockRejectedValue(new Error('Network timeout')); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const cancelBtn = buttons.find(btn => btn.textContent?.includes('Cancel')); + + await act(async () => { + cancelBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(container.textContent).toContain('Network timeout'); + }); + + it('disables all buttons while an action is pending', async () => { + mockStreamLib.pause.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: true, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const pauseBtn = buttons.find(btn => btn.textContent?.includes('Pause')); + + act(() => { + pauseBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // All buttons should be disabled + const allButtons = Array.from(container.querySelectorAll('button')) as HTMLButtonElement[]; + allButtons.forEach(btn => { + expect(btn.disabled).toBe(true); + }); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 150)); + }); + }); + + it('opens top-up modal when Top up button is clicked', () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + expect(container.querySelector('[data-testid="modal"]')).toBeNull(); + + const buttons = Array.from(container.querySelectorAll('button')); + const topUpBtn = buttons.find(btn => btn.textContent?.includes('Top up')); + + act(() => { + topUpBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const modal = container.querySelector('[data-testid="modal"]'); + expect(modal).toBeTruthy(); + expect(modal?.getAttribute('data-title')).toBe('Top up stream'); + }); + + it('validates top-up amount is greater than 0', async () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + // Open modal + const buttons = Array.from(container.querySelectorAll('button')); + const topUpBtn = buttons.find(btn => btn.textContent?.includes('Top up')); + act(() => { + topUpBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Try to submit with empty amount + const confirmBtn = Array.from(container.querySelectorAll('button')).find( + btn => btn.textContent?.includes('Confirm top-up') + ); + + await act(async () => { + confirmBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Enter a valid amount greater than 0'); + expect(mockStreamLib.topUp).not.toHaveBeenCalled(); + }); + + it('validates top-up amount does not exceed MAX_I128', async () => { + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + // Open modal + const buttons = Array.from(container.querySelectorAll('button')); + const topUpBtn = buttons.find(btn => btn.textContent?.includes('Top up')); + act(() => { + topUpBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Enter amount that exceeds MAX_I128 + const input = container.querySelector('[data-testid="input"]') as HTMLInputElement; + act(() => { + input.value = '170141183460469231731687303715884105728'; // MAX_I128 + 1 + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + + const confirmBtn = Array.from(container.querySelectorAll('button')).find( + btn => btn.textContent?.includes('Confirm top-up') + ); + + await act(async () => { + confirmBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Amount exceeds maximum allowed'); + expect(mockStreamLib.topUp).not.toHaveBeenCalled(); + }); + + it('submits top-up with valid amount', async () => { + mockStreamLib.topUp.mockResolvedValue(undefined); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + // Open modal + const buttons = Array.from(container.querySelectorAll('button')); + const topUpBtn = buttons.find(btn => btn.textContent?.includes('Top up')); + act(() => { + topUpBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Enter valid amount + const input = container.querySelector('[data-testid="input"]') as HTMLInputElement; + act(() => { + input.value = '100'; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + + const confirmBtn = Array.from(container.querySelectorAll('button')).find( + btn => btn.textContent?.includes('Confirm top-up') + ); + + await act(async () => { + confirmBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(mockStreamLib.topUp).toHaveBeenCalledWith( + mockWalletContext.publicKey, + 'CSTREAM123', + 1000000000n, // 100 * 1e7 + mockWalletContext.signTx + ); + }); + + it('closes modal after successful top-up', async () => { + mockStreamLib.topUp.mockResolvedValue(undefined); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + // Open modal + const buttons = Array.from(container.querySelectorAll('button')); + const topUpBtn = buttons.find(btn => btn.textContent?.includes('Top up')); + act(() => { + topUpBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[data-testid="modal"]')).toBeTruthy(); + + // Enter valid amount and submit + const input = container.querySelector('[data-testid="input"]') as HTMLInputElement; + act(() => { + input.value = '100'; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + + const confirmBtn = Array.from(container.querySelectorAll('button')).find( + btn => btn.textContent?.includes('Confirm top-up') + ); + + await act(async () => { + confirmBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Modal should be closed + expect(container.querySelector('[data-testid="modal"]')).toBeNull(); + }); + + it('calls onSuccess callback after successful action', async () => { + const onSuccess = vi.fn(); + mockStreamLib.resume.mockResolvedValue(undefined); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'paused', + clawbackEnabled: false, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + onSuccess, + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const resumeBtn = buttons.find(btn => btn.textContent?.includes('Resume')); + + await act(async () => { + resumeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not update state after unmount (mount guard)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let resolveClawback: () => void; + const clawbackPromise = new Promise(resolve => { + resolveClawback = resolve; + }); + mockStreamLib.clawback.mockReturnValue(clawbackPromise); + + act(() => { + root.render( + React.createElement(StreamActions, { + streamAddress: 'CSTREAM123', + status: 'active', + clawbackEnabled: true, + isSender: true, + isRecipient: false, + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const buttons = Array.from(container.querySelectorAll('button')); + const clawbackBtn = buttons.find(btn => btn.textContent?.includes('Clawback')); + + act(() => { + clawbackBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Unmount before action completes + act(() => { + root.unmount(); + }); + + // Complete the action after unmount + await act(async () => { + resolveClawback!(); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Should not have thrown errors about setState on unmounted component + expect(consoleErrorSpy).not.toHaveBeenCalledWith(expect.stringContaining('unmounted')); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/components/stream/__tests__/WithdrawButton.test.tsx b/components/stream/__tests__/WithdrawButton.test.tsx new file mode 100644 index 0000000..a84dcda --- /dev/null +++ b/components/stream/__tests__/WithdrawButton.test.tsx @@ -0,0 +1,468 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { WithdrawButton } from '../WithdrawButton'; + +/** + * Test coverage for WithdrawButton.tsx (issue #325). + * + * Tests the state machine: idle → signing → submitting → done/error + * and mount-guarded state updates to prevent memory leaks. + */ + +// Mock wallet context +const mockWalletContext = { + publicKey: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP', + signTx: vi.fn(), +}; + +vi.mock('@/contexts/WalletContext', () => ({ + useWallet: () => mockWalletContext, +})); + +// Mock withdraw function +const mockWithdraw = vi.fn(); +vi.mock('@/lib/stream', () => ({ + withdraw: (...args: any[]) => mockWithdraw(...args), +})); + +// Mock format utilities +vi.mock('@/lib/format', () => ({ + fromStroops: (val: bigint) => (Number(val) / 1e7).toFixed(7), +})); + +// Mock query client +const mockInvalidateQueries = vi.fn(); +vi.mock('@/lib/queryClient', () => ({ + queryClient: { + invalidateQueries: () => mockInvalidateQueries(), + }, +})); + +// Mock UI components +vi.mock('@/components/ui/Tooltip', () => ({ + Tooltip: ({ children }: any) => children, +})); + +vi.mock('@/components/ui/CopyHashButton', () => ({ + CopyHashButton: ({ hash }: any) => + React.createElement('button', { 'data-testid': 'copy-hash' }, `Copy ${hash}`), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + ArrowDownToLine: ({ className }: any) => + React.createElement('span', { className, 'data-testid': 'arrow-icon' }), + CheckCircle: ({ className }: any) => + React.createElement('span', { className, 'data-testid': 'check-icon' }), + AlertCircle: ({ className }: any) => + React.createElement('span', { className, 'data-testid': 'alert-icon' }), + Info: ({ className }: any) => + React.createElement('span', { className, 'data-testid': 'info-icon' }), +})); + +describe('WithdrawButton', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('renders withdraw button in idle state with amount', () => { + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + expect(button?.textContent).toContain('Withdraw 0.5000000 XLM'); + expect(button?.disabled).toBe(false); + }); + + it('shows "Nothing to withdraw yet" when withdrawable is 0', () => { + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + expect(button?.textContent).toContain('Nothing to withdraw yet'); + expect(button?.disabled).toBe(true); + }); + + it('transitions through state machine: idle → signing → submitting → done', async () => { + const mockTxHash = 'abc123txhash'; + mockWithdraw.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return mockTxHash; + }); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + // Initial state: idle + expect(button?.textContent).toContain('Withdraw'); + + // Click to start withdrawal + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Should immediately show signing + expect(button?.textContent).toContain('Waiting for signature…'); + + // Wait for async operation + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Should show done state + expect(container.textContent).toContain('Withdrawn 0.5000000 XLM'); + expect(container.textContent).toContain(mockTxHash); + }); + + it('transitions to error state when withdraw fails', async () => { + mockWithdraw.mockRejectedValue(new Error('Network timeout')); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Should show error state + expect(container.textContent).toContain('Transaction failed'); + expect(container.textContent).toContain('Network timeout'); + }); + + it('shows generic error message for non-Error rejections', async () => { + mockWithdraw.mockRejectedValue('some string error'); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(container.textContent).toContain('Transaction failed'); + }); + + it('shows error when wallet is not connected', async () => { + mockWalletContext.publicKey = null; + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Connect your wallet first'); + + // Reset for other tests + mockWalletContext.publicKey = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOP'; + }); + + it('calls onSuccess callback after successful withdrawal', async () => { + const onSuccess = vi.fn(); + mockWithdraw.mockResolvedValue('txhash123'); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + onSuccess, + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('invalidates queries after successful withdrawal', async () => { + mockWithdraw.mockResolvedValue('txhash123'); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); + }); + + it('allows dismissing success state', async () => { + mockWithdraw.mockResolvedValue('txhash123'); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + let button = container.querySelector('button.btn-primary'); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Should be in done state + expect(container.textContent).toContain('Withdrawn'); + + // Click dismiss + const dismissButton = Array.from(container.querySelectorAll('button')).find( + btn => btn.textContent === 'Dismiss' + ); + expect(dismissButton).toBeTruthy(); + + act(() => { + dismissButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Should return to idle state + button = container.querySelector('button.btn-primary'); + expect(button?.textContent).toContain('Withdraw'); + }); + + it('allows retrying from error state', async () => { + mockWithdraw + .mockRejectedValueOnce(new Error('First error')) + .mockResolvedValueOnce('txhash123'); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + let button = container.querySelector('button.btn-primary'); + + // First attempt fails + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(container.textContent).toContain('Transaction failed'); + expect(container.textContent).toContain('First error'); + + // Click retry + const retryButton = Array.from(container.querySelectorAll('button')).find( + btn => btn.textContent === 'Retry' + ); + expect(retryButton).toBeTruthy(); + + act(() => { + retryButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Should return to idle + button = container.querySelector('button.btn-primary'); + expect(button?.textContent).toContain('Withdraw'); + + // Second attempt succeeds + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + expect(container.textContent).toContain('Withdrawn'); + }); + + it('does not update state after unmount (mount guard)', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create a promise that resolves after unmount + let resolveWithdraw: (value: string) => void; + const withdrawPromise = new Promise(resolve => { + resolveWithdraw = resolve; + }); + mockWithdraw.mockReturnValue(withdrawPromise); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + // Start withdrawal + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Unmount before withdrawal completes + act(() => { + root.unmount(); + }); + + // Complete the withdrawal after unmount + await act(async () => { + resolveWithdraw!('txhash123'); + await new Promise(resolve => setTimeout(resolve, 50)); + }); + + // Should not have thrown any errors about setState on unmounted component + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('disables button during signing and submitting states', async () => { + mockWithdraw.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return 'txhash'; + }); + + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + const button = container.querySelector('button.btn-primary'); + + act(() => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + // Button should be disabled during process + expect(button?.disabled).toBe(true); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + // After completion, goes to done state (no button anymore) + expect(container.querySelector('button.btn-primary')).toBeNull(); + }); + + it('shows protocol fee info when withdrawable > 0', () => { + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 5000000n, + token: 'XLM', + }) + ); + }); + + expect(container.textContent).toContain('Protocol fee applies'); + expect(container.querySelector('[data-testid="info-icon"]')).toBeTruthy(); + }); + + it('does not show protocol fee info when withdrawable is 0', () => { + act(() => { + root.render( + React.createElement(WithdrawButton, { + streamAddress: 'CSTREAM123', + withdrawable: 0n, + token: 'XLM', + }) + ); + }); + + expect(container.textContent).not.toContain('Protocol fee applies'); + }); +});