mirror of
https://github.com/acedanger/finance.git
synced 2025-12-06 07:00:13 -08:00
feat: add testing infrastructure and improve component feedback
- Added React testing setup with JSDOM - Added component tests for AddTransactionForm and TransactionTable - Improved error handling and success messages in components - Updated test configuration and dependencies - Added CSS for error and success states
This commit is contained in:
293
src/test/components.test.tsx
Normal file
293
src/test/components.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import AddTransactionForm from '../components/AddTransactionForm';
|
||||
import TransactionTable from '../components/TransactionTable';
|
||||
import type { Transaction } from '../types';
|
||||
|
||||
// Create hoisted mocks that are safe to use in vi.mock
|
||||
const createMocks = vi.hoisted(() => ({
|
||||
useStore: vi.fn((store) => {
|
||||
// Match store values based on the store reference
|
||||
switch (store?.get?.()) {
|
||||
case createMocks.currentAccountId.get():
|
||||
return '1';
|
||||
case createMocks.transactionToEdit.get():
|
||||
return null;
|
||||
case createMocks.refreshKey.get():
|
||||
return 0;
|
||||
default:
|
||||
return store?.get?.();
|
||||
}
|
||||
}),
|
||||
currentAccountId: { get: vi.fn(() => '1') },
|
||||
transactionToEdit: { get: vi.fn(() => null) },
|
||||
refreshKey: { get: vi.fn(() => 0) },
|
||||
setCurrentAccountId: vi.fn(),
|
||||
setTransactionToEdit: vi.fn(),
|
||||
triggerRefresh: vi.fn(),
|
||||
cancelEditingTransaction: vi.fn(),
|
||||
transactionSaved: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock modules using hoisted mocks
|
||||
vi.mock('@nanostores/react', () => ({
|
||||
useStore: createMocks.useStore,
|
||||
}));
|
||||
|
||||
vi.mock('../stores/transactionStore', () => ({
|
||||
currentAccountId: createMocks.currentAccountId,
|
||||
transactionToEdit: createMocks.transactionToEdit,
|
||||
refreshKey: createMocks.refreshKey,
|
||||
setCurrentAccountId: createMocks.setCurrentAccountId,
|
||||
setTransactionToEdit: createMocks.setTransactionToEdit,
|
||||
triggerRefresh: createMocks.triggerRefresh,
|
||||
cancelEditingTransaction: createMocks.cancelEditingTransaction,
|
||||
transactionSaved: createMocks.transactionSaved,
|
||||
}));
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('Transaction Management Components', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
createMocks.currentAccountId.get.mockReturnValue('1');
|
||||
createMocks.transactionToEdit.get.mockReturnValue(null);
|
||||
createMocks.refreshKey.get.mockReturnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('AddTransactionForm', () => {
|
||||
it('should render the form with default values', () => {
|
||||
render(<AddTransactionForm />);
|
||||
|
||||
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/amount/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save transaction/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
render(<AddTransactionForm />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /save transaction/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/description must be at least/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle successful transaction creation', async () => {
|
||||
const mockTransaction: Transaction = {
|
||||
id: '123',
|
||||
accountId: '1',
|
||||
date: '2025-05-05',
|
||||
description: 'Test Transaction',
|
||||
amount: -50,
|
||||
};
|
||||
|
||||
// Mock fetch to delay the response slightly to simulate real API
|
||||
(global.fetch as jest.Mock).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTransaction),
|
||||
});
|
||||
}, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AddTransactionForm />);
|
||||
|
||||
// Fill out form
|
||||
const descInput = screen.getByLabelText(/description/i);
|
||||
const amountInput = screen.getByLabelText(/amount/i);
|
||||
|
||||
// Fill form with valid data
|
||||
await act(async () => {
|
||||
fireEvent.change(descInput, { target: { value: 'Test Transaction' } });
|
||||
fireEvent.change(amountInput, { target: { value: '-50' } });
|
||||
});
|
||||
|
||||
// Submit form
|
||||
await act(async () => {
|
||||
fireEvent.submit(screen.getByRole('form'));
|
||||
});
|
||||
|
||||
// Use a more generous timeout for waitFor
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId('success-message')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
expect(createMocks.transactionSaved).toHaveBeenCalledWith(mockTransaction);
|
||||
expect(createMocks.triggerRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
// Mock fetch to delay the error response
|
||||
(global.fetch as jest.Mock).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
}, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AddTransactionForm />);
|
||||
|
||||
// Fill out form
|
||||
const descInput = screen.getByLabelText(/description/i);
|
||||
const amountInput = screen.getByLabelText(/amount/i);
|
||||
|
||||
// Fill form with valid data
|
||||
await act(async () => {
|
||||
fireEvent.change(descInput, { target: { value: 'Test Transaction' } });
|
||||
fireEvent.change(amountInput, { target: { value: '-50' } });
|
||||
});
|
||||
|
||||
// Submit form
|
||||
await act(async () => {
|
||||
fireEvent.submit(screen.getByRole('form'));
|
||||
});
|
||||
|
||||
// Use a more generous timeout for waitFor
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId('error-message')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('error-message')).toHaveTextContent(/500: Internal Server Error/i);
|
||||
});
|
||||
|
||||
it('should handle edit mode', async () => {
|
||||
const mockTransaction: Transaction = {
|
||||
id: '123',
|
||||
accountId: '1',
|
||||
date: '2025-05-05',
|
||||
description: 'Test Transaction',
|
||||
amount: -50,
|
||||
};
|
||||
|
||||
// Set up edit mode
|
||||
createMocks.transactionToEdit.get.mockReturnValue(mockTransaction);
|
||||
createMocks.useStore.mockImplementation((store) => {
|
||||
if (store === createMocks.transactionToEdit) return mockTransaction;
|
||||
return store?.get?.();
|
||||
});
|
||||
|
||||
render(<AddTransactionForm />);
|
||||
|
||||
// Verify form is populated with transaction data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/date/i)).toHaveValue('2025-05-05');
|
||||
expect(screen.getByLabelText(/description/i)).toHaveValue('Test Transaction');
|
||||
expect(screen.getByLabelText(/amount/i)).toHaveDisplayValue('-50');
|
||||
expect(screen.getByRole('button', { name: /update/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransactionTable', () => {
|
||||
const mockTransactions: Transaction[] = [
|
||||
{
|
||||
id: '1',
|
||||
accountId: '1',
|
||||
date: '2025-05-05',
|
||||
description: 'Test Transaction 1',
|
||||
amount: -50,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
accountId: '1',
|
||||
date: '2025-05-05',
|
||||
description: 'Test Transaction 2',
|
||||
amount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
it('should render transactions', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTransactions),
|
||||
});
|
||||
|
||||
render(<TransactionTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Transaction 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Transaction 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle transaction deletion', async () => {
|
||||
(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTransactions),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 204,
|
||||
});
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm');
|
||||
confirmSpy.mockImplementation(() => true);
|
||||
|
||||
render(<TransactionTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Transaction 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const deleteButton = screen.getAllByRole('button', { name: /delete/i })[0];
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/transactions/1',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
expect(createMocks.triggerRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle loading state', async () => {
|
||||
(global.fetch as jest.Mock).mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<TransactionTable />);
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle error state', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch'));
|
||||
|
||||
render(<TransactionTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import type { APIContext } from 'astro';
|
||||
import { beforeEach } from 'vitest';
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
import { accounts, transactions } from '../data/store';
|
||||
|
||||
// Setup JSDOM globals needed for React testing
|
||||
// @ts-ignore - vi.stubGlobal is not in the types
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
// Create a mock APIContext factory
|
||||
export function createMockAPIContext(options: Partial<APIContext> = {}): APIContext {
|
||||
return {
|
||||
@@ -55,4 +60,8 @@ beforeEach(() => {
|
||||
amount: 100.0,
|
||||
},
|
||||
);
|
||||
|
||||
// Reset fetch mock
|
||||
// @ts-ignore - vi.fn() is not in the types
|
||||
fetch.mockReset();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user