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>
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
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);
|
|
});
|
|
});
|