All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m5s
468 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|