From 666acb65faa1d2d5d2f40db0fb1d6787d1f8df59 Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Thu, 19 Mar 2026 21:10:44 -0400 Subject: [PATCH] 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 --- server/src/__tests__/financing.routes.test.js | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 server/src/__tests__/financing.routes.test.js diff --git a/server/src/__tests__/financing.routes.test.js b/server/src/__tests__/financing.routes.test.js new file mode 100644 index 0000000..8767116 --- /dev/null +++ b/server/src/__tests__/financing.routes.test.js @@ -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' }); + }); +});