All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
// src/tests/integration/budget.integration.test.ts
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import supertest from 'supertest';
|
|
import { createAndLoginUser } from '../utils/testHelpers';
|
|
import { cleanupDb } from '../utils/cleanup';
|
|
import type { UserProfile, Budget } from '../../types';
|
|
import { getPool } from '../../services/db/connection.db';
|
|
|
|
/**
|
|
* @vitest-environment node
|
|
*/
|
|
|
|
describe('Budget API Routes Integration Tests', () => {
|
|
let request: ReturnType<typeof supertest>;
|
|
let testUser: UserProfile;
|
|
let authToken: string;
|
|
let testBudget: Budget;
|
|
const createdUserIds: string[] = [];
|
|
const createdBudgetIds: number[] = [];
|
|
|
|
beforeAll(async () => {
|
|
vi.stubEnv('FRONTEND_URL', 'https://example.com');
|
|
const app = (await import('../../../server')).default;
|
|
request = supertest(app);
|
|
|
|
// 1. Create a user for the tests
|
|
const { user, token } = await createAndLoginUser({
|
|
email: `budget-user-${Date.now()}@example.com`,
|
|
fullName: 'Budget Test User',
|
|
request,
|
|
});
|
|
testUser = user;
|
|
authToken = token;
|
|
createdUserIds.push(user.user.user_id);
|
|
|
|
// 2. Seed some budget data for this user directly in the DB for predictable testing
|
|
const budgetToCreate = {
|
|
name: 'Monthly Groceries',
|
|
amount_cents: 50000, // $500.00
|
|
period: 'monthly',
|
|
start_date: '2025-01-01',
|
|
};
|
|
|
|
const budgetRes = await getPool().query(
|
|
`INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *`,
|
|
[
|
|
testUser.user.user_id,
|
|
budgetToCreate.name,
|
|
budgetToCreate.amount_cents,
|
|
budgetToCreate.period,
|
|
budgetToCreate.start_date,
|
|
],
|
|
);
|
|
testBudget = budgetRes.rows[0];
|
|
createdBudgetIds.push(testBudget.budget_id);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
vi.unstubAllEnvs();
|
|
// Clean up all created resources
|
|
await cleanupDb({
|
|
userIds: createdUserIds,
|
|
budgetIds: createdBudgetIds,
|
|
});
|
|
});
|
|
|
|
describe('GET /api/budgets', () => {
|
|
it('should fetch budgets for the authenticated user', async () => {
|
|
const response = await request
|
|
.get('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
const budgets: Budget[] = response.body.data;
|
|
expect(budgets).toBeInstanceOf(Array);
|
|
expect(budgets.some((b) => b.budget_id === testBudget.budget_id)).toBe(true);
|
|
});
|
|
|
|
it('should return 401 if user is not authenticated', async () => {
|
|
const response = await request.get('/api/budgets');
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/budgets', () => {
|
|
it('should allow an authenticated user to create a new budget', async () => {
|
|
const newBudgetData = {
|
|
name: 'Weekly Snacks',
|
|
amount_cents: 15000, // $150.00
|
|
period: 'weekly',
|
|
start_date: '2025-02-01',
|
|
};
|
|
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send(newBudgetData);
|
|
|
|
expect(response.status).toBe(201);
|
|
const createdBudget: Budget = response.body.data;
|
|
expect(createdBudget.name).toBe(newBudgetData.name);
|
|
expect(createdBudget.amount_cents).toBe(newBudgetData.amount_cents);
|
|
expect(createdBudget.period).toBe(newBudgetData.period);
|
|
// The API returns a DATE column as ISO timestamp. Due to timezone differences,
|
|
// the date might shift by a day. We verify the date is within 1 day of expected.
|
|
const returnedDate = new Date(createdBudget.start_date);
|
|
const expectedDate = new Date(newBudgetData.start_date + 'T12:00:00Z'); // Use noon UTC to avoid day shifts
|
|
const daysDiff =
|
|
Math.abs(returnedDate.getTime() - expectedDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
expect(daysDiff).toBeLessThanOrEqual(1);
|
|
expect(createdBudget.user_id).toBe(testUser.user.user_id);
|
|
expect(createdBudget.budget_id).toBeDefined();
|
|
|
|
// Track for cleanup
|
|
createdBudgetIds.push(createdBudget.budget_id);
|
|
});
|
|
|
|
it('should return 400 for invalid budget data', async () => {
|
|
const invalidBudgetData = {
|
|
name: '', // Invalid: empty name
|
|
amount_cents: -100, // Invalid: negative amount
|
|
period: 'daily', // Invalid: not 'weekly' or 'monthly'
|
|
start_date: 'not-a-date',
|
|
};
|
|
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send(invalidBudgetData);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 401 if user is not authenticated', async () => {
|
|
const response = await request.post('/api/budgets').send({
|
|
name: 'Unauthorized Budget',
|
|
amount_cents: 10000,
|
|
period: 'monthly',
|
|
start_date: '2025-01-01',
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it('should reject period="yearly" (only weekly/monthly allowed)', async () => {
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
name: 'Yearly Budget',
|
|
amount_cents: 100000,
|
|
period: 'yearly',
|
|
start_date: '2025-01-01',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
|
});
|
|
|
|
it('should reject negative amount_cents', async () => {
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
name: 'Negative Budget',
|
|
amount_cents: -500,
|
|
period: 'weekly',
|
|
start_date: '2025-01-01',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
|
|
it('should reject invalid date format', async () => {
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
name: 'Invalid Date Budget',
|
|
amount_cents: 10000,
|
|
period: 'weekly',
|
|
start_date: '01-01-2025', // Wrong format, should be YYYY-MM-DD
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
});
|
|
|
|
it('should require name field', async () => {
|
|
const response = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({
|
|
amount_cents: 10000,
|
|
period: 'weekly',
|
|
start_date: '2025-01-01',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.success).toBe(false);
|
|
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/budgets/:id', () => {
|
|
it('should allow an authenticated user to update their own budget', async () => {
|
|
const updatedData = {
|
|
name: 'Updated Monthly Groceries',
|
|
amount_cents: 60000, // $600.00
|
|
};
|
|
|
|
const response = await request
|
|
.put(`/api/budgets/${testBudget.budget_id}`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send(updatedData);
|
|
|
|
expect(response.status).toBe(200);
|
|
const updatedBudget: Budget = response.body.data;
|
|
expect(updatedBudget.name).toBe(updatedData.name);
|
|
expect(updatedBudget.amount_cents).toBe(updatedData.amount_cents);
|
|
// Unchanged fields should remain the same
|
|
expect(updatedBudget.period).toBe(testBudget.period);
|
|
// The seeded budget start_date is a plain DATE, but API may return ISO timestamp.
|
|
// Due to timezone differences, verify the date is within 1 day of expected.
|
|
const returnedDate = new Date(updatedBudget.start_date);
|
|
const expectedDate = new Date('2025-01-01T12:00:00Z'); // Use noon UTC to avoid day shifts
|
|
const daysDiff =
|
|
Math.abs(returnedDate.getTime() - expectedDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
expect(daysDiff).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('should return 404 when updating a non-existent budget', async () => {
|
|
const response = await request
|
|
.put('/api/budgets/999999')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ name: 'Non-existent' });
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('should return 400 when no update fields are provided', async () => {
|
|
const response = await request
|
|
.put(`/api/budgets/${testBudget.budget_id}`)
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({});
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 401 if user is not authenticated', async () => {
|
|
const response = await request
|
|
.put(`/api/budgets/${testBudget.budget_id}`)
|
|
.send({ name: 'Hacked Budget' });
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/budgets/:id', () => {
|
|
it('should allow an authenticated user to delete their own budget', async () => {
|
|
// Create a budget specifically for deletion
|
|
const budgetToDelete = {
|
|
name: 'To Be Deleted',
|
|
amount_cents: 5000,
|
|
period: 'weekly',
|
|
start_date: '2025-03-01',
|
|
};
|
|
|
|
const createResponse = await request
|
|
.post('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send(budgetToDelete);
|
|
|
|
expect(createResponse.status).toBe(201);
|
|
const createdBudget: Budget = createResponse.body.data;
|
|
|
|
// Now delete it
|
|
const deleteResponse = await request
|
|
.delete(`/api/budgets/${createdBudget.budget_id}`)
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(deleteResponse.status).toBe(204);
|
|
|
|
// Verify it's actually deleted
|
|
const getResponse = await request
|
|
.get('/api/budgets')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
const budgets: Budget[] = getResponse.body.data;
|
|
expect(budgets.some((b) => b.budget_id === createdBudget.budget_id)).toBe(false);
|
|
});
|
|
|
|
it('should return 404 when deleting a non-existent budget', async () => {
|
|
const response = await request
|
|
.delete('/api/budgets/999999')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it('should return 401 if user is not authenticated', async () => {
|
|
const response = await request.delete(`/api/budgets/${testBudget.budget_id}`);
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/budgets/spending-analysis', () => {
|
|
it('should return spending analysis for the authenticated user', async () => {
|
|
// Note: This test verifies the endpoint works and returns the correct structure.
|
|
// In a real scenario with seeded shopping trip data, we'd verify actual values.
|
|
const response = await request
|
|
.get('/api/budgets/spending-analysis')
|
|
.query({ startDate: '2025-01-01', endDate: '2025-12-31' })
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data).toBeInstanceOf(Array);
|
|
|
|
// Each item in the array should have the SpendingByCategory structure
|
|
if (response.body.data.length > 0) {
|
|
const firstItem = response.body.data[0];
|
|
expect(firstItem).toHaveProperty('category_id');
|
|
expect(firstItem).toHaveProperty('category_name');
|
|
expect(firstItem).toHaveProperty('total_spent_cents');
|
|
}
|
|
});
|
|
|
|
it('should return 400 for invalid date format', async () => {
|
|
const response = await request
|
|
.get('/api/budgets/spending-analysis')
|
|
.query({ startDate: 'invalid-date', endDate: '2025-12-31' })
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 400 when required query params are missing', async () => {
|
|
const response = await request
|
|
.get('/api/budgets/spending-analysis')
|
|
.set('Authorization', `Bearer ${authToken}`);
|
|
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it('should return 401 if user is not authenticated', async () => {
|
|
const response = await request
|
|
.get('/api/budgets/spending-analysis')
|
|
.query({ startDate: '2025-01-01', endDate: '2025-12-31' });
|
|
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
});
|