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:
341
server/src/__tests__/financing.routes.test.js
Normal file
341
server/src/__tests__/financing.routes.test.js
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user