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); + }); +});