Files
flyer-crawler.projectium.com/src/routes/budget.routes.test.ts

303 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('./passport.routes', () => ({
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).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.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).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.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.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.errors).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.errors[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).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.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.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.errors[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.errors[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.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.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.errors[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).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.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.errors).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.errors).toHaveLength(2);
});
});
});