Add component tests for Bills, Financing, Settings, MonthlySummary, AnnualOverview

62 client tests now passing across 8 test files. Handles duplicate text in
stat cards vs summary tables using getAllByText, and error states via ok:false
mock responses to trigger component-level error messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:32:17 -04:00
parent bb2038c58c
commit ccd0fb2155
5 changed files with 670 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import AnnualOverview from '../pages/AnnualOverview.jsx';
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const annualData = {
year: 2026,
categories: ['Food', 'Gas'],
months: [
{
month: 1, month_name: 'January',
income_net: 4800, total_bills: 600, total_variable: 250,
total_one_time: 50, total_spending: 900, surplus_deficit: 3900,
variable_by_category: [{ category: 'Food', total: 200 }, { category: 'Gas', total: 50 }],
},
{
month: 2, month_name: 'February',
income_net: 4800, total_bills: 600, total_variable: 300,
total_one_time: 0, total_spending: 900, surplus_deficit: 3900,
variable_by_category: [{ category: 'Food', total: 250 }, { category: 'Gas', total: 50 }],
},
],
};
const emptyData = { year: 2026, categories: [], months: [] };
function mockFetch(data = annualData) {
return vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(data) }));
}
function renderOverview() {
return render(<MemoryRouter><AnnualOverview /></MemoryRouter>);
}
describe('AnnualOverview', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'], now: new Date('2026-03-19') });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('shows current year in nav', () => {
global.fetch = mockFetch();
renderOverview();
expect(screen.getByText('2026')).toBeInTheDocument();
});
it('shows loading then renders data', async () => {
global.fetch = mockFetch();
renderOverview();
expect(screen.getByText('Loading…')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Annual Income (net)')).toBeInTheDocument();
});
});
it('shows error on fetch failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Server error'));
renderOverview();
await waitFor(() => {
expect(screen.getByText(/Error:/)).toBeInTheDocument();
});
});
it('shows — for all stat cards when no data', async () => {
global.fetch = mockFetch(emptyData);
renderOverview();
await waitFor(() => {
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(4); // 4 stat cards
});
});
it('shows annual totals when data available', async () => {
global.fetch = mockFetch();
renderOverview();
await waitFor(() => {
// annualIncome = 4800 + 4800 = 9600 — appears in stat card and table footer
expect(screen.getAllByText('$9,600.00').length).toBeGreaterThanOrEqual(1);
});
});
it('shows all 12 months in table', async () => {
global.fetch = mockFetch();
renderOverview();
await waitFor(() => screen.getByText('January'));
// All 12 month names should appear in the table
expect(screen.getByText('January')).toBeInTheDocument();
expect(screen.getByText('June')).toBeInTheDocument();
expect(screen.getByText('December')).toBeInTheDocument();
});
it('shows — for months without data', async () => {
global.fetch = mockFetch();
renderOverview();
await waitFor(() => {
// March through December have no data — should show dashes
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThan(0);
});
});
it('navigates to previous year', async () => {
const user = userEvent.setup();
global.fetch = mockFetch();
renderOverview();
await waitFor(() => screen.getByText('2026'));
await user.click(screen.getByRole('button', { name: 'Previous year' }));
expect(screen.getByText('2025')).toBeInTheDocument();
});
it('navigates to next year', async () => {
const user = userEvent.setup();
global.fetch = mockFetch();
renderOverview();
await waitFor(() => screen.getByText('2026'));
await user.click(screen.getByRole('button', { name: 'Next year' }));
expect(screen.getByText('2027')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import Bills from '../pages/Bills.jsx';
function mockFetch(bills = []) {
return vi.fn((url, opts) => {
if (url === '/api/bills' && !opts) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(bills) });
}
// POST/PUT/DELETE/PATCH all succeed and re-fetch returns same list
return Promise.resolve({ ok: true, json: () => Promise.resolve(bills[0] || {}) });
});
}
function renderBills() {
return render(<MemoryRouter><Bills /></MemoryRouter>);
}
const sampleBills = [
{ id: 1, name: 'Electric', amount: 150, due_day: 5, assigned_paycheck: 1, category: 'Utilities', active: true, variable_amount: false },
{ id: 2, name: 'Water', amount: 50, due_day: 20, assigned_paycheck: 2, category: 'Utilities', active: false, variable_amount: false },
];
describe('Bills', () => {
afterEach(() => vi.restoreAllMocks());
it('shows loading state then renders bills table', async () => {
global.fetch = mockFetch(sampleBills);
renderBills();
expect(screen.getByText('Loading bills…')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Electric')).toBeInTheDocument();
expect(screen.getByText('Water')).toBeInTheDocument();
});
});
it('shows empty state when no bills', async () => {
global.fetch = mockFetch([]);
renderBills();
await waitFor(() => {
expect(screen.getByText(/No bills yet/)).toBeInTheDocument();
});
});
it('shows error state on fetch failure', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({}) });
renderBills();
await waitFor(() => {
expect(screen.getByText(/Error: Failed to load bills/)).toBeInTheDocument();
});
});
it('displays bill details in the table', async () => {
global.fetch = mockFetch(sampleBills);
renderBills();
await waitFor(() => {
expect(screen.getByText('Electric')).toBeInTheDocument();
expect(screen.getByText('$150.00')).toBeInTheDocument();
expect(screen.getByText('5th')).toBeInTheDocument();
expect(screen.getByText('#1')).toBeInTheDocument();
});
});
it('opens add form when "+ Add Bill" is clicked', async () => {
const user = userEvent.setup();
global.fetch = mockFetch([]);
renderBills();
await waitFor(() => screen.getByText('+ Add Bill'));
await user.click(screen.getByText('+ Add Bill'));
expect(screen.getByText('Add Bill')).toBeInTheDocument();
expect(screen.getByLabelText('Name')).toBeInTheDocument();
});
it('closes form on Cancel', async () => {
const user = userEvent.setup();
global.fetch = mockFetch([]);
renderBills();
await waitFor(() => screen.getByText('+ Add Bill'));
await user.click(screen.getByText('+ Add Bill'));
expect(screen.getByText('Add Bill')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.queryByText('Add Bill')).not.toBeInTheDocument();
});
it('opens edit form with existing values', async () => {
const user = userEvent.setup();
global.fetch = mockFetch(sampleBills);
renderBills();
await waitFor(() => screen.getAllByText('Edit'));
await user.click(screen.getAllByText('Edit')[0]);
expect(screen.getByText('Edit Bill')).toBeInTheDocument();
expect(screen.getByDisplayValue('Electric')).toBeInTheDocument();
expect(screen.getByDisplayValue('150')).toBeInTheDocument();
});
it('shows variable badge for variable bills', async () => {
const variableBill = { ...sampleBills[0], variable_amount: true, amount: 0 };
global.fetch = mockFetch([variableBill]);
renderBills();
await waitFor(() => {
expect(screen.getByText(/varies/)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import Financing from '../pages/Financing.jsx';
const activePlan = {
id: 1, name: 'Car Repair', total_amount: 1200, due_date: '2027-06-15',
start_date: '2026-01-01', assigned_paycheck: null,
active: true, paid_total: 300, remaining: 900, overdue: false,
paid_count: 3, total_count: 12,
};
const paidOffPlan = {
...activePlan, id: 2, name: 'Old Loan', active: false,
paid_total: 1200, remaining: 0,
};
function mockFetch(plans = []) {
return vi.fn((url, opts) => {
if (url === '/api/financing' && !opts) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(plans) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(plans[0] || {}) });
});
}
function renderFinancing() {
return render(<MemoryRouter><Financing /></MemoryRouter>);
}
describe('Financing', () => {
afterEach(() => vi.restoreAllMocks());
it('shows loading then renders active plans', async () => {
global.fetch = mockFetch([activePlan]);
renderFinancing();
expect(screen.getByText('Loading…')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Car Repair')).toBeInTheDocument();
});
});
it('shows empty state when no active plans', async () => {
global.fetch = mockFetch([]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText(/No active financing plans/)).toBeInTheDocument();
});
});
it('shows error on fetch failure', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({}) });
renderFinancing();
await waitFor(() => {
expect(screen.getByText(/Error: Failed to load/)).toBeInTheDocument();
});
});
it('shows paid off section for inactive plans', async () => {
global.fetch = mockFetch([activePlan, paidOffPlan]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText('Car Repair')).toBeInTheDocument();
expect(screen.getByText('Old Loan')).toBeInTheDocument();
expect(screen.getByText('Paid Off')).toBeInTheDocument();
});
});
it('shows "paid off" badge on inactive plans', async () => {
global.fetch = mockFetch([paidOffPlan]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText('paid off')).toBeInTheDocument();
});
});
it('shows split assignment text for null assigned_paycheck', async () => {
global.fetch = mockFetch([activePlan]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText(/Split across both paychecks/)).toBeInTheDocument();
});
});
it('shows specific paycheck assignment', async () => {
const plan = { ...activePlan, assigned_paycheck: 1 };
global.fetch = mockFetch([plan]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText(/Paycheck 1 only/)).toBeInTheDocument();
});
});
it('opens add form on "+ Add Plan" click', async () => {
const user = userEvent.setup();
global.fetch = mockFetch([]);
renderFinancing();
await waitFor(() => screen.getByText('+ Add Plan'));
await user.click(screen.getByText('+ Add Plan'));
expect(screen.getByText('New Financing Plan')).toBeInTheDocument();
});
it('opens edit form with existing values', async () => {
const user = userEvent.setup();
global.fetch = mockFetch([activePlan]);
renderFinancing();
await waitFor(() => screen.getByText('Edit'));
await user.click(screen.getByText('Edit'));
expect(screen.getByText('Edit Financing Plan')).toBeInTheDocument();
expect(screen.getByDisplayValue('Car Repair')).toBeInTheDocument();
});
it('shows overdue badge for overdue plans', async () => {
const overduePlan = { ...activePlan, overdue: true };
global.fetch = mockFetch([overduePlan]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText('overdue')).toBeInTheDocument();
});
});
it('shows paid and total amounts', async () => {
global.fetch = mockFetch([activePlan]);
renderFinancing();
await waitFor(() => {
expect(screen.getByText('$300.00 paid')).toBeInTheDocument();
expect(screen.getByText(/\$900\.00 remaining/)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import MonthlySummary from '../pages/MonthlySummary.jsx';
// Recharts uses ResizeObserver which doesn't exist in jsdom
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const summaryData = {
year: 2026, month: 3, month_name: 'March',
income: { gross: 6000, net: 4800 },
bills: { planned: 600, paid: 400, unpaid: 200, count: 3, paid_count: 2 },
actuals: { total: 250, count: 5, by_category: [{ category: 'Food', total: 250 }] },
one_time_expenses: { total: 50, count: 1 },
summary: { total_spending: 900, surplus_deficit: 3900 },
};
function mockFetch(data = summaryData) {
return vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(data) }));
}
function renderSummary() {
return render(<MemoryRouter><MonthlySummary /></MemoryRouter>);
}
describe('MonthlySummary', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'], now: new Date('2026-03-19') });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('shows current month in nav', () => {
global.fetch = mockFetch();
renderSummary();
expect(screen.getByText('March 2026')).toBeInTheDocument();
});
it('shows loading then renders summary data', async () => {
global.fetch = mockFetch();
renderSummary();
expect(screen.getByText('Loading…')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Net Income')).toBeInTheDocument();
// $4,800.00 appears in both the stat card and summary table
expect(screen.getAllByText('$4,800.00').length).toBeGreaterThanOrEqual(1);
});
});
it('shows error on fetch failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Server error'));
renderSummary();
await waitFor(() => {
expect(screen.getByText(/Error:/)).toBeInTheDocument();
});
});
it('displays stat cards with correct values', async () => {
global.fetch = mockFetch();
renderSummary();
await waitFor(() => {
expect(screen.getByText('Bills Planned')).toBeInTheDocument();
// $600.00, $900.00, $3,900.00 appear in both stat cards and the summary table
expect(screen.getAllByText('$600.00').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Total Spending')).toBeInTheDocument();
expect(screen.getAllByText('$900.00').length).toBeGreaterThanOrEqual(1);
// "Surplus / Deficit" appears in both stat card and table footer
expect(screen.getAllByText('Surplus / Deficit').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('$3,900.00').length).toBeGreaterThanOrEqual(1);
});
});
it('shows bills paid count', async () => {
global.fetch = mockFetch();
renderSummary();
await waitFor(() => {
expect(screen.getByText('2 of 3')).toBeInTheDocument();
});
});
it('navigates to previous month', async () => {
const user = userEvent.setup();
global.fetch = mockFetch();
renderSummary();
await waitFor(() => screen.getByText('March 2026'));
await user.click(screen.getByRole('button', { name: '←' }));
expect(screen.getByText('February 2026')).toBeInTheDocument();
});
it('navigates to next month', async () => {
const user = userEvent.setup();
global.fetch = mockFetch();
renderSummary();
await waitFor(() => screen.getByText('March 2026'));
await user.click(screen.getByRole('button', { name: '→' }));
expect(screen.getByText('April 2026')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import Settings from '../pages/Settings.jsx';
const configData = {
paycheck1_day: 1, paycheck2_day: 15,
paycheck1_gross: 3000, paycheck1_net: 2400,
paycheck2_gross: 3000, paycheck2_net: 2400,
};
function mockFetch(config = configData) {
return vi.fn((url, opts) => {
return Promise.resolve({ ok: true, json: () => Promise.resolve(config) });
});
}
function renderSettings() {
return render(<MemoryRouter><Settings /></MemoryRouter>);
}
describe('Settings', () => {
afterEach(() => vi.restoreAllMocks());
it('renders the settings form', () => {
global.fetch = mockFetch();
renderSettings();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByLabelText('Paycheck 1 Day')).toBeInTheDocument();
expect(screen.getByLabelText('Paycheck 2 Day')).toBeInTheDocument();
expect(screen.getByText('Save Settings')).toBeInTheDocument();
});
it('loads and displays current config values', async () => {
global.fetch = mockFetch();
renderSettings();
await waitFor(() => {
expect(screen.getByDisplayValue('1')).toBeInTheDocument(); // paycheck1_day
expect(screen.getByDisplayValue('15')).toBeInTheDocument(); // paycheck2_day
// both paychecks have gross=3000, so multiple inputs have this value
expect(screen.getAllByDisplayValue('3000').length).toBe(2);
});
});
it('shows error when config load fails', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
renderSettings();
await waitFor(() => {
expect(screen.getByText('Failed to load settings.')).toBeInTheDocument();
});
});
it('shows "Saved ✓" after successful save', async () => {
const user = userEvent.setup();
global.fetch = mockFetch();
renderSettings();
await waitFor(() => screen.getByDisplayValue('1'));
await user.click(screen.getByText('Save Settings'));
await waitFor(() => {
expect(screen.getByText(/Saved ✓/)).toBeInTheDocument();
});
});
it('shows error when save fails', async () => {
const user = userEvent.setup();
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(configData) }) // GET
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({}) }); // PUT
renderSettings();
await waitFor(() => screen.getByDisplayValue('1'));
await user.click(screen.getByText('Save Settings'));
await waitFor(() => {
expect(screen.getByText('Failed to save settings.')).toBeInTheDocument();
});
});
it('updates form field on input', async () => {
const user = userEvent.setup();
global.fetch = mockFetch();
renderSettings();
await waitFor(() => screen.getByLabelText('Paycheck 1 Day'));
const input = screen.getByLabelText('Paycheck 1 Day');
await user.clear(input);
await user.type(input, '5');
expect(input).toHaveValue(5);
});
});