Add PaycheckView unit tests
Export ordinal/formatCurrency/formatPayDate for direct testing. Tests cover
pure function formatting, virtual/saved rendering, preview banner, error state,
month navigation (including year wrap), bill display, remaining balance, and
gross/net amounts. Uses vi.useFakeTimers({ toFake: ['Date'] }) to control
current date without faking setTimeout (which would break RTL's waitFor).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
253
client/src/__tests__/PaycheckView.test.jsx
Normal file
253
client/src/__tests__/PaycheckView.test.jsx
Normal file
@@ -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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaycheckView />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,8 @@ function todayISO() {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ordinal, formatCurrency, formatPayDate };
|
||||||
|
|
||||||
// ─── PaycheckColumn ───────────────────────────────────────────────────────────
|
// ─── PaycheckColumn ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) {
|
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user