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
139 changes: 139 additions & 0 deletions src/__tests__/InvoiceExportButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import InvoiceExportButton from '@/components/InvoiceExportButton';
import type { Invoice } from '@stellar-split/sdk';

// Mock the dynamic PDF import
jest.mock('@react-pdf/renderer', () => ({
pdf: jest.fn(() => ({
toBlob: jest.fn(async () => new Blob(['test'])),
})),
Document: jest.fn(() => null),
Page: jest.fn(() => null),
Text: jest.fn(() => null),
View: jest.fn(() => null),
StyleSheet: { create: jest.fn((obj) => obj) },
Image: jest.fn(() => null),
}));

jest.mock('@/lib/branding', () => ({
fetchBrandSettings: jest.fn(async () => ({})),
}));

describe('InvoiceExportButton', () => {
const mockInvoice: Invoice = {
id: 'test-id',
creator: 'GTEST',
recipients: [
{ address: 'GRECIPIENT', amount: 100n },
],
payments: [],
status: 'Pending',
funded: 0n,
token: 'USDC',
deadline: 0,
};

beforeEach(() => {
global.URL.createObjectURL = jest.fn(() => 'blob:test');
global.URL.revokeObjectURL = jest.fn();
HTMLAnchorElement.prototype.click = jest.fn();

jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({}),
} as any);
});

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

it('should render the export button', () => {
render(
<InvoiceExportButton
invoice={mockInvoice}
total={100n}
/>
);

expect(screen.getByRole('button', { name: /export pdf/i })).toBeInTheDocument();
});

it('should be enabled initially', () => {
render(
<InvoiceExportButton
invoice={mockInvoice}
total={100n}
/>
);

const button = screen.getByRole('button', { name: /export pdf/i });
expect(button).not.toBeDisabled();
});

it('should be disabled while export is in progress', async () => {
render(
<InvoiceExportButton
invoice={mockInvoice}
total={100n}
/>
);

const button = screen.getByRole('button', { name: /export pdf/i });
fireEvent.click(button);

await waitFor(() => {
expect(button).toBeDisabled();
});
});

it('should show spinner icon while loading', async () => {
render(
<InvoiceExportButton
invoice={mockInvoice}
total={100n}
/>
);

const button = screen.getByRole('button', { name: /export pdf/i });
fireEvent.click(button);

await waitFor(() => {
const spinner = button.querySelector('svg.animate-spin');
expect(spinner).toBeInTheDocument();
});
});

it('should show generating text while loading', async () => {
render(
<InvoiceExportButton
invoice={mockInvoice}
total={100n}
/>
);

const button = screen.getByRole('button', { name: /export pdf/i });
fireEvent.click(button);

await waitFor(() => {
expect(button).toHaveTextContent('Generating…');
});
});

it('should return to normal state after export completes', async () => {
render(
<InvoiceExportButton
invoice={mockInvoice}
total={100n}
/>
);

const button = screen.getByRole('button', { name: /export pdf/i });
fireEvent.click(button);

await waitFor(() => {
expect(button).not.toBeDisabled();
expect(button).toHaveTextContent('Export PDF');
});
});
});
116 changes: 116 additions & 0 deletions src/__tests__/InvoiceListSentinel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { render, screen, waitFor } from '@testing-library/react';
import InvoiceListSentinel from '@/components/InvoiceListSentinel';

describe('InvoiceListSentinel', () => {
let observerMock: { observe: jest.Mock; disconnect: jest.Mock; unobserve: jest.Mock };
let IntersectionObserverMock: jest.Mock;

beforeEach(() => {
observerMock = {
observe: jest.fn(),
disconnect: jest.fn(),
unobserve: jest.fn(),
};

IntersectionObserverMock = jest.fn(() => observerMock);
(global as any).IntersectionObserver = IntersectionObserverMock;
});

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

it('should create an IntersectionObserver on mount', () => {
const mockOnVisible = jest.fn();
render(
<InvoiceListSentinel
onVisible={mockOnVisible}
loading={false}
allLoaded={false}
/>
);

expect(IntersectionObserverMock).toHaveBeenCalled();
expect(observerMock.observe).toHaveBeenCalled();
});

it('should disconnect the IntersectionObserver on unmount', () => {
const mockOnVisible = jest.fn();
const { unmount } = render(
<InvoiceListSentinel
onVisible={mockOnVisible}
loading={false}
allLoaded={false}
/>
);

unmount();

expect(observerMock.disconnect).toHaveBeenCalled();
});

it('should call onVisible when intersection is detected', () => {
const mockOnVisible = jest.fn();
IntersectionObserverMock.mockImplementation((callback) => {
setTimeout(() => {
callback([{ isIntersecting: true }] as any);
}, 0);
return observerMock;
});

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

waitFor(() => {
expect(mockOnVisible).toHaveBeenCalled();
});
});

it('should display loading spinner when loading is true', () => {
const mockOnVisible = jest.fn();
render(
<InvoiceListSentinel
onVisible={mockOnVisible}
loading={true}
allLoaded={false}
/>
);

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

it('should display all loaded message when allLoaded is true', () => {
const mockOnVisible = jest.fn();
render(
<InvoiceListSentinel
onVisible={mockOnVisible}
loading={false}
allLoaded={true}
/>
);

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

it('should pass custom rootMargin to IntersectionObserver', () => {
const mockOnVisible = jest.fn();
const customMargin = '500px';

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

const callArgs = IntersectionObserverMock.mock.calls[0];
expect(callArgs[1]).toEqual({ rootMargin: customMargin });
});
});
Loading
Loading