Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 153 additions & 1 deletion src/app/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): Stream {
Expand Down Expand Up @@ -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(<DashboardPage />);
}

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(<DashboardPage />);
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({
Expand Down
142 changes: 142 additions & 0 deletions src/components/streams/StreamDetailsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -77,4 +79,144 @@ describe('StreamDetailsModal', () => {
fireEvent.click(dialog);
expect(onClose).not.toHaveBeenCalled();
});

describe('keyboard behaviour', () => {
function OpenerHarness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open details
</button>
{open && <StreamDetailsModal stream={MOCK_STREAM} onClose={() => setOpen(false)} />}
</>
);
}

it('moves focus to the close button when it opens', () => {
render(<StreamDetailsModal stream={MOCK_STREAM} onClose={vi.fn()} />);

expect(screen.getByLabelText('Close')).toHaveFocus();
});

it('calls onClose when Escape is pressed', () => {
const onClose = vi.fn();
render(<StreamDetailsModal stream={MOCK_STREAM} onClose={onClose} />);

fireEvent.keyDown(document, { key: 'Escape' });

expect(onClose).toHaveBeenCalledTimes(1);
});

it('does not call onClose for other keys', () => {
const onClose = vi.fn();
render(<StreamDetailsModal stream={MOCK_STREAM} onClose={onClose} />);

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(<StreamDetailsModal stream={MOCK_STREAM} onClose={onClose} />);

unmount();
fireEvent.keyDown(document, { key: 'Escape' });

expect(onClose).not.toHaveBeenCalled();
});

it('wraps Tab from the last focusable element back to the first', () => {
render(<StreamDetailsModal stream={MOCK_STREAM} onClose={vi.fn()} />);
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(<StreamDetailsModal stream={MOCK_STREAM} onClose={vi.fn()} />);
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(<StreamDetailsModal stream={MOCK_STREAM} onClose={vi.fn()} />);
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(<StreamDetailsModal stream={MOCK_STREAM} onClose={vi.fn()} />);
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(<StreamDetailsModal stream={MOCK_STREAM} onClose={vi.fn()} />);

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(<OpenerHarness />);
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(<OpenerHarness />);
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();
});
});
});
Loading
Loading