Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/__tests__/InvoiceExportButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { vi, describe, test, expect, beforeEach } from 'vitest';
import InvoiceExportButton from '@/components/InvoiceExportButton';
import type { Invoice } from '@stellar-split/sdk';

const mockInvoice: Invoice = {
id: 'test-invoice-123',
creator: 'GBBD47UZQ5DSJYNF4H46ZCT5GX6MCLJJWHCCTV4GMSD5SJWATLVL547Z',
status: 'PENDING',
token: 'USDC',
recipients: [
{ address: 'GTEST1', amount: BigInt(1000000) },
{ address: 'GTEST2', amount: BigInt(2000000) },
],
payments: [],
funded: BigInt(0),
deadline: 0,
};

// Mock the toast hook
vi.mock('@/hooks/useToast', () => ({
useToast: () => ({
addToast: vi.fn(),
}),
}));

describe('InvoiceExportButton', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('renders export button with initial text', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

expect(screen.getByRole('button')).toHaveTextContent('↓ Export PDF');
});

test('button is not disabled initially', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).not.toBeDisabled();
});

test('applies correct styling to button', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('px-3', 'py-1.5', 'rounded-lg', 'bg-gray-700');
});

test('button has focus-visible ring on focus', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('focus-visible:ring-2', 'focus-visible:ring-indigo-500');
});

test('maintains disabled state opacity styling', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('disabled:opacity-50');
});

test('button renders with proper button type', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveAttribute('type', 'button');
});

test('button contains spinner icon', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const svg = screen.getByRole('button').querySelector('svg');
expect(svg).toBeInTheDocument();
});

test('has aria-hidden attribute on spinner icon', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const svg = screen.getByRole('button').querySelector('svg');
expect(svg).toHaveAttribute('aria-hidden', 'true');
});

test('renders with flex layout for content', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('flex', 'items-center', 'gap-2');
});

test('applies hover styling classes', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('hover:bg-gray-600');
});

test('applies text styling for small font', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('text-sm', 'font-semibold');
});

test('applies transition-colors for smooth state changes', () => {
render(<InvoiceExportButton invoice={mockInvoice} total={BigInt(3000000)} />);

const button = screen.getByRole('button');
expect(button).toHaveClass('transition-colors');
});
});
141 changes: 141 additions & 0 deletions src/__tests__/InvoiceListSentinel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest';
import InvoiceListSentinel from '@/components/InvoiceListSentinel';

describe('InvoiceListSentinel', () => {
let mockObserverDisconnect: ReturnType<typeof vi.fn>;
let mockObserverObserve: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockObserverDisconnect = vi.fn();
mockObserverObserve = vi.fn();

global.IntersectionObserver = vi.fn((callback) => ({
observe: mockObserverObserve,
disconnect: mockObserverDisconnect,
unobserve: vi.fn(),
})) as any;
});

afterEach(() => {
vi.clearAllMocks();
});

test('calls observer.disconnect() on unmount', () => {
const { unmount } = render(
<InvoiceListSentinel onVisible={vi.fn()} loading={false} allLoaded={false} />
);

unmount();

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

test('observes the sentinel element on mount', () => {
render(<InvoiceListSentinel onVisible={vi.fn()} loading={false} allLoaded={false} />);

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

test('displays loading spinner when loading is true', () => {
render(<InvoiceListSentinel onVisible={vi.fn()} loading={true} allLoaded={false} />);

expect(screen.getByText('Loading more invoices…')).toBeInTheDocument();
});

test('displays all loaded message when allLoaded is true', () => {
render(<InvoiceListSentinel onVisible={vi.fn()} loading={false} allLoaded={true} />);

expect(screen.getByText('All invoices loaded')).toBeInTheDocument();
});

test('does not display loading spinner or all loaded message when loading is false and allLoaded is false', () => {
render(<InvoiceListSentinel onVisible={vi.fn()} loading={false} allLoaded={false} />);

expect(screen.queryByText('Loading more invoices…')).not.toBeInTheDocument();
expect(screen.queryByText('All invoices loaded')).not.toBeInTheDocument();
});

test('calls onVisible when sentinel enters viewport', () => {
const onVisible = vi.fn();
const mockCallback = vi.fn();

global.IntersectionObserver = vi.fn((callback) => {
// Simulate intersection
callback([{ isIntersecting: true } as IntersectionObserverEntry]);
return {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
}) as any;

render(<InvoiceListSentinel onVisible={onVisible} loading={false} allLoaded={false} />);

expect(onVisible).toHaveBeenCalled();
});

test('does not call onVisible when sentinel is not intersecting', () => {
const onVisible = vi.fn();

global.IntersectionObserver = vi.fn((callback) => {
// Simulate no intersection
callback([{ isIntersecting: false } as IntersectionObserverEntry]);
return {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
}) as any;

render(<InvoiceListSentinel onVisible={onVisible} loading={false} allLoaded={false} />);

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

test('uses default rootMargin of 300px when not specified', () => {
const IntersectionObserverSpy = vi.fn((callback) => ({
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
}));

global.IntersectionObserver = IntersectionObserverSpy as any;

render(<InvoiceListSentinel onVisible={vi.fn()} loading={false} allLoaded={false} />);

expect(IntersectionObserverSpy).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ rootMargin: '300px' })
);
});

test('uses custom rootMargin when specified', () => {
const IntersectionObserverSpy = vi.fn((callback) => ({
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
}));

global.IntersectionObserver = IntersectionObserverSpy as any;

render(
<InvoiceListSentinel onVisible={vi.fn()} loading={false} allLoaded={false} rootMargin="500px" />
);

expect(IntersectionObserverSpy).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ rootMargin: '500px' })
);
});

test('has aria-live attribute for accessibility', () => {
const { container } = render(
<InvoiceListSentinel onVisible={vi.fn()} loading={true} allLoaded={false} />
);

const sentinel = container.querySelector('[aria-live="polite"]');
expect(sentinel).toBeInTheDocument();
});
});
Loading
Loading