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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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