Add financing route unit tests

Tests cover remainingPeriods pure function (single/split plans, boundary
cases), GET/POST/GET:id/PUT/DELETE /api/financing, and
PATCH /api/financing-payments/:id/paid including auto-close when fully paid.
Uses mid-month dates in pure function tests to avoid UTC timezone boundary issues.

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

View File

@@ -0,0 +1,341 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
const app = require('../app');
const db = require('../db');
const { remainingPeriods } = require('../routes/financing');
const originalQuery = db.pool.query;
const originalConnect = db.pool.connect;
db.pool.query = vi.fn();
const mockClient = {
query: vi.fn(),
release: vi.fn(),
};
db.pool.connect = vi.fn().mockResolvedValue(mockClient);
afterAll(() => {
db.pool.query = originalQuery;
db.pool.connect = originalConnect;
});
// Shared enrichPlan mock response (called after create/update/get)
const enrichResult = {
rows: [{ paid_total: '0', scheduled_total: '0', paid_count: 0, total_count: 0 }],
};
const basePlan = {
id: 1,
name: 'Car repair',
total_amount: '1200',
due_date: '2027-01-01',
assigned_paycheck: null,
active: true,
start_date: '2026-01-01',
};
// ─── Pure function tests ───────────────────────────────────────────────────────
describe('remainingPeriods', () => {
// Use mid-month dates to avoid UTC midnight timezone boundary issues
const plan = { due_date: '2026-06-15', assigned_paycheck: 1 };
const splitPlan = { due_date: '2026-06-15', assigned_paycheck: null };
it('counts remaining months for a single-paycheck plan', () => {
// due June 2026, current March 2026 → 4 months remaining (Mar, Apr, May, Jun)
expect(remainingPeriods(plan, 2026, 3)).toBe(4);
});
it('returns 1 for the due month', () => {
expect(remainingPeriods(plan, 2026, 6)).toBe(1);
});
it('returns 1 (minimum) when past due date', () => {
expect(remainingPeriods(plan, 2026, 7)).toBe(1);
});
it('doubles periods for split plans (assigned_paycheck = null)', () => {
// 4 months × 2 per month = 8
expect(remainingPeriods(splitPlan, 2026, 3)).toBe(8);
});
});
// ─── Route tests ──────────────────────────────────────────────────────────────
describe('GET /api/financing', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns enriched financing plans', async () => {
db.pool.query
.mockResolvedValueOnce({ rows: [basePlan] }) // SELECT * FROM financing_plans
.mockResolvedValueOnce(enrichResult); // enrichPlan query
const res = await request(app).get('/api/financing');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].name).toBe('Car repair');
expect(res.body[0]).toHaveProperty('remaining');
expect(res.body[0]).toHaveProperty('paid_total');
});
it('returns empty array when no plans', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app).get('/api/financing');
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/financing');
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Failed to fetch financing plans' });
});
});
describe('POST /api/financing', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates a plan with valid data', async () => {
db.pool.query
.mockResolvedValueOnce({ rows: [basePlan] }) // INSERT RETURNING
.mockResolvedValueOnce(enrichResult); // enrichPlan
const res = await request(app)
.post('/api/financing')
.send({ name: 'Car repair', total_amount: 1200, due_date: '2027-01-01' });
expect(res.status).toBe(201);
expect(res.body.name).toBe('Car repair');
expect(res.body).toHaveProperty('remaining');
});
it('returns 400 when name is missing', async () => {
const res = await request(app)
.post('/api/financing')
.send({ total_amount: 1200, due_date: '2027-01-01' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'name, total_amount, and due_date are required' });
});
it('returns 400 when total_amount is missing', async () => {
const res = await request(app)
.post('/api/financing')
.send({ name: 'Car', due_date: '2027-01-01' });
expect(res.status).toBe(400);
});
it('returns 400 for invalid assigned_paycheck', async () => {
const res = await request(app)
.post('/api/financing')
.send({ name: 'Car', total_amount: 1200, due_date: '2027-01-01', assigned_paycheck: 3 });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'assigned_paycheck must be 1, 2, or null' });
});
it('accepts null assigned_paycheck (split plan)', async () => {
db.pool.query
.mockResolvedValueOnce({ rows: [{ ...basePlan, assigned_paycheck: null }] })
.mockResolvedValueOnce(enrichResult);
const res = await request(app)
.post('/api/financing')
.send({ name: 'Car repair', total_amount: 1200, due_date: '2027-01-01', assigned_paycheck: null });
expect(res.status).toBe(201);
});
});
describe('GET /api/financing/:id', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns enriched plan with payments', async () => {
const payments = [
{ id: 10, amount: 100, paid: false, paid_at: null, period_year: 2026, period_month: 3, paycheck_number: 1, pay_date: '2026-03-01' },
];
db.pool.query
.mockResolvedValueOnce({ rows: [basePlan] }) // SELECT plan
.mockResolvedValueOnce(enrichResult) // enrichPlan
.mockResolvedValueOnce({ rows: payments }); // payments
const res = await request(app).get('/api/financing/1');
expect(res.status).toBe(200);
expect(res.body.name).toBe('Car repair');
expect(res.body.payments).toHaveLength(1);
expect(res.body.payments[0].amount).toBe(100);
});
it('returns 404 when plan not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app).get('/api/financing/999');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Not found' });
});
});
describe('PUT /api/financing/:id', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('updates a plan', async () => {
db.pool.query
.mockResolvedValueOnce({ rows: [{ ...basePlan, name: 'Updated' }] })
.mockResolvedValueOnce(enrichResult);
const res = await request(app)
.put('/api/financing/1')
.send({ name: 'Updated', total_amount: 1200, due_date: '2027-01-01' });
expect(res.status).toBe(200);
expect(res.body.name).toBe('Updated');
});
it('returns 400 for missing required fields', async () => {
const res = await request(app)
.put('/api/financing/1')
.send({ name: 'Updated' });
expect(res.status).toBe(400);
});
it('returns 404 when plan not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app)
.put('/api/financing/999')
.send({ name: 'X', total_amount: 100, due_date: '2027-01-01' });
expect(res.status).toBe(404);
});
});
describe('DELETE /api/financing/:id', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('deletes a plan', async () => {
db.pool.query.mockResolvedValue({ rows: [{ id: 1 }] });
const res = await request(app).delete('/api/financing/1');
expect(res.status).toBe(200);
expect(res.body).toEqual({ deleted: true });
});
it('returns 404 when plan not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app).delete('/api/financing/999');
expect(res.status).toBe(404);
});
});
describe('PATCH /api/financing-payments/:id/paid', () => {
beforeEach(() => {
vi.clearAllMocks();
mockClient.query.mockReset();
mockClient.release.mockReset();
});
it('marks payment as paid', async () => {
const payment = { id: 10, plan_id: 1, amount: 100, paid: true, paid_at: '2026-03-15T10:00:00Z' };
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [payment] }) // UPDATE financing_payments
.mockResolvedValueOnce({ rows: [{ ...basePlan, total_amount: '1200' }] }) // SELECT plan
.mockResolvedValueOnce({ rows: [{ paid_total: '100' }] }) // SUM paid
.mockResolvedValueOnce(undefined); // COMMIT
const res = await request(app)
.patch('/api/financing-payments/10/paid')
.send({ paid: true });
expect(res.status).toBe(200);
expect(res.body.paid).toBe(true);
expect(res.body.plan_closed).toBe(false);
});
it('auto-closes plan when fully paid', async () => {
const payment = { id: 10, plan_id: 1, amount: 1200, paid: true, paid_at: '2026-03-15T10:00:00Z' };
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [payment] }) // UPDATE
.mockResolvedValueOnce({ rows: [{ ...basePlan, total_amount: '1200' }] }) // plan
.mockResolvedValueOnce({ rows: [{ paid_total: '1200' }] }) // paid total = full amount
.mockResolvedValueOnce(undefined) // UPDATE SET active=FALSE
.mockResolvedValueOnce(undefined); // COMMIT
const res = await request(app)
.patch('/api/financing-payments/10/paid')
.send({ paid: true });
expect(res.status).toBe(200);
expect(res.body.plan_closed).toBe(true);
});
it('unmarks payment as paid', async () => {
const payment = { id: 10, plan_id: 1, amount: 100, paid: false, paid_at: null };
mockClient.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({ rows: [payment] })
.mockResolvedValueOnce({ rows: [{ ...basePlan, total_amount: '1200' }] })
.mockResolvedValueOnce({ rows: [{ paid_total: '0' }] })
.mockResolvedValueOnce(undefined);
const res = await request(app)
.patch('/api/financing-payments/10/paid')
.send({ paid: false });
expect(res.status).toBe(200);
expect(res.body.paid).toBe(false);
expect(res.body.plan_closed).toBe(false);
});
it('returns 400 when paid is not boolean', async () => {
const res = await request(app)
.patch('/api/financing-payments/10/paid')
.send({ paid: 'yes' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'paid must be a boolean' });
});
it('returns 404 when payment not found', async () => {
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // UPDATE returns nothing
.mockResolvedValueOnce(undefined); // ROLLBACK
const res = await request(app)
.patch('/api/financing-payments/999/paid')
.send({ paid: true });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Payment not found' });
});
});