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); }); });