Merge branch 'main' into feature/database-integration

This commit is contained in:
GitHub Copilot
2025-05-06 10:08:15 +00:00
16 changed files with 1052 additions and 1001 deletions

View 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();
});
});
});
});

View File

@@ -1,3 +1,4 @@
import '@testing-library/jest-dom';
import type { APIContext } from 'astro';
// Create a mock APIContext factory