From 1106ec770c553d9821e792696bdd63fb50ba0aaa Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Thu, 19 Mar 2026 21:08:49 -0400 Subject: [PATCH] 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 --- server/src/__tests__/paychecks.routes.test.js | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 server/src/__tests__/paychecks.routes.test.js diff --git a/server/src/__tests__/paychecks.routes.test.js b/server/src/__tests__/paychecks.routes.test.js new file mode 100644 index 0000000..9cb614a --- /dev/null +++ b/server/src/__tests__/paychecks.routes.test.js @@ -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); + }); +});