Skip to content

Commit a313663

Browse files
test(588): add tests for InvoiceListSentinel observer cleanup, InvoiceExportButton spinner, LineItemRow component, and InvoiceTimeline animation
1 parent 6234cd0 commit a313663

4 files changed

Lines changed: 600 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import InvoiceExportButton from '@/components/InvoiceExportButton';
3+
import type { Invoice } from '@stellar-split/sdk';
4+
5+
// Mock the dynamic PDF import
6+
jest.mock('@react-pdf/renderer', () => ({
7+
pdf: jest.fn(() => ({
8+
toBlob: jest.fn(async () => new Blob(['test'])),
9+
})),
10+
Document: jest.fn(() => null),
11+
Page: jest.fn(() => null),
12+
Text: jest.fn(() => null),
13+
View: jest.fn(() => null),
14+
StyleSheet: { create: jest.fn((obj) => obj) },
15+
Image: jest.fn(() => null),
16+
}));
17+
18+
jest.mock('@/lib/branding', () => ({
19+
fetchBrandSettings: jest.fn(async () => ({})),
20+
}));
21+
22+
describe('InvoiceExportButton', () => {
23+
const mockInvoice: Invoice = {
24+
id: 'test-id',
25+
creator: 'GTEST',
26+
recipients: [
27+
{ address: 'GRECIPIENT', amount: 100n },
28+
],
29+
payments: [],
30+
status: 'Pending',
31+
funded: 0n,
32+
token: 'USDC',
33+
deadline: 0,
34+
};
35+
36+
beforeEach(() => {
37+
global.URL.createObjectURL = jest.fn(() => 'blob:test');
38+
global.URL.revokeObjectURL = jest.fn();
39+
HTMLAnchorElement.prototype.click = jest.fn();
40+
41+
jest.spyOn(global, 'fetch').mockResolvedValue({
42+
ok: true,
43+
json: async () => ({}),
44+
} as any);
45+
});
46+
47+
afterEach(() => {
48+
jest.clearAllMocks();
49+
});
50+
51+
it('should render the export button', () => {
52+
render(
53+
<InvoiceExportButton
54+
invoice={mockInvoice}
55+
total={100n}
56+
/>
57+
);
58+
59+
expect(screen.getByRole('button', { name: /export pdf/i })).toBeInTheDocument();
60+
});
61+
62+
it('should be enabled initially', () => {
63+
render(
64+
<InvoiceExportButton
65+
invoice={mockInvoice}
66+
total={100n}
67+
/>
68+
);
69+
70+
const button = screen.getByRole('button', { name: /export pdf/i });
71+
expect(button).not.toBeDisabled();
72+
});
73+
74+
it('should be disabled while export is in progress', async () => {
75+
render(
76+
<InvoiceExportButton
77+
invoice={mockInvoice}
78+
total={100n}
79+
/>
80+
);
81+
82+
const button = screen.getByRole('button', { name: /export pdf/i });
83+
fireEvent.click(button);
84+
85+
await waitFor(() => {
86+
expect(button).toBeDisabled();
87+
});
88+
});
89+
90+
it('should show spinner icon while loading', async () => {
91+
render(
92+
<InvoiceExportButton
93+
invoice={mockInvoice}
94+
total={100n}
95+
/>
96+
);
97+
98+
const button = screen.getByRole('button', { name: /export pdf/i });
99+
fireEvent.click(button);
100+
101+
await waitFor(() => {
102+
const spinner = button.querySelector('svg.animate-spin');
103+
expect(spinner).toBeInTheDocument();
104+
});
105+
});
106+
107+
it('should show generating text while loading', async () => {
108+
render(
109+
<InvoiceExportButton
110+
invoice={mockInvoice}
111+
total={100n}
112+
/>
113+
);
114+
115+
const button = screen.getByRole('button', { name: /export pdf/i });
116+
fireEvent.click(button);
117+
118+
await waitFor(() => {
119+
expect(button).toHaveTextContent('Generating…');
120+
});
121+
});
122+
123+
it('should return to normal state after export completes', async () => {
124+
render(
125+
<InvoiceExportButton
126+
invoice={mockInvoice}
127+
total={100n}
128+
/>
129+
);
130+
131+
const button = screen.getByRole('button', { name: /export pdf/i });
132+
fireEvent.click(button);
133+
134+
await waitFor(() => {
135+
expect(button).not.toBeDisabled();
136+
expect(button).toHaveTextContent('Export PDF');
137+
});
138+
});
139+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import InvoiceListSentinel from '@/components/InvoiceListSentinel';
3+
4+
describe('InvoiceListSentinel', () => {
5+
let observerMock: { observe: jest.Mock; disconnect: jest.Mock; unobserve: jest.Mock };
6+
let IntersectionObserverMock: jest.Mock;
7+
8+
beforeEach(() => {
9+
observerMock = {
10+
observe: jest.fn(),
11+
disconnect: jest.fn(),
12+
unobserve: jest.fn(),
13+
};
14+
15+
IntersectionObserverMock = jest.fn(() => observerMock);
16+
(global as any).IntersectionObserver = IntersectionObserverMock;
17+
});
18+
19+
afterEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
it('should create an IntersectionObserver on mount', () => {
24+
const mockOnVisible = jest.fn();
25+
render(
26+
<InvoiceListSentinel
27+
onVisible={mockOnVisible}
28+
loading={false}
29+
allLoaded={false}
30+
/>
31+
);
32+
33+
expect(IntersectionObserverMock).toHaveBeenCalled();
34+
expect(observerMock.observe).toHaveBeenCalled();
35+
});
36+
37+
it('should disconnect the IntersectionObserver on unmount', () => {
38+
const mockOnVisible = jest.fn();
39+
const { unmount } = render(
40+
<InvoiceListSentinel
41+
onVisible={mockOnVisible}
42+
loading={false}
43+
allLoaded={false}
44+
/>
45+
);
46+
47+
unmount();
48+
49+
expect(observerMock.disconnect).toHaveBeenCalled();
50+
});
51+
52+
it('should call onVisible when intersection is detected', () => {
53+
const mockOnVisible = jest.fn();
54+
IntersectionObserverMock.mockImplementation((callback) => {
55+
setTimeout(() => {
56+
callback([{ isIntersecting: true }] as any);
57+
}, 0);
58+
return observerMock;
59+
});
60+
61+
render(
62+
<InvoiceListSentinel
63+
onVisible={mockOnVisible}
64+
loading={false}
65+
allLoaded={false}
66+
/>
67+
);
68+
69+
waitFor(() => {
70+
expect(mockOnVisible).toHaveBeenCalled();
71+
});
72+
});
73+
74+
it('should display loading spinner when loading is true', () => {
75+
const mockOnVisible = jest.fn();
76+
render(
77+
<InvoiceListSentinel
78+
onVisible={mockOnVisible}
79+
loading={true}
80+
allLoaded={false}
81+
/>
82+
);
83+
84+
expect(screen.getByText('Loading more invoices…')).toBeInTheDocument();
85+
});
86+
87+
it('should display all loaded message when allLoaded is true', () => {
88+
const mockOnVisible = jest.fn();
89+
render(
90+
<InvoiceListSentinel
91+
onVisible={mockOnVisible}
92+
loading={false}
93+
allLoaded={true}
94+
/>
95+
);
96+
97+
expect(screen.getByText('All invoices loaded')).toBeInTheDocument();
98+
});
99+
100+
it('should pass custom rootMargin to IntersectionObserver', () => {
101+
const mockOnVisible = jest.fn();
102+
const customMargin = '500px';
103+
104+
render(
105+
<InvoiceListSentinel
106+
onVisible={mockOnVisible}
107+
loading={false}
108+
allLoaded={false}
109+
rootMargin={customMargin}
110+
/>
111+
);
112+
113+
const callArgs = IntersectionObserverMock.mock.calls[0];
114+
expect(callArgs[1]).toEqual({ rootMargin: customMargin });
115+
});
116+
});

0 commit comments

Comments
 (0)