Files
flyer-crawler.projectium.com/src/controllers/budget.controller.test.ts
Torben Sorensen 174b637a0a
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m5s
even more typescript fixes
2026-02-17 17:20:54 -08:00

468 lines
13 KiB
TypeScript

// src/controllers/budget.controller.test.ts
// ============================================================================
// BUDGET CONTROLLER UNIT TESTS
// ============================================================================
// Unit tests for the BudgetController class. These tests verify controller
// logic in isolation by mocking the budget repository.
// ============================================================================
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import type { Request as ExpressRequest } from 'express';
import { createMockLogger } from '../tests/utils/testHelpers';
// ============================================================================
// MOCK SETUP
// ============================================================================
// Mock tsoa decorators and Controller class
vi.mock('tsoa', () => ({
Controller: class Controller {
protected setStatus(status: number): void {
this._status = status;
}
private _status = 200;
},
Get: () => () => {},
Post: () => () => {},
Put: () => () => {},
Delete: () => () => {},
Route: () => () => {},
Tags: () => () => {},
Security: () => () => {},
Path: () => () => {},
Query: () => () => {},
Body: () => () => {},
Request: () => () => {},
SuccessResponse: () => () => {},
Response: () => () => {},
}));
// Mock budget repository
vi.mock('../services/db/index.db', () => ({
budgetRepo: {
getBudgetsForUser: vi.fn(),
createBudget: vi.fn(),
updateBudget: vi.fn(),
deleteBudget: vi.fn(),
getSpendingByCategory: vi.fn(),
},
}));
// Import mocked modules after mock definitions
import { budgetRepo } from '../services/db/index.db';
import { BudgetController } from './budget.controller';
// Cast mocked modules for type-safe access
const mockedBudgetRepo = budgetRepo as Mocked<typeof budgetRepo>;
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Creates a mock Express request object with authenticated user.
*/
function createMockRequest(overrides: Partial<ExpressRequest> = {}): ExpressRequest {
return {
body: {},
params: {},
query: {},
user: createMockUserProfile(),
log: createMockLogger(),
...overrides,
} as unknown as ExpressRequest;
}
/**
* Creates a mock user profile for testing.
*/
function createMockUserProfile() {
return {
full_name: 'Test User',
role: 'user' as const,
points: 0,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
user: {
user_id: 'test-user-id',
email: 'test@example.com',
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
},
};
}
/**
* Creates a mock budget record.
*/
function createMockBudget(overrides: Record<string, unknown> = {}) {
return {
budget_id: 1,
user_id: 'test-user-id',
name: 'Monthly Groceries',
amount_cents: 50000,
period: 'monthly' as const,
start_date: '2024-01-01',
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
/**
* Creates a mock spending by category record.
*/
function createMockSpendingByCategory(overrides: Record<string, unknown> = {}) {
return {
category_id: 1,
category_name: 'Dairy & Eggs',
total_spent_cents: 2500,
...overrides,
};
}
// ============================================================================
// TEST SUITE
// ============================================================================
describe('BudgetController', () => {
let controller: BudgetController;
beforeEach(() => {
vi.clearAllMocks();
controller = new BudgetController();
});
afterEach(() => {
vi.useRealTimers();
});
// ==========================================================================
// LIST BUDGETS
// ==========================================================================
describe('getBudgets()', () => {
it('should return all budgets for the user', async () => {
// Arrange
const mockBudgets = [
createMockBudget(),
createMockBudget({ budget_id: 2, name: 'Weekly Snacks', period: 'weekly' }),
];
const request = createMockRequest();
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue(mockBudgets);
// Act
const result = await controller.getBudgets(request);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0].name).toBe('Monthly Groceries');
}
expect(mockedBudgetRepo.getBudgetsForUser).toHaveBeenCalledWith(
'test-user-id',
expect.anything(),
);
});
it('should return empty array when user has no budgets', async () => {
// Arrange
const request = createMockRequest();
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue([]);
// Act
const result = await controller.getBudgets(request);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(0);
}
});
});
// ==========================================================================
// CREATE BUDGET
// ==========================================================================
describe('createBudget()', () => {
it('should create a new budget', async () => {
// Arrange
const mockBudget = createMockBudget();
const request = createMockRequest();
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
// Act
const result = await controller.createBudget(request, {
name: 'Monthly Groceries',
amount_cents: 50000,
period: 'monthly',
start_date: '2024-01-01',
});
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Monthly Groceries');
expect(result.data.amount_cents).toBe(50000);
}
expect(mockedBudgetRepo.createBudget).toHaveBeenCalledWith(
'test-user-id',
expect.objectContaining({
name: 'Monthly Groceries',
amount_cents: 50000,
period: 'monthly',
}),
expect.anything(),
);
});
it('should create a weekly budget', async () => {
// Arrange
const mockBudget = createMockBudget({ period: 'weekly', amount_cents: 10000 });
const request = createMockRequest();
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
// Act
const result = await controller.createBudget(request, {
name: 'Weekly Snacks',
amount_cents: 10000,
period: 'weekly',
start_date: '2024-01-01',
});
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.period).toBe('weekly');
}
});
});
// ==========================================================================
// UPDATE BUDGET
// ==========================================================================
describe('updateBudget()', () => {
it('should update an existing budget', async () => {
// Arrange
const mockUpdatedBudget = createMockBudget({ amount_cents: 60000 });
const request = createMockRequest();
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
// Act
const result = await controller.updateBudget(1, request, {
amount_cents: 60000,
});
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.amount_cents).toBe(60000);
}
expect(mockedBudgetRepo.updateBudget).toHaveBeenCalledWith(
1,
'test-user-id',
expect.objectContaining({ amount_cents: 60000 }),
expect.anything(),
);
});
it('should update budget name', async () => {
// Arrange
const mockUpdatedBudget = createMockBudget({ name: 'Updated Budget Name' });
const request = createMockRequest();
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
// Act
const result = await controller.updateBudget(1, request, {
name: 'Updated Budget Name',
});
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Updated Budget Name');
}
});
it('should reject update with no fields provided', async () => {
// Arrange
const request = createMockRequest();
// Act & Assert
await expect(controller.updateBudget(1, request, {})).rejects.toThrow(
'At least one field to update must be provided.',
);
});
it('should update multiple fields at once', async () => {
// Arrange
const mockUpdatedBudget = createMockBudget({
name: 'New Name',
amount_cents: 75000,
period: 'weekly',
});
const request = createMockRequest();
mockedBudgetRepo.updateBudget.mockResolvedValue(mockUpdatedBudget);
// Act
const result = await controller.updateBudget(1, request, {
name: 'New Name',
amount_cents: 75000,
period: 'weekly',
});
// Assert
expect(result.success).toBe(true);
expect(mockedBudgetRepo.updateBudget).toHaveBeenCalledWith(
1,
'test-user-id',
expect.objectContaining({
name: 'New Name',
amount_cents: 75000,
period: 'weekly',
}),
expect.anything(),
);
});
});
// ==========================================================================
// DELETE BUDGET
// ==========================================================================
describe('deleteBudget()', () => {
it('should delete a budget', async () => {
// Arrange
const request = createMockRequest();
mockedBudgetRepo.deleteBudget.mockResolvedValue(undefined);
// Act
const result = await controller.deleteBudget(1, request);
// Assert
expect(result).toBeUndefined();
expect(mockedBudgetRepo.deleteBudget).toHaveBeenCalledWith(
1,
'test-user-id',
expect.anything(),
);
});
});
// ==========================================================================
// SPENDING ANALYSIS
// ==========================================================================
describe('getSpendingAnalysis()', () => {
it('should return spending breakdown by category', async () => {
// Arrange
const mockSpendingData = [
createMockSpendingByCategory(),
createMockSpendingByCategory({
category_id: 2,
category_name: 'Produce',
total_cents: 3500,
}),
];
const request = createMockRequest();
mockedBudgetRepo.getSpendingByCategory.mockResolvedValue(mockSpendingData);
// Act
const result = await controller.getSpendingAnalysis('2024-01-01', '2024-01-31', request);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0].category_name).toBe('Dairy & Eggs');
}
expect(mockedBudgetRepo.getSpendingByCategory).toHaveBeenCalledWith(
'test-user-id',
'2024-01-01',
'2024-01-31',
expect.anything(),
);
});
it('should return empty array when no spending data exists', async () => {
// Arrange
const request = createMockRequest();
mockedBudgetRepo.getSpendingByCategory.mockResolvedValue([]);
// Act
const result = await controller.getSpendingAnalysis('2024-01-01', '2024-01-31', request);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(0);
}
});
});
// ==========================================================================
// BASE CONTROLLER INTEGRATION
// ==========================================================================
describe('BaseController integration', () => {
it('should use success helper for consistent response format', async () => {
// Arrange
const request = createMockRequest();
mockedBudgetRepo.getBudgetsForUser.mockResolvedValue([]);
// Act
const result = await controller.getBudgets(request);
// Assert
expect(result).toHaveProperty('success', true);
expect(result).toHaveProperty('data');
});
it('should use created helper for 201 responses', async () => {
// Arrange
const mockBudget = createMockBudget();
const request = createMockRequest();
mockedBudgetRepo.createBudget.mockResolvedValue(mockBudget);
// Act
const result = await controller.createBudget(request, {
name: 'Test',
amount_cents: 1000,
period: 'weekly',
start_date: '2024-01-01',
});
// Assert
expect(result.success).toBe(true);
});
it('should use noContent helper for 204 responses', async () => {
// Arrange
const request = createMockRequest();
mockedBudgetRepo.deleteBudget.mockResolvedValue(undefined);
// Act
const result = await controller.deleteBudget(1, request);
// Assert
expect(result).toBeUndefined();
});
});
});