diff --git a/client/src/__tests__/PaycheckView.test.jsx b/client/src/__tests__/PaycheckView.test.jsx new file mode 100644 index 0000000..7e005fb --- /dev/null +++ b/client/src/__tests__/PaycheckView.test.jsx @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import PaycheckView, { ordinal, formatCurrency, formatPayDate } from '../pages/PaycheckView.jsx'; + +// ─── Pure function tests ─────────────────────────────────────────────────────── + +describe('ordinal', () => { + it('formats 1st, 2nd, 3rd', () => { + expect(ordinal(1)).toBe('1st'); + expect(ordinal(2)).toBe('2nd'); + expect(ordinal(3)).toBe('3rd'); + expect(ordinal(4)).toBe('4th'); + expect(ordinal(11)).toBe('11th'); + expect(ordinal(12)).toBe('12th'); + expect(ordinal(13)).toBe('13th'); + expect(ordinal(21)).toBe('21st'); + expect(ordinal(22)).toBe('22nd'); + expect(ordinal(31)).toBe('31st'); + }); +}); + +describe('formatCurrency', () => { + it('formats numbers as USD', () => { + expect(formatCurrency(1500)).toBe('$1,500.00'); + expect(formatCurrency(0)).toBe('$0.00'); + expect(formatCurrency(99.9)).toBe('$99.90'); + expect(formatCurrency('150.5')).toBe('$150.50'); + expect(formatCurrency(null)).toBe('$0.00'); + expect(formatCurrency(undefined)).toBe('$0.00'); + }); +}); + +describe('formatPayDate', () => { + it('formats YYYY-MM-DD to readable date', () => { + expect(formatPayDate('2026-03-01')).toBe('March 1, 2026'); + expect(formatPayDate('2026-01-15')).toBe('January 15, 2026'); + expect(formatPayDate('2025-12-31')).toBe('December 31, 2025'); + }); +}); + +// ─── Component helpers ───────────────────────────────────────────────────────── + +const virtualPaycheck = (num) => ({ + id: null, + period_year: 2026, + period_month: 3, + paycheck_number: num, + pay_date: `2026-03-${num === 1 ? '01' : '15'}`, + gross: 3000, + net: 2400, + bills: [], + one_time_expenses: [], + financing: [], +}); + +const savedPaycheck = (num) => ({ + ...virtualPaycheck(num), + id: num, +}); + +function mockFetch(paychecks, categories = []) { + return vi.fn((url) => { + if (url.includes('/api/paychecks')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(paychecks) }); + } + if (url.includes('/api/expense-categories')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(categories) }); + } + if (url.includes('/api/actuals')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); +} + +function renderView() { + return render( + + + + ); +} + +// ─── PaycheckView rendering tests ───────────────────────────────────────────── + +describe('PaycheckView', () => { + beforeEach(() => { + // Fake only Date (not setTimeout/setInterval) so RTL's waitFor keeps working + vi.useFakeTimers({ toFake: ['Date'], now: new Date('2026-03-19') }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('shows current month and year in nav', async () => { + global.fetch = mockFetch([virtualPaycheck(1), virtualPaycheck(2)]); + + renderView(); + + expect(screen.getByText('March 2026')).toBeInTheDocument(); + }); + + it('shows loading state then renders paychecks', async () => { + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Paycheck 1')).toBeInTheDocument(); + expect(screen.getByText('Paycheck 2')).toBeInTheDocument(); + }); + }); + + it('shows virtual preview banner for virtual paychecks', async () => { + global.fetch = mockFetch([virtualPaycheck(1), virtualPaycheck(2)]); + + renderView(); + + await waitFor(() => { + expect(screen.getByText(/Previewing from current settings/)).toBeInTheDocument(); + }); + }); + + it('does not show preview banner for saved paychecks', async () => { + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + await waitFor(() => { + expect(screen.queryByText(/Previewing from current settings/)).not.toBeInTheDocument(); + }); + }); + + it('shows error state on fetch failure', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + renderView(); + + await waitFor(() => { + expect(screen.getByText(/Error: Network error/)).toBeInTheDocument(); + }); + }); + + it('navigates to previous month', async () => { + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + await waitFor(() => screen.getByText('March 2026')); + + await act(async () => { + await userEvent.click(screen.getByRole('button', { name: '←' })); + }); + + expect(screen.getByText('February 2026')).toBeInTheDocument(); + }); + + it('navigates to next month', async () => { + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + await waitFor(() => screen.getByText('March 2026')); + + await act(async () => { + await userEvent.click(screen.getByRole('button', { name: '→' })); + }); + + expect(screen.getByText('April 2026')).toBeInTheDocument(); + }); + + it('wraps from January to December on prev', async () => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ['Date'], now: new Date('2026-01-15') }); + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + await waitFor(() => screen.getByText('January 2026')); + + await act(async () => { + await userEvent.click(screen.getByRole('button', { name: '←' })); + }); + + expect(screen.getByText('December 2025')).toBeInTheDocument(); + }); + + it('renders bills in paycheck columns', async () => { + const pc1 = { + ...savedPaycheck(1), + bills: [ + { paycheck_bill_id: 10, bill_id: 1, name: 'Electric', amount: 150, effective_amount: 150, due_day: 5, category: 'Utilities', variable_amount: false, paid: false, paid_at: null, amount_override: null }, + ], + }; + global.fetch = mockFetch([pc1, savedPaycheck(2)]); + + renderView(); + + await waitFor(() => { + expect(screen.getByText('Electric')).toBeInTheDocument(); + expect(screen.getByText('$150.00')).toBeInTheDocument(); + expect(screen.getByText('due 5th')).toBeInTheDocument(); + }); + }); + + it('shows remaining balance', async () => { + const pc1 = { + ...savedPaycheck(1), + net: 2400, + bills: [ + { paycheck_bill_id: 10, bill_id: 1, name: 'Electric', amount: 150, effective_amount: 150, due_day: 5, category: 'Utilities', variable_amount: false, paid: false, paid_at: null, amount_override: null }, + ], + one_time_expenses: [], + financing: [], + }; + global.fetch = mockFetch([pc1, savedPaycheck(2)]); + + renderView(); + + await waitFor(() => { + // Remaining = 2400 - 150 = 2250 + expect(screen.getAllByText('$2,250.00')[0]).toBeInTheDocument(); + }); + }); + + it('shows empty state when no bills', async () => { + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + await waitFor(() => { + // Two paycheck columns, each with "(none)" for bills + expect(screen.getAllByText('(none)').length).toBeGreaterThanOrEqual(2); + }); + }); + + it('shows gross and net amounts', async () => { + global.fetch = mockFetch([savedPaycheck(1), savedPaycheck(2)]); + + renderView(); + + await waitFor(() => { + expect(screen.getAllByText('$3,000.00').length).toBeGreaterThan(0); // gross + expect(screen.getAllByText('$2,400.00').length).toBeGreaterThan(0); // net + }); + }); +}); diff --git a/client/src/pages/PaycheckView.jsx b/client/src/pages/PaycheckView.jsx index b6d1e3f..ef0e872 100644 --- a/client/src/pages/PaycheckView.jsx +++ b/client/src/pages/PaycheckView.jsx @@ -25,6 +25,8 @@ function todayISO() { return new Date().toISOString().slice(0, 10); } +export { ordinal, formatCurrency, formatPayDate }; + // ─── PaycheckColumn ─────────────────────────────────────────────────────────── function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) {