diff --git a/client/src/__tests__/AnnualOverview.test.jsx b/client/src/__tests__/AnnualOverview.test.jsx
new file mode 100644
index 0000000..15081c4
--- /dev/null
+++ b/client/src/__tests__/AnnualOverview.test.jsx
@@ -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();
+}
+
+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();
+ });
+});
diff --git a/client/src/__tests__/Bills.test.jsx b/client/src/__tests__/Bills.test.jsx
new file mode 100644
index 0000000..2ac18ff
--- /dev/null
+++ b/client/src/__tests__/Bills.test.jsx
@@ -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();
+}
+
+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();
+ });
+ });
+});
diff --git a/client/src/__tests__/Financing.test.jsx b/client/src/__tests__/Financing.test.jsx
new file mode 100644
index 0000000..9f51dcf
--- /dev/null
+++ b/client/src/__tests__/Financing.test.jsx
@@ -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();
+}
+
+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();
+ });
+ });
+});
diff --git a/client/src/__tests__/MonthlySummary.test.jsx b/client/src/__tests__/MonthlySummary.test.jsx
new file mode 100644
index 0000000..96be3ae
--- /dev/null
+++ b/client/src/__tests__/MonthlySummary.test.jsx
@@ -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();
+}
+
+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();
+ });
+});
diff --git a/client/src/__tests__/Settings.test.jsx b/client/src/__tests__/Settings.test.jsx
new file mode 100644
index 0000000..c837dbc
--- /dev/null
+++ b/client/src/__tests__/Settings.test.jsx
@@ -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();
+}
+
+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);
+ });
+});