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:
154
client/src/__tests__/AnnualOverview.test.jsx
Normal file
154
client/src/__tests__/AnnualOverview.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
client/src/__tests__/Bills.test.jsx
Normal file
129
client/src/__tests__/Bills.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
156
client/src/__tests__/Financing.test.jsx
Normal file
156
client/src/__tests__/Financing.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
125
client/src/__tests__/MonthlySummary.test.jsx
Normal file
125
client/src/__tests__/MonthlySummary.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
106
client/src/__tests__/Settings.test.jsx
Normal file
106
client/src/__tests__/Settings.test.jsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user