diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx index 0cd6ff2..d3d8c9c 100644 --- a/src/app/dashboard/page.test.tsx +++ b/src/app/dashboard/page.test.tsx @@ -1,10 +1,11 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import DashboardPage from './page'; import * as WalletProviderMod from '@/components/wallet/WalletProvider'; import * as ApiMod from '@/lib/api'; +import * as CsvMod from '@/lib/csv'; import type { Stream } from '@/lib/api'; function makeStream(overrides: Partial = {}): Stream { @@ -274,6 +275,157 @@ describe('DashboardPage', () => { }); }); + describe('filter tabs', () => { + // StreamControls is mocked to null above, so an ACTIVE row is safe to + // render here. + const activeStream = makeStream({ + id: 's1', + onChainId: '101', + status: 'ACTIVE', + ngo: { id: 'ngo-1', name: 'Active NGO', ownerAddress: 'G1' }, + }); + const cancelledStream = makeStream({ + id: 's2', + onChainId: '202', + status: 'CANCELLED', + ngo: { id: 'ngo-2', name: 'Cancelled NGO', ownerAddress: 'G2' }, + }); + + function setup(streams: Stream[]) { + vi.spyOn(WalletProviderMod, 'useWallet').mockReturnValue({ + address: 'G123', + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTransaction: vi.fn(), + signMessage: vi.fn(), + }); + vi.spyOn(ApiMod, 'getStreams').mockResolvedValue(streams); + render(); + } + + it('selects the Active tab on first load when there are active streams', async () => { + setup([activeStream, cancelledStream]); + + expect(await screen.findByText('Active NGO')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Active' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByText('Cancelled NGO')).not.toBeInTheDocument(); + }); + + it('falls back to the All tab when no stream is active', async () => { + setup([cancelledStream]); + + expect(await screen.findByText('Cancelled NGO')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('shows the right streams for each of the All, Active and Cancelled tabs', async () => { + const user = userEvent.setup(); + setup([activeStream, cancelledStream]); + await screen.findByText('Active NGO'); + + await user.click(screen.getByRole('tab', { name: 'All' })); + expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Active NGO')).toBeInTheDocument(); + expect(screen.getByText('Cancelled NGO')).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'Cancelled' })); + expect(screen.getByRole('tab', { name: 'Cancelled' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByText('Active NGO')).not.toBeInTheDocument(); + expect(screen.getByText('Cancelled NGO')).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'Active' })); + expect(screen.getByRole('tab', { name: 'Active' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Active NGO')).toBeInTheDocument(); + expect(screen.queryByText('Cancelled NGO')).not.toBeInTheDocument(); + }); + + it('shows an empty message when the selected tab has no streams', async () => { + const user = userEvent.setup(); + setup([activeStream]); + await screen.findByText('Active NGO'); + + await user.click(screen.getByRole('tab', { name: 'Cancelled' })); + + expect(screen.getByText('No cancelled streams found.')).toBeInTheDocument(); + }); + + it('reveals the next page on Load more within a tab', async () => { + const user = userEvent.setup(); + const manyCancelled = Array.from({ length: 12 }, (_, i) => + makeStream({ id: `c${i + 1}`, onChainId: String(i + 1), status: 'CANCELLED' }), + ); + setup([activeStream, ...manyCancelled]); + await screen.findByText('Active NGO'); + + await user.click(screen.getByRole('tab', { name: 'Cancelled' })); + expect(screen.getAllByRole('button', { name: 'View details' })).toHaveLength(10); + + await user.click(screen.getByRole('button', { name: 'Load more' })); + + expect(screen.getAllByRole('button', { name: 'View details' })).toHaveLength(12); + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + }); + }); + + describe('export CSV', () => { + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + vi.restoreAllMocks(); + }); + + it('builds a CSV blob from all streams and downloads it', async () => { + const streams = [ + makeStream({ id: 's1', onChainId: '101', status: 'ACTIVE' }), + makeStream({ id: 's2', onChainId: '202', status: 'CANCELLED' }), + ]; + vi.spyOn(WalletProviderMod, 'useWallet').mockReturnValue({ + address: 'G123', + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTransaction: vi.fn(), + signMessage: vi.fn(), + }); + vi.spyOn(ApiMod, 'getStreams').mockResolvedValue(streams); + vi.mocked(CsvMod.buildDonationHistoryCsv).mockReturnValue('id,status\n101,ACTIVE'); + + const createObjectURL = vi.fn(() => 'blob:streamgive-csv'); + const revokeObjectURL = vi.fn(); + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + + let downloaded: HTMLAnchorElement | null = null; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + downloaded = this; + }); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Export CSV' })); + + // The export covers every stream, not just the ones in the current tab. + expect(CsvMod.buildDonationHistoryCsv).toHaveBeenCalledWith(streams); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + const blob = (createObjectURL.mock.calls[0] as unknown as [Blob])[0]; + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('text/csv;charset=utf-8;'); + expect(blob.size).toBe('id,status\n101,ACTIVE'.length); + + expect(downloaded).not.toBeNull(); + expect((downloaded as unknown as HTMLAnchorElement).download).toBe('streamgive-donations.csv'); + expect((downloaded as unknown as HTMLAnchorElement).href).toBe('blob:streamgive-csv'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:streamgive-csv'); + }); + }); + describe('background polling', () => { function mockConnectedWallet() { vi.spyOn(WalletProviderMod, 'useWallet').mockReturnValue({ diff --git a/src/components/streams/StreamDetailsModal.test.tsx b/src/components/streams/StreamDetailsModal.test.tsx index acb193f..c1b29d1 100644 --- a/src/components/streams/StreamDetailsModal.test.tsx +++ b/src/components/streams/StreamDetailsModal.test.tsx @@ -1,4 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { Stream } from '@/lib/api'; @@ -77,4 +79,144 @@ describe('StreamDetailsModal', () => { fireEvent.click(dialog); expect(onClose).not.toHaveBeenCalled(); }); + + describe('keyboard behaviour', () => { + function OpenerHarness() { + const [open, setOpen] = useState(false); + return ( + <> + + {open && setOpen(false)} />} + + ); + } + + it('moves focus to the close button when it opens', () => { + render(); + + expect(screen.getByLabelText('Close')).toHaveFocus(); + }); + + it('calls onClose when Escape is pressed', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose for other keys', () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(document, { key: 'Enter' }); + fireEvent.keyDown(document, { key: 'a' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('stops listening for Escape once it unmounts', () => { + const onClose = vi.fn(); + const { unmount } = render(); + + unmount(); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('wraps Tab from the last focusable element back to the first', () => { + render(); + const closeButton = screen.getByLabelText('Close'); + const lastLink = screen.getByRole('link', { name: 'View contract' }); + lastLink.focus(); + + const notPrevented = fireEvent.keyDown(document, { key: 'Tab' }); + + expect(closeButton).toHaveFocus(); + expect(notPrevented).toBe(false); + }); + + it('wraps Shift+Tab from the first focusable element to the last', () => { + render(); + const closeButton = screen.getByLabelText('Close'); + const lastLink = screen.getByRole('link', { name: 'View contract' }); + expect(closeButton).toHaveFocus(); + + const notPrevented = fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + + expect(lastLink).toHaveFocus(); + expect(notPrevented).toBe(false); + }); + + it('leaves Tab alone while focus is in the middle of the dialog', () => { + render(); + screen.getByRole('link', { name: 'Test NGO' }).focus(); + + const forward = fireEvent.keyDown(document, { key: 'Tab' }); + const backward = fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + + // Not prevented: the browser moves focus, and it stays inside the dialog. + expect(forward).toBe(true); + expect(backward).toBe(true); + expect(screen.getByRole('link', { name: 'Test NGO' })).toHaveFocus(); + }); + + it('keeps focus inside the dialog across a full Tab cycle', async () => { + const user = userEvent.setup(); + render(); + const dialog = screen.getByRole('dialog'); + const focusableCount = dialog.querySelectorAll('a[href], button').length; + + for (let i = 0; i < focusableCount * 2; i += 1) { + await user.tab(); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + } + for (let i = 0; i < focusableCount * 2; i += 1) { + await user.tab({ shift: true }); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + } + }); + + it('locks body scroll while open and restores it on close', () => { + document.body.style.overflow = 'scroll'; + const { unmount } = render(); + + expect(document.body.style.overflow).toBe('hidden'); + + unmount(); + + expect(document.body.style.overflow).toBe('scroll'); + document.body.style.overflow = ''; + }); + + it('returns focus to the opener when closed with Escape', async () => { + const user = userEvent.setup(); + render(); + const opener = screen.getByRole('button', { name: 'Open details' }); + + await user.click(opener); + expect(screen.getByLabelText('Close')).toHaveFocus(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(opener).toHaveFocus(); + }); + + it('returns focus to the opener when closed with the close button', async () => { + const user = userEvent.setup(); + render(); + const opener = screen.getByRole('button', { name: 'Open details' }); + + await user.click(opener); + await user.click(screen.getByLabelText('Close')); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(opener).toHaveFocus(); + }); + }); }); diff --git a/src/components/toast/ToastProvider.test.tsx b/src/components/toast/ToastProvider.test.tsx index 3b9f944..3ceb03c 100644 --- a/src/components/toast/ToastProvider.test.tsx +++ b/src/components/toast/ToastProvider.test.tsx @@ -97,4 +97,121 @@ describe('ToastProvider', () => { expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', 'https://stellar.expert/explorer/testnet/tx/123'); }); + + describe('pause, resume and dismiss', () => { + function renderWithToast(buttonLabel = 'Show Success') { + render( + + + , + ); + fireEvent.click(screen.getByText(buttonLabel)); + } + + const toastBox = () => screen.getByRole('status'); + + it('stops the auto-dismiss timer while the toast is hovered', () => { + renderWithToast(); + + act(() => { + vi.advanceTimersByTime(3000); + }); + fireEvent.mouseEnter(toastBox()); + act(() => { + vi.advanceTimersByTime(60_000); + }); + + expect(screen.getByText('Success Toast')).toBeInTheDocument(); + }); + + it('restarts the timer when the pointer leaves', () => { + renderWithToast(); + + fireEvent.mouseEnter(toastBox()); + act(() => { + vi.advanceTimersByTime(60_000); + }); + fireEvent.mouseLeave(toastBox()); + + act(() => { + vi.advanceTimersByTime(4999); + }); + expect(screen.getByText('Success Toast')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.queryByText('Success Toast')).not.toBeInTheDocument(); + }); + + it('stops the auto-dismiss timer while focus is inside the toast', () => { + renderWithToast(); + + act(() => { + vi.advanceTimersByTime(3000); + }); + fireEvent.focus(screen.getByRole('button', { name: 'Dismiss notification' })); + act(() => { + vi.advanceTimersByTime(60_000); + }); + + expect(screen.getByText('Success Toast')).toBeInTheDocument(); + }); + + it('restarts the timer when focus leaves the toast', () => { + renderWithToast(); + const dismiss = screen.getByRole('button', { name: 'Dismiss notification' }); + + fireEvent.focus(dismiss); + act(() => { + vi.advanceTimersByTime(60_000); + }); + fireEvent.blur(dismiss); + + act(() => { + vi.advanceTimersByTime(4999); + }); + expect(screen.getByText('Success Toast')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.queryByText('Success Toast')).not.toBeInTheDocument(); + }); + + it('only pauses the toast that is hovered', () => { + render( + + + , + ); + fireEvent.click(screen.getByText('Show Success')); + fireEvent.click(screen.getByText('Show Info')); + + fireEvent.mouseEnter(screen.getAllByRole('status')[0]); + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.getByText('Success Toast')).toBeInTheDocument(); + expect(screen.queryByText('Info Toast')).not.toBeInTheDocument(); + }); + + it('removes the toast immediately when the dismiss button is clicked', () => { + renderWithToast(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); + + expect(screen.queryByText('Success Toast')).not.toBeInTheDocument(); + }); + + it('clears the auto-dismiss timer of a manually dismissed toast', () => { + renderWithToast(); + expect(vi.getTimerCount()).toBe(1); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss notification' })); + + expect(vi.getTimerCount()).toBe(0); + }); + }); }); diff --git a/src/components/wallet/WalletProvider.test.tsx b/src/components/wallet/WalletProvider.test.tsx index 9943f77..7fa2831 100644 --- a/src/components/wallet/WalletProvider.test.tsx +++ b/src/components/wallet/WalletProvider.test.tsx @@ -68,6 +68,24 @@ function SignMessageProbe({ message }: { message: string }) { ); } +/** Exposes networkMismatch as text, so the raw flag can be asserted next to + * the "Wrong network" hint ConnectWalletButton derives from it. */ +function NetworkMismatchProbe() { + const { networkMismatch } = useWallet(); + return {String(networkMismatch)}; +} + +function renderButtonWithProbe() { + return render( + + + + + + , + ); +} + function renderSignMessageProbe(message = 'hello') { return render( @@ -205,6 +223,98 @@ describe('wallet connect flow', () => { }); }); +describe('networkMismatch', () => { + const OTHER_NETWORK = { + network: 'PUBLIC', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }; + + beforeEach(() => { + mockGetAddress.mockReset(); + mockAuthModal.mockReset(); + mockGetNetwork.mockClear(); + // Nothing to restore on mount: these tests connect through the modal. + mockGetAddress.mockRejectedValue(new Error('not connected')); + mockAuthModal.mockResolvedValue({ address: TEST_ADDRESS }); + }); + + async function connectWallet() { + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: /connect wallet/i })); + await screen.findByRole('button', { name: /connected as/i }); + return user; + } + + it('is true, and the button shows "Wrong network", when the wallet reports a different passphrase', async () => { + mockGetNetwork.mockResolvedValueOnce(OTHER_NETWORK); + + renderButtonWithProbe(); + await connectWallet(); + + expect(screen.getByTestId('network-mismatch')).toHaveTextContent('true'); + expect(screen.getByText('Wrong network')).toBeInTheDocument(); + }); + + it('is false, and no hint shows, when the wallet is on the matching network', async () => { + mockGetNetwork.mockResolvedValueOnce({ + network: 'TESTNET', + networkPassphrase: NETWORK_PASSPHRASE, + }); + + renderButtonWithProbe(); + await connectWallet(); + + expect(mockGetNetwork).toHaveBeenCalled(); + expect(screen.getByTestId('network-mismatch')).toHaveTextContent('false'); + expect(screen.queryByText('Wrong network')).not.toBeInTheDocument(); + }); + + it('is false when the wallet cannot report its network', async () => { + mockGetNetwork.mockRejectedValueOnce(new Error('getNetwork is not supported')); + + renderButtonWithProbe(); + await connectWallet(); + + expect(screen.getByTestId('network-mismatch')).toHaveTextContent('false'); + expect(screen.queryByText('Wrong network')).not.toBeInTheDocument(); + }); + + it('clears again after the wallet switches back to the matching network', async () => { + mockGetNetwork.mockResolvedValueOnce(OTHER_NETWORK); + + renderButtonWithProbe(); + await connectWallet(); + expect(screen.getByText('Wrong network')).toBeInTheDocument(); + + mockGetNetwork.mockResolvedValueOnce({ + network: 'TESTNET', + networkPassphrase: NETWORK_PASSPHRASE, + }); + await act(async () => { + window.dispatchEvent(new Event('focus')); + }); + + await waitFor(() => { + expect(screen.getByTestId('network-mismatch')).toHaveTextContent('false'); + }); + expect(screen.queryByText('Wrong network')).not.toBeInTheDocument(); + }); + + it('is cleared on disconnect', async () => { + mockGetNetwork.mockResolvedValueOnce(OTHER_NETWORK); + + renderButtonWithProbe(); + const user = await connectWallet(); + expect(screen.getByText('Wrong network')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /connected as/i })); + + expect(await screen.findByRole('button', { name: /connect wallet/i })).toBeInTheDocument(); + expect(screen.getByTestId('network-mismatch')).toHaveTextContent('false'); + expect(screen.queryByText('Wrong network')).not.toBeInTheDocument(); + }); +}); + describe('signMessage (SEP-53)', () => { beforeEach(() => { mockGetAddress.mockReset();