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:
27
CLAUDE.md
27
CLAUDE.md
@@ -5,6 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Agent Workflow Rules
|
## Agent Workflow Rules
|
||||||
|
|
||||||
- **Commit after every task**: When a task is complete, stage all changed files and create a git commit before marking the task done.
|
- **Commit after every task**: When a task is complete, stage all changed files and create a git commit before marking the task done.
|
||||||
|
- **Write tests with features**: New features and bug fixes must include unit tests. Run `npm test` in both `server/` and `client/` before committing.
|
||||||
- **Keep documentation current**: Update `CLAUDE.md` after every task that adds, changes, or removes a feature, API endpoint, or architectural pattern. This is mandatory, not optional. Update `PRD.md` only if scope/design decisions changed.
|
- **Keep documentation current**: Update `CLAUDE.md` after every task that adds, changes, or removes a feature, API endpoint, or architectural pattern. This is mandatory, not optional. Update `PRD.md` only if scope/design decisions changed.
|
||||||
- **Mark tasks in td**: `td start <id>` when beginning, `td close <id>` when done.
|
- **Mark tasks in td**: `td start <id>` when beginning, `td close <id>` when done.
|
||||||
- **Run td commands one at a time**: Never chain `td` commands with `&&` or `;`. Each `td` call must be its own separate shell invocation.
|
- **Run td commands one at a time**: Never chain `td` commands with `&&` or `;`. Each `td` call must be its own separate shell invocation.
|
||||||
@@ -50,6 +51,32 @@ cd client && npm install && npm run dev
|
|||||||
cd server && npm install && npm run dev
|
cd server && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests are required when adding or modifying features. Both server and client use [Vitest](https://vitest.dev/).
|
||||||
|
|
||||||
|
**Run all tests:**
|
||||||
|
```bash
|
||||||
|
cd server && npm test # server unit tests
|
||||||
|
cd client && npm test # client unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Watch mode (re-runs on file change):**
|
||||||
|
```bash
|
||||||
|
cd server && npm run test:watch
|
||||||
|
cd client && npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server tests** (`server/src/__tests__/`): Use Vitest + [Supertest](https://github.com/ladakh/supertest) for route testing. The CJS server code requires mocking `db.pool.query` directly (replace the method on the shared pool object) rather than using `vi.mock` for CJS modules. Validation and pure logic functions are exported and tested directly. See `bills.validation.test.js` and `bills.routes.test.js` for patterns.
|
||||||
|
|
||||||
|
**Client tests** (`client/src/__tests__/`): Use Vitest + [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/). jsdom environment is configured via `client/vitest.config.js`. The test setup file (`client/src/test/setup.js`) provides `@testing-library/jest-dom` matchers and polyfills like `window.matchMedia`. See `ThemeContext.test.jsx` and `App.test.jsx` for patterns.
|
||||||
|
|
||||||
|
**When adding features:**
|
||||||
|
- Add unit tests for new validation logic, utility functions, and API routes
|
||||||
|
- Add component tests for new React components or significant UI changes
|
||||||
|
- Export pure functions (validators, formatters, etc.) for direct testing
|
||||||
|
- Run `npm test` in both `server/` and `client/` before committing
|
||||||
|
|
||||||
## Application Structure
|
## Application Structure
|
||||||
|
|
||||||
The default route `/` renders the paycheck-centric main view (`client/src/pages/PaycheckView.jsx`). It shows the current month's two paychecks side-by-side with bills, paid status, one-time expenses, and remaining balance. Month navigation (prev/next) fetches data via `GET /api/paychecks?year=&month=`.
|
The default route `/` renders the paycheck-centric main view (`client/src/pages/PaycheckView.jsx`). It shows the current month's two paychecks side-by-side with bills, paid status, one-time expenses, and remaining balance. Month navigation (prev/next) fetches data via `GET /api/paychecks?year=&month=`.
|
||||||
|
|||||||
1932
client/package-lock.json
generated
1932
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -13,7 +15,12 @@
|
|||||||
"recharts": "^3.8.0"
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"vite": "^5.2.11"
|
"jsdom": "^29.0.0",
|
||||||
|
"vite": "^5.2.11",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
client/src/__tests__/App.test.jsx
Normal file
33
client/src/__tests__/App.test.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider } from '../ThemeContext.jsx';
|
||||||
|
import App from '../App.jsx';
|
||||||
|
|
||||||
|
function renderApp(initialRoute = '/') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialRoute]}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the navigation bar', () => {
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
expect(screen.getByText('Budget')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Paychecks')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Bills')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Financing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders theme toggle button', () => {
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /toggle theme/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
client/src/__tests__/ThemeContext.test.jsx
Normal file
73
client/src/__tests__/ThemeContext.test.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider, useTheme } from '../ThemeContext.jsx';
|
||||||
|
|
||||||
|
function ThemeDisplay() {
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="theme">{theme}</span>
|
||||||
|
<button onClick={toggle}>Toggle</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThemeContext', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to light theme when no preference', () => {
|
||||||
|
// jsdom defaults to no prefers-color-scheme match
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeDisplay />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads stored theme from localStorage', () => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeDisplay />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles theme on button click', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeDisplay />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Toggle'));
|
||||||
|
|
||||||
|
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||||
|
expect(localStorage.getItem('theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets data-theme attribute on document', () => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeDisplay />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
client/src/test/setup.js
Normal file
16
client/src/test/setup.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// jsdom doesn't implement window.matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
11
client/vitest.config.js
Normal file
11
client/vitest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.js',
|
||||||
|
},
|
||||||
|
});
|
||||||
1512
server/package-lock.json
generated
1512
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
|||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js"
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -13,6 +15,8 @@
|
|||||||
"pg": "^8.11.5"
|
"pg": "^8.11.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
133
server/src/__tests__/bills.routes.test.js
Normal file
133
server/src/__tests__/bills.routes.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
server/src/__tests__/bills.validation.test.js
Normal file
54
server/src/__tests__/bills.validation.test.js
Normal 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
37
server/src/app.js
Normal 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;
|
||||||
@@ -1,42 +1,9 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const app = require('./app');
|
||||||
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 db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
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 () => {
|
(async () => {
|
||||||
await db.initialize();
|
await db.initialize();
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
@@ -177,3 +177,4 @@ router.patch('/bills/:id/toggle', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.validateBillFields = validateBillFields;
|
||||||
|
|||||||
8
server/vitest.config.js
Normal file
8
server/vitest.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user