Add paychecks route unit tests

Tests cover GET /api/paychecks (virtual + saved), GET /api/paychecks/months,
POST /api/paychecks/generate, PATCH /api/paychecks/:id (gross/net),
PATCH /api/paycheck-bills/:id/amount, and PATCH /api/paycheck-bills/:id/paid
(paid toggle + amount_override locking).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:08:49 -04:00
parent d338283d8f
commit 1106ec770c

View File

@@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
const app = require('../app');
const db = require('../db');
const originalQuery = db.pool.query;
const originalConnect = db.pool.connect;
// Mock pool.query
db.pool.query = vi.fn();
// Mock pool.connect (used by generatePaychecks and buildVirtualPaychecks for financing)
const mockClient = {
query: vi.fn(),
release: vi.fn(),
};
db.pool.connect = vi.fn().mockResolvedValue(mockClient);
afterAll(() => {
db.pool.query = originalQuery;
db.pool.connect = originalConnect;
});
// Helper: config query returns sensible defaults
function mockConfig(overrides = {}) {
const defaults = {
paycheck1_day: '1', paycheck2_day: '15',
paycheck1_gross: '3000', paycheck1_net: '2400',
paycheck2_gross: '3000', paycheck2_net: '2400',
};
const merged = { ...defaults, ...overrides };
return {
rows: Object.entries(merged).map(([key, value]) => ({ key, value })),
};
}
describe('GET /api/paychecks', () => {
beforeEach(() => {
vi.clearAllMocks();
mockClient.query.mockReset();
mockClient.release.mockReset();
});
it('returns 400 when year/month missing', async () => {
const res = await request(app).get('/api/paychecks');
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'year and month (1-12) are required query params' });
});
it('returns 400 for invalid month', async () => {
const res = await request(app).get('/api/paychecks?year=2026&month=13');
expect(res.status).toBe(400);
});
it('returns virtual paychecks when no DB records exist', async () => {
// First call: check for existing records → none
db.pool.query
.mockResolvedValueOnce({ rows: [] }) // SELECT id FROM paychecks
.mockResolvedValueOnce(mockConfig()) // getConfig
.mockResolvedValueOnce({ rows: [] }) // bills for paycheck 1
.mockResolvedValueOnce({ rows: [] }) // bills for paycheck 2
.mockResolvedValueOnce({ rows: [] }); // active financing plans
mockClient.query.mockResolvedValue({ rows: [] }); // client.release path for financing
const res = await request(app).get('/api/paychecks?year=2026&month=3');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].id).toBeNull();
expect(res.body[0].paycheck_number).toBe(1);
expect(res.body[1].paycheck_number).toBe(2);
expect(res.body[0].gross).toBe(3000);
expect(res.body[0].bills).toEqual([]);
});
it('returns virtual paychecks with bills attached', async () => {
const bills1 = [{ id: 10, name: 'Electric', amount: 150, due_day: 5, category: 'Utilities', variable_amount: false }];
const bills2 = [{ id: 11, name: 'Water', amount: 50, due_day: 20, category: 'Utilities', variable_amount: false }];
db.pool.query
.mockResolvedValueOnce({ rows: [] }) // no existing paychecks
.mockResolvedValueOnce(mockConfig()) // getConfig
.mockResolvedValueOnce({ rows: bills1 }) // bills for paycheck 1
.mockResolvedValueOnce({ rows: bills2 }) // bills for paycheck 2
.mockResolvedValueOnce({ rows: [] }); // no financing plans
mockClient.query.mockResolvedValue({ rows: [] });
const res = await request(app).get('/api/paychecks?year=2026&month=3');
expect(res.status).toBe(200);
expect(res.body[0].bills).toHaveLength(1);
expect(res.body[0].bills[0].name).toBe('Electric');
expect(res.body[0].bills[0].paycheck_bill_id).toBeNull();
expect(res.body[0].bills[0].paid).toBe(false);
expect(res.body[1].bills[0].name).toBe('Water');
});
it('returns saved paychecks when DB records exist', async () => {
const savedPc = [
{ id: 1, period_year: 2026, period_month: 3, paycheck_number: 1, pay_date: '2026-03-01', gross: 3000, net: 2400 },
{ id: 2, period_year: 2026, period_month: 3, paycheck_number: 2, pay_date: '2026-03-15', gross: 3000, net: 2400 },
];
db.pool.query
.mockResolvedValueOnce({ rows: [{ id: 1 }] }) // existing record found
.mockResolvedValueOnce({ rows: savedPc }) // fetchPaychecksForMonth
.mockResolvedValueOnce({ rows: [] }) // bills for pc 1
.mockResolvedValueOnce({ rows: [] }) // one_time_expenses for pc 1
.mockResolvedValueOnce({ rows: [] }) // financing for pc 1
.mockResolvedValueOnce({ rows: [] }) // bills for pc 2
.mockResolvedValueOnce({ rows: [] }) // one_time_expenses for pc 2
.mockResolvedValueOnce({ rows: [] }); // financing for pc 2
const res = await request(app).get('/api/paychecks?year=2026&month=3');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].id).toBe(1);
expect(res.body[0].bills).toEqual([]);
});
it('returns 500 on db error', async () => {
db.pool.query.mockRejectedValueOnce(new Error('DB error'));
const res = await request(app).get('/api/paychecks?year=2026&month=3');
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Failed to fetch paychecks' });
});
});
describe('GET /api/paychecks/months', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns list of generated months', async () => {
const months = [
{ year: 2026, month: 3 },
{ year: 2026, month: 2 },
];
db.pool.query.mockResolvedValue({ rows: months });
const res = await request(app).get('/api/paychecks/months');
expect(res.status).toBe(200);
expect(res.body).toEqual(months);
});
it('returns empty array when no paychecks generated', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app).get('/api/paychecks/months');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns 500 on db error', async () => {
db.pool.query.mockRejectedValue(new Error('DB error'));
const res = await request(app).get('/api/paychecks/months');
expect(res.status).toBe(500);
});
});
describe('POST /api/paychecks/generate', () => {
beforeEach(() => {
vi.clearAllMocks();
mockClient.query.mockReset();
mockClient.release.mockReset();
});
it('returns 400 for missing params', async () => {
const res = await request(app).post('/api/paychecks/generate');
expect(res.status).toBe(400);
});
it('generates paychecks and returns them', async () => {
const paycheckRows = [
{ id: 1, period_year: 2026, period_month: 3, paycheck_number: 1, pay_date: '2026-03-01', gross: 3000, net: 2400 },
{ id: 2, period_year: 2026, period_month: 3, paycheck_number: 2, pay_date: '2026-03-15', gross: 3000, net: 2400 },
];
// generatePaychecks uses client (transaction)
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 1 }] }) // upsert paycheck 1
.mockResolvedValueOnce({ rows: [] }) // bills for paycheck 1
.mockResolvedValueOnce({ rows: [{ id: 2 }] }) // upsert paycheck 2
.mockResolvedValueOnce({ rows: [] }) // bills for paycheck 2
.mockResolvedValueOnce({ rows: [] }) // active financing plans
.mockResolvedValueOnce(undefined); // COMMIT
// fetchPaychecksForMonth uses pool.query
db.pool.query
.mockResolvedValueOnce(mockConfig()) // getConfig inside generatePaychecks
.mockResolvedValueOnce({ rows: paycheckRows }) // fetchPaychecksForMonth
.mockResolvedValueOnce({ rows: [] }) // bills for pc 1
.mockResolvedValueOnce({ rows: [] }) // ote for pc 1
.mockResolvedValueOnce({ rows: [] }) // financing for pc 1
.mockResolvedValueOnce({ rows: [] }) // bills for pc 2
.mockResolvedValueOnce({ rows: [] }) // ote for pc 2
.mockResolvedValueOnce({ rows: [] }); // financing for pc 2
const res = await request(app).post('/api/paychecks/generate?year=2026&month=3');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].id).toBe(1);
});
});
describe('PATCH /api/paychecks/:id', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('updates gross and net', async () => {
db.pool.query.mockResolvedValue({ rows: [{ id: 1, gross: 3200, net: 2600 }] });
const res = await request(app)
.patch('/api/paychecks/1')
.send({ gross: 3200, net: 2600 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: 1, gross: 3200, net: 2600 });
});
it('returns 400 when gross or net missing', async () => {
const res = await request(app)
.patch('/api/paychecks/1')
.send({ gross: 3200 });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'gross and net are required' });
});
it('returns 404 when paycheck not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app)
.patch('/api/paychecks/999')
.send({ gross: 3000, net: 2400 });
expect(res.status).toBe(404);
});
});
describe('PATCH /api/paycheck-bills/:id/amount', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('sets amount_override', async () => {
db.pool.query.mockResolvedValue({ rows: [{ id: 5, amount_override: 175.50 }] });
const res = await request(app)
.patch('/api/paycheck-bills/5/amount')
.send({ amount: 175.50 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: 5, amount_override: 175.50 });
});
it('returns 400 when amount missing', async () => {
const res = await request(app)
.patch('/api/paycheck-bills/5/amount')
.send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'amount is required' });
});
it('returns 404 when paycheck_bill not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app)
.patch('/api/paycheck-bills/999/amount')
.send({ amount: 100 });
expect(res.status).toBe(404);
});
});
describe('PATCH /api/paycheck-bills/:id/paid', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('marks bill as paid and locks amount_override', async () => {
const result = { id: 5, paid: true, paid_at: '2026-03-15T10:00:00Z', amount_override: 150 };
db.pool.query.mockResolvedValue({ rows: [result] });
const res = await request(app)
.patch('/api/paycheck-bills/5/paid')
.send({ paid: true });
expect(res.status).toBe(200);
expect(res.body.paid).toBe(true);
expect(res.body.amount_override).toBe(150);
});
it('clears amount_override when unmarking paid', async () => {
const result = { id: 5, paid: false, paid_at: null, amount_override: null };
db.pool.query.mockResolvedValue({ rows: [result] });
const res = await request(app)
.patch('/api/paycheck-bills/5/paid')
.send({ paid: false });
expect(res.status).toBe(200);
expect(res.body.paid).toBe(false);
expect(res.body.amount_override).toBeNull();
});
it('returns 400 when paid is not a boolean', async () => {
const res = await request(app)
.patch('/api/paycheck-bills/5/paid')
.send({ paid: 'yes' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'paid must be a boolean' });
});
it('returns 404 when paycheck_bill not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app)
.patch('/api/paycheck-bills/999/paid')
.send({ paid: true });
expect(res.status).toBe(404);
});
});