Files
flyer-crawler.projectium.com/src/tests/integration/budget.integration.test.ts
Torben Sorensen c24103d9a0
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
frontend direct testing result and fixes
2026-01-18 13:57:47 -08:00

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);
});
});
});