Add unit testing infrastructure with Vitest

Set up Vitest for both server (Node + Supertest) and client (jsdom + React
Testing Library). Extract Express app into app.js for testability. Add example
tests covering bills validation, bills route CRUD, ThemeContext, and App nav
rendering. Update CLAUDE.md with testing docs and requirement to write tests
with features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:03:29 -04:00
parent 10f8debdf5
commit e9f5a48f2d
15 changed files with 3851 additions and 40 deletions

1512
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
"dev": "nodemon src/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"cors": "^2.8.5",
@@ -13,6 +15,8 @@
"pg": "^8.11.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
// Import the real app and db — Pool is lazy, no connection until query()
const app = require('../app');
const db = require('../db');
// Replace pool.query with a mock — routes reference the same pool object
const originalQuery = db.pool.query;
db.pool.query = vi.fn();
afterAll(() => {
db.pool.query = originalQuery;
});
describe('GET /api/bills', () => {
beforeEach(() => {
db.pool.query.mockReset();
});
it('returns all bills', async () => {
const mockBills = [
{ id: 1, name: 'Electric', amount: 150, due_day: 15, assigned_paycheck: 1 },
{ id: 2, name: 'Water', amount: 50, due_day: 20, assigned_paycheck: 2 },
];
db.pool.query.mockResolvedValue({ rows: mockBills });
const res = await request(app).get('/api/bills');
expect(res.status).toBe(200);
expect(res.body).toEqual(mockBills);
});
it('returns 500 on db error', async () => {
db.pool.query.mockRejectedValue(new Error('DB error'));
const res = await request(app).get('/api/bills');
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Failed to fetch bills' });
});
});
describe('POST /api/bills', () => {
beforeEach(() => {
db.pool.query.mockReset();
});
it('creates a bill with valid data', async () => {
const newBill = { id: 1, name: 'Internet', amount: 80, due_day: 1, assigned_paycheck: 1, category: 'General', active: true, variable_amount: false };
db.pool.query.mockResolvedValue({ rows: [newBill] });
const res = await request(app)
.post('/api/bills')
.send({ name: 'Internet', amount: 80, due_day: 1, assigned_paycheck: 1 });
expect(res.status).toBe(201);
expect(res.body).toEqual(newBill);
});
it('returns 400 for invalid data', async () => {
const res = await request(app)
.post('/api/bills')
.send({ name: '', amount: 80, due_day: 1, assigned_paycheck: 1 });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'name is required' });
});
});
describe('GET /api/bills/:id', () => {
beforeEach(() => {
db.pool.query.mockReset();
});
it('returns a bill by id', async () => {
const bill = { id: 1, name: 'Electric', amount: 150 };
db.pool.query.mockResolvedValue({ rows: [bill] });
const res = await request(app).get('/api/bills/1');
expect(res.status).toBe(200);
expect(res.body).toEqual(bill);
});
it('returns 404 when not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app).get('/api/bills/999');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Bill not found' });
});
});
describe('DELETE /api/bills/:id', () => {
beforeEach(() => {
db.pool.query.mockReset();
});
it('deletes a bill', async () => {
db.pool.query.mockResolvedValue({ rows: [{ id: 1 }] });
const res = await request(app).delete('/api/bills/1');
expect(res.status).toBe(200);
expect(res.body).toEqual({ deleted: true, id: 1 });
});
it('returns 404 when bill not found', async () => {
db.pool.query.mockResolvedValue({ rows: [] });
const res = await request(app).delete('/api/bills/999');
expect(res.status).toBe(404);
});
});
describe('PATCH /api/bills/:id/toggle', () => {
beforeEach(() => {
db.pool.query.mockReset();
});
it('toggles bill active status', async () => {
const toggled = { id: 1, name: 'Electric', active: false };
db.pool.query.mockResolvedValue({ rows: [toggled] });
const res = await request(app).patch('/api/bills/1/toggle');
expect(res.status).toBe(200);
expect(res.body).toEqual(toggled);
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
const { validateBillFields } = require('../routes/bills');
describe('validateBillFields', () => {
const validBill = {
name: 'Electric',
amount: 150,
due_day: 15,
assigned_paycheck: 1,
};
it('returns null for a valid bill', () => {
expect(validateBillFields(validBill)).toBeNull();
});
it('requires name', () => {
expect(validateBillFields({ ...validBill, name: '' })).toBe('name is required');
expect(validateBillFields({ ...validBill, name: undefined })).toBe('name is required');
});
it('requires amount for non-variable bills', () => {
expect(validateBillFields({ ...validBill, amount: undefined })).toBe('amount is required');
expect(validateBillFields({ ...validBill, amount: null })).toBe('amount is required');
expect(validateBillFields({ ...validBill, amount: '' })).toBe('amount is required');
});
it('allows missing amount for variable bills', () => {
expect(validateBillFields({ ...validBill, amount: undefined, variable_amount: true })).toBeNull();
});
it('rejects non-numeric amount', () => {
expect(validateBillFields({ ...validBill, amount: 'abc' })).toBe('amount must be a number');
});
it('requires due_day', () => {
expect(validateBillFields({ ...validBill, due_day: undefined })).toBe('due_day is required');
expect(validateBillFields({ ...validBill, due_day: '' })).toBe('due_day is required');
});
it('rejects due_day outside 1-31', () => {
expect(validateBillFields({ ...validBill, due_day: 0 })).toBe('due_day must be an integer between 1 and 31');
expect(validateBillFields({ ...validBill, due_day: 32 })).toBe('due_day must be an integer between 1 and 31');
expect(validateBillFields({ ...validBill, due_day: 'abc' })).toBe('due_day must be an integer between 1 and 31');
});
it('requires assigned_paycheck', () => {
expect(validateBillFields({ ...validBill, assigned_paycheck: undefined })).toBe('assigned_paycheck is required');
});
it('rejects assigned_paycheck other than 1 or 2', () => {
expect(validateBillFields({ ...validBill, assigned_paycheck: 3 })).toBe('assigned_paycheck must be 1 or 2');
expect(validateBillFields({ ...validBill, assigned_paycheck: 0 })).toBe('assigned_paycheck must be 1 or 2');
});
});

37
server/src/app.js Normal file
View File

@@ -0,0 +1,37 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const healthRouter = require('./routes/health');
const configRouter = require('./routes/config');
const billsRouter = require('./routes/bills');
const paychecksRouter = require('./routes/paychecks');
const actualsRouter = require('./routes/actuals');
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const summaryRouter = require('./routes/summary');
const { router: financingRouter } = require('./routes/financing');
const app = express();
app.use(cors());
app.use(express.json());
// API routes
app.use('/api', healthRouter);
app.use('/api', configRouter);
app.use('/api', billsRouter);
app.use('/api', paychecksRouter);
app.use('/api', actualsRouter);
app.use('/api', oneTimeExpensesRouter);
app.use('/api', summaryRouter);
app.use('/api', financingRouter);
// Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist');
app.use(express.static(clientDist));
// SPA fallback — send index.html for any unmatched route
app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
module.exports = app;

View File

@@ -1,42 +1,9 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const healthRouter = require('./routes/health');
const configRouter = require('./routes/config');
const billsRouter = require('./routes/bills');
const paychecksRouter = require('./routes/paychecks');
const actualsRouter = require('./routes/actuals');
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const summaryRouter = require('./routes/summary');
const { router: financingRouter } = require('./routes/financing');
const app = require('./app');
const db = require('./db');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// API routes
app.use('/api', healthRouter);
app.use('/api', configRouter);
app.use('/api', billsRouter);
app.use('/api', paychecksRouter);
app.use('/api', actualsRouter);
app.use('/api', oneTimeExpensesRouter);
app.use('/api', summaryRouter);
app.use('/api', financingRouter);
// Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist');
app.use(express.static(clientDist));
// SPA fallback — send index.html for any unmatched route
app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
(async () => {
await db.initialize();
app.listen(PORT, () => {

View File

@@ -177,3 +177,4 @@ router.patch('/bills/:id/toggle', async (req, res) => {
});
module.exports = router;
module.exports.validateBillFields = validateBillFields;

8
server/vitest.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});