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:
GitHub Copilot
2025-05-05 17:41:39 +00:00
parent d3855aa7e4
commit 7e5ed585f7
19 changed files with 1299 additions and 1096 deletions

View File

@@ -1,12 +1,9 @@
import { useStore } from '@nanostores/react';
import type React from 'react';
import { useEffect, useState } from 'react';
import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores/transactionStore';
import type { Account } from '../types';
import { formatCurrency } from '../utils';
type AccountSummaryProps = {};
export default function AccountSummary() {
const currentAccountId = useStore(currentAccountIdStore);
const refreshCounter = useStore(refreshKey);
@@ -16,7 +13,7 @@ export default function AccountSummary() {
useEffect(() => {
if (!currentAccountId) {
setAccount(null); // Clear account details if no account selected
setAccount(null);
setError(null);
setIsLoading(false);
return;
@@ -40,30 +37,31 @@ export default function AccountSummary() {
}
};
fetchDetails();
}, [currentAccountId]);
// Add a small delay to ensure the API has processed any changes
const timeoutId = setTimeout(() => {
fetchDetails();
}, 100);
// Determine content based on state
let balanceContent: React.ReactNode;
return () => clearTimeout(timeoutId);
}, [currentAccountId, refreshCounter]); // Dependent on both account ID and refresh counter
let balanceContent = null;
if (isLoading) {
balanceContent = <span className="loading-inline">Loading...</span>;
} else if (error) {
balanceContent = <span className="error-message">Error</span>;
balanceContent = <span className="error-message">Error: {error}</span>;
} else if (account) {
balanceContent = formatCurrency(account.balance);
} else {
balanceContent = 'N/A'; // Or some placeholder
balanceContent = 'N/A';
}
return (
<div className="account-summary">
<h4>Account Summary</h4>
{/* Keep the ID for potential external manipulation if needed, though ideally not */}
<p>
Balance: <span id="account-balance">{balanceContent}</span>
</p>
{/* Display error details if needed */}
{/* {error && <small className="error-message">{error}</small>} */}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
currentAccountId as currentAccountIdStore,
transactionSaved,
transactionToEdit as transactionToEditStore,
triggerRefresh,
} from '../stores/transactionStore';
import type { Transaction } from '../types';
@@ -22,50 +23,52 @@ export default function AddTransactionForm() {
const [editingId, setEditingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isEditMode = !!editingId;
// --- Effects ---
// Effect to set default date on mount
useEffect(() => {
// Only set default date if not editing
if (!transactionToEdit) {
setDate(new Date().toISOString().split('T')[0]);
}
}, [transactionToEdit]); // Rerun if edit mode changes
}, [transactionToEdit]);
// Effect to populate form when editing
useEffect(() => {
if (transactionToEdit) {
setEditingId(transactionToEdit.id);
// Format date correctly for input type="date"
try {
const dateObj = new Date(transactionToEdit.date);
// Check if date is valid before formatting
if (!Number.isNaN(dateObj.getTime())) {
// Directly format the date object (usually interpreted as UTC midnight)
// into the YYYY-MM-DD format required by the input.
// No timezone adjustment needed here.
setDate(dateObj.toISOString().split('T')[0]);
} else {
console.warn('Invalid date received for editing:', transactionToEdit.date);
setDate(''); // Set to empty if invalid
setDate('');
}
} catch (e) {
console.error('Error parsing date for editing:', e);
setDate(''); // Set to empty on error
setDate('');
}
setDescription(transactionToEdit.description);
setAmount(transactionToEdit.amount.toString());
setError(null); // Clear errors when starting edit
} else {
// Reset form if transactionToEdit becomes null (e.g., after saving or cancelling)
// but only if not already resetting via handleCancel or handleSubmit
if (!isLoading) {
resetForm();
}
setError(null);
setSuccessMessage(null);
} else if (!isLoading) {
resetForm();
}
}, [transactionToEdit, isLoading]); // Add isLoading dependency
}, [transactionToEdit, isLoading]);
// Clear success message after 5 seconds
useEffect(() => {
if (successMessage) {
const timer = setTimeout(() => {
setSuccessMessage(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [successMessage]);
// --- Helper Functions ---
const resetForm = () => {
@@ -74,7 +77,7 @@ export default function AddTransactionForm() {
setDescription('');
setAmount('');
setError(null);
// Don't reset isLoading here, it's handled in submit/cancel
setSuccessMessage(null);
};
const validateForm = (): string[] => {
@@ -96,7 +99,7 @@ export default function AddTransactionForm() {
errors.push('Date is required');
} else {
try {
const dateObj = new Date(`${date}T00:00:00`); // Treat input as local date
const dateObj = new Date(`${date}T00:00:00`);
if (Number.isNaN(dateObj.getTime())) {
errors.push('Invalid date format');
}
@@ -111,6 +114,7 @@ export default function AddTransactionForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccessMessage(null);
if (isLoading || !currentAccountId) {
if (!currentAccountId) setError('No account selected.');
@@ -126,11 +130,9 @@ export default function AddTransactionForm() {
setIsLoading(true);
try {
// Ensure date is sent in a consistent format (e.g., YYYY-MM-DD)
// The API should handle parsing this.
const transactionData = {
accountId: currentAccountId,
date: date, // Send as YYYY-MM-DD string
date: date,
description: description.trim(),
amount: Number.parseFloat(amount),
};
@@ -150,17 +152,39 @@ export default function AddTransactionForm() {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
} catch (jsonError) {
// Ignore if response is not JSON
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
}
const savedTransaction: Transaction = await response.json();
transactionSaved(savedTransaction); // Call store action instead of prop callback
resetForm(); // Reset form on success
// First notify about the saved transaction
transactionSaved(savedTransaction);
// Then explicitly trigger a refresh to ensure balance updates
triggerRefresh();
// Set success message before clearing form
setSuccessMessage(
isEditMode ? 'Transaction updated successfully' : 'Transaction created successfully',
);
// Only reset the form after the success message is shown
setTimeout(() => {
resetForm();
// Optionally collapse the form after success
const addTransactionSection = document.getElementById('add-transaction-section');
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn');
if (addTransactionSection?.classList.contains('expanded')) {
addTransactionSection.classList.replace('expanded', 'collapsed');
toggleAddTxnBtn?.setAttribute('aria-expanded', 'false');
}
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
const errorMessage = err instanceof Error ? err.message : 'An unexpected error occurred';
setError(errorMessage);
setSuccessMessage(null);
} finally {
setIsLoading(false);
}
@@ -168,14 +192,23 @@ export default function AddTransactionForm() {
const handleCancel = () => {
resetForm();
cancelEditingTransaction(); // Call store action instead of prop callback
cancelEditingTransaction();
};
// --- JSX ---
return (
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
<form id="add-transaction-form-react" role="form" onSubmit={handleSubmit} noValidate>
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4>
{error && <div className="error-message">{error}</div>}
{error && (
<div className="error-message" data-testid="error-message">
{error}
</div>
)}
{successMessage && (
<div className="success-message" data-testid="success-message">
{successMessage}
</div>
)}
<div className="form-group">
<label htmlFor="txn-date-react">Date</label>

View File

@@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react';
import { useStore } from '@nanostores/react';
import React, { useState, useEffect } from 'react';
import {
currentAccountId as currentAccountIdStore,
@@ -45,7 +44,7 @@ export default function TransactionTable({}: TransactionTableProps) {
};
fetchTransactions();
}, [currentAccountId]);
}, [currentAccountId, refreshCounter]);
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
@@ -76,15 +75,16 @@ export default function TransactionTable({}: TransactionTableProps) {
console.log(`Transaction ${txnId} deleted successfully.`);
// Remove from local state
setTransactions((currentTransactions) =>
currentTransactions.filter((txn) => txn.id !== txnId),
);
// Trigger refresh to update balances and table
triggerRefresh();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
console.error('Delete error:', error);
} finally {
}
};

View File

@@ -213,6 +213,58 @@ tbody tr:hover {
border-color: #f5c6cb;
}
.error-message {
color: #dc3545;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 15px;
font-size: 0.9em;
}
.success-message {
color: #198754;
background-color: #d1e7dd;
border: 1px solid #badbcc;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 15px;
font-size: 0.9em;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.cancel-btn {
background-color: #6c757d;
color: white;
padding: 8px 15px;
border: none;
border-radius: 3px;
cursor: pointer;
}
.cancel-btn:hover {
background-color: #5a6268;
}
.cancel-btn:disabled {
background-color: #6c757d;
opacity: 0.65;
cursor: not-allowed;
}
.help-text {
display: block;
margin-top: 4px;
color: #6c757d;
font-size: 0.85em;
}
/* Basic Responsive */
@media (max-width: 768px) {
.dashboard-layout {

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