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:
339
server/src/__tests__/paychecks.routes.test.js
Normal file
339
server/src/__tests__/paychecks.routes.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user