Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m17s
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
// src/routes/budget.routes.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
import {
|
|
createMockUserProfile,
|
|
createMockBudget,
|
|
createMockSpendingByCategory,
|
|
} from '../tests/utils/mockFactories';
|
|
import { createTestApp } from '../tests/utils/createTestApp';
|
|
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
|
|
// 1. Mock the Service Layer directly.
|
|
// This decouples the route tests from the database logic.
|
|
vi.mock('../services/db/index.db', () => ({
|
|
budgetRepo: {
|
|
getBudgetsForUser: vi.fn(),
|
|
createBudget: vi.fn(),
|
|
updateBudget: vi.fn(),
|
|
deleteBudget: vi.fn(),
|
|
getSpendingByCategory: vi.fn(),
|
|
},
|
|
userRepo: {
|
|
findUserProfileById: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock the logger to keep test output clean
|
|
vi.mock('../services/logger.server', async () => ({
|
|
// Use async import to avoid hoisting issues with mockLogger
|
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
|
}));
|
|
|
|
// Import the router and mocked DB AFTER all mocks are defined.
|
|
import budgetRouter from './budget.routes';
|
|
import * as db from '../services/db/index.db';
|
|
|
|
const mockUser = createMockUserProfile({
|
|
user: { user_id: 'user-123', email: 'test@test.com' },
|
|
});
|
|
|
|
// Standardized mock for passport.routes
|
|
vi.mock('../config/passport', () => ({
|
|
default: {
|
|
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
|
req.user = mockUser;
|
|
next();
|
|
}),
|
|
initialize: () => (req: Request, res: Response, next: NextFunction) => next(),
|
|
},
|
|
}));
|
|
|
|
// Define a reusable matcher for the logger object.
|
|
const expectLogger = expect.objectContaining({
|
|
info: expect.any(Function),
|
|
error: expect.any(Function),
|
|
});
|
|
|
|
describe('Budget Routes (/api/budgets)', () => {
|
|
const mockUserProfile = createMockUserProfile({
|
|
user: { user_id: 'user-123', email: 'test@test.com' },
|
|
points: 100,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Provide default mock implementations to prevent undefined errors.
|
|
// Individual tests can override these with more specific values.
|
|
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue([]);
|
|
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue([]);
|
|
});
|
|
|
|
const app = createTestApp({
|
|
router: budgetRouter,
|
|
basePath: '/api/budgets',
|
|
authenticatedUser: mockUserProfile,
|
|
});
|
|
|
|
describe('GET /', () => {
|
|
it('should return a list of budgets for the user', async () => {
|
|
const mockBudgets = [createMockBudget({ budget_id: 1, user_id: 'user-123' })];
|
|
// Mock the service function directly
|
|
vi.mocked(db.budgetRepo.getBudgetsForUser).mockResolvedValue(mockBudgets);
|
|
|
|
const response = await supertest(app).get('/api/budgets');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockBudgets);
|
|
expect(db.budgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
|
|
mockUserProfile.user.user_id,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('should return 500 if the database call fails', async () => {
|
|
vi.mocked(db.budgetRepo.getBudgetsForUser).mockRejectedValue(new Error('DB Error'));
|
|
const response = await supertest(app).get('/api/budgets');
|
|
expect(response.status).toBe(500); // The custom handler will now be used
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
});
|
|
});
|
|
|
|
describe('POST /', () => {
|
|
it('should create a new budget and return it', async () => {
|
|
const newBudgetData = {
|
|
name: 'Entertainment',
|
|
amount_cents: 10000,
|
|
period: 'monthly' as const,
|
|
start_date: '2024-01-01',
|
|
};
|
|
const mockCreatedBudget = createMockBudget({
|
|
budget_id: 2,
|
|
user_id: 'user-123',
|
|
...newBudgetData,
|
|
});
|
|
// Mock the service function
|
|
vi.mocked(db.budgetRepo.createBudget).mockResolvedValue(mockCreatedBudget);
|
|
|
|
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.data).toEqual(mockCreatedBudget);
|
|
});
|
|
|
|
it('should return 400 if the user does not exist', async () => {
|
|
const newBudgetData = {
|
|
name: 'Entertainment',
|
|
amount_cents: 10000,
|
|
period: 'monthly' as const,
|
|
start_date: '2024-01-01',
|
|
};
|
|
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(
|
|
new ForeignKeyConstraintError('User not found'),
|
|
);
|
|
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.message).toBe('User not found');
|
|
});
|
|
|
|
it('should return 500 if a generic database error occurs', async () => {
|
|
const newBudgetData = {
|
|
name: 'Entertainment',
|
|
amount_cents: 10000,
|
|
period: 'monthly' as const,
|
|
start_date: '2024-01-01',
|
|
};
|
|
vi.mocked(db.budgetRepo.createBudget).mockRejectedValue(new Error('DB Error'));
|
|
const response = await supertest(app).post('/api/budgets').send(newBudgetData);
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
});
|
|
|
|
it('should return 400 for invalid budget data', async () => {
|
|
const invalidData = {
|
|
name: '', // empty name
|
|
amount_cents: -100, // negative amount
|
|
period: 'yearly', // invalid period
|
|
start_date: 'not-a-date', // invalid date
|
|
};
|
|
|
|
const response = await supertest(app).post('/api/budgets').send(invalidData);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details).toHaveLength(4);
|
|
});
|
|
|
|
it('should return 400 if required fields are missing', async () => {
|
|
// This test covers the `val ?? ''` part of the `requiredString` helper
|
|
const response = await supertest(app)
|
|
.post('/api/budgets')
|
|
.send({ amount_cents: 10000, period: 'monthly', start_date: '2024-01-01' });
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe('Budget name is required.');
|
|
});
|
|
});
|
|
|
|
describe('PUT /:id', () => {
|
|
it('should update an existing budget', async () => {
|
|
const budgetUpdates = { amount_cents: 60000 };
|
|
const mockUpdatedBudget = createMockBudget({
|
|
budget_id: 1,
|
|
user_id: 'user-123',
|
|
...budgetUpdates,
|
|
});
|
|
// Mock the service function
|
|
vi.mocked(db.budgetRepo.updateBudget).mockResolvedValue(mockUpdatedBudget);
|
|
|
|
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockUpdatedBudget);
|
|
});
|
|
|
|
it('should return 404 if the budget is not found', async () => {
|
|
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(
|
|
new NotFoundError('Budget not found'),
|
|
);
|
|
const response = await supertest(app).put('/api/budgets/999').send({ amount_cents: 1 });
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error.message).toBe('Budget not found');
|
|
});
|
|
|
|
it('should return 500 if a generic database error occurs', async () => {
|
|
const budgetUpdates = { amount_cents: 60000 };
|
|
vi.mocked(db.budgetRepo.updateBudget).mockRejectedValue(new Error('DB Error'));
|
|
const response = await supertest(app).put('/api/budgets/1').send(budgetUpdates);
|
|
expect(response.status).toBe(500); // The custom handler will now be used
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
});
|
|
|
|
it('should return 400 if no update fields are provided', async () => {
|
|
const response = await supertest(app).put('/api/budgets/1').send({});
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toBe(
|
|
'At least one field to update must be provided.',
|
|
);
|
|
});
|
|
|
|
it('should return 400 for an invalid budget ID', async () => {
|
|
const response = await supertest(app).put('/api/budgets/abc').send({ amount_cents: 5000 });
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /:id', () => {
|
|
it('should delete a budget', async () => {
|
|
// Mock the service function to resolve (void)
|
|
vi.mocked(db.budgetRepo.deleteBudget).mockResolvedValue(undefined);
|
|
|
|
const response = await supertest(app).delete('/api/budgets/1');
|
|
|
|
expect(response.status).toBe(204);
|
|
expect(db.budgetRepo.deleteBudget).toHaveBeenCalledWith(
|
|
1,
|
|
mockUserProfile.user.user_id,
|
|
expectLogger,
|
|
);
|
|
});
|
|
|
|
it('should return 404 if the budget is not found', async () => {
|
|
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(
|
|
new NotFoundError('Budget not found'),
|
|
);
|
|
const response = await supertest(app).delete('/api/budgets/999');
|
|
expect(response.status).toBe(404);
|
|
expect(response.body.error.message).toBe('Budget not found');
|
|
});
|
|
|
|
it('should return 500 if a generic database error occurs', async () => {
|
|
vi.mocked(db.budgetRepo.deleteBudget).mockRejectedValue(new Error('DB Error'));
|
|
const response = await supertest(app).delete('/api/budgets/1');
|
|
expect(response.status).toBe(500); // The custom handler will now be used
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
});
|
|
|
|
it('should return 400 for an invalid budget ID', async () => {
|
|
const response = await supertest(app).delete('/api/budgets/abc');
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i);
|
|
});
|
|
});
|
|
|
|
describe('GET /spending-analysis', () => {
|
|
it('should return spending analysis data for a valid date range', async () => {
|
|
const mockSpendingData = [
|
|
createMockSpendingByCategory({ category_id: 1, category_name: 'Produce' }),
|
|
];
|
|
// Mock the service function
|
|
vi.mocked(db.budgetRepo.getSpendingByCategory).mockResolvedValue(mockSpendingData);
|
|
|
|
const response = await supertest(app).get(
|
|
'/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toEqual(mockSpendingData);
|
|
});
|
|
|
|
it('should return 500 if the database call fails', async () => {
|
|
// Mock the service function to throw
|
|
vi.mocked(db.budgetRepo.getSpendingByCategory).mockRejectedValue(new Error('DB Error'));
|
|
|
|
const response = await supertest(app).get(
|
|
'/api/budgets/spending-analysis?startDate=2024-01-01&endDate=2024-01-31',
|
|
);
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body.error.message).toBe('DB Error');
|
|
});
|
|
|
|
it('should return 400 for invalid date formats', async () => {
|
|
const response = await supertest(app).get(
|
|
'/api/budgets/spending-analysis?startDate=2024/01/01&endDate=invalid',
|
|
);
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error.details).toHaveLength(2);
|
|
});
|
|
|
|
it('should return 400 if required query parameters are missing', async () => {
|
|
const response = await supertest(app).get('/api/budgets/spending-analysis');
|
|
expect(response.status).toBe(400);
|
|
// Expect errors for both startDate and endDate
|
|
expect(response.body.error.details).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|