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);
|
||||
}
|
||||
|
||||
export { ordinal, formatCurrency, formatPayDate };
|
||||
|
||||
// ─── PaycheckColumn ───────────────────────────────────────────────────────────
|
||||
|
||||
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) {
|
||||
|
||||
Reference in New Issue
Block a user