// src/tests/e2e/budget-journey.e2e.test.ts /** * End-to-End test for the Budget Management user journey. * Tests the complete flow from user registration to creating budgets, tracking spending, and managing finances. */ import { describe, it, expect, afterAll } from 'vitest'; import * as apiClient from '../../services/apiClient'; import { cleanupDb } from '../utils/cleanup'; import { poll } from '../utils/poll'; import { getPool } from '../../services/db/connection.db'; /** * @vitest-environment node */ const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api'; // Helper to make authenticated API calls const authedFetch = async ( path: string, options: RequestInit & { token?: string } = {}, ): Promise => { const { token, ...fetchOptions } = options; const headers: Record = { 'Content-Type': 'application/json', ...(fetchOptions.headers as Record), }; if (token) { headers['Authorization'] = `Bearer ${token}`; } return fetch(`${API_BASE_URL}${path}`, { ...fetchOptions, headers, }); }; describe('E2E Budget Management Journey', () => { const uniqueId = Date.now(); const userEmail = `budget-e2e-${uniqueId}@example.com`; const userPassword = 'StrongBudgetPassword123!'; let authToken: string; let userId: string | null = null; const createdBudgetIds: number[] = []; const createdReceiptIds: number[] = []; const createdStoreIds: number[] = []; afterAll(async () => { const pool = getPool(); // Clean up receipt items and receipts (for spending tracking) if (createdReceiptIds.length > 0) { await pool.query('DELETE FROM public.receipt_items WHERE receipt_id = ANY($1::bigint[])', [ createdReceiptIds, ]); await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::bigint[])', [ createdReceiptIds, ]); } // Clean up budgets if (createdBudgetIds.length > 0) { await pool.query('DELETE FROM public.budgets WHERE budget_id = ANY($1::bigint[])', [ createdBudgetIds, ]); } // Clean up stores if (createdStoreIds.length > 0) { await pool.query('DELETE FROM public.stores WHERE store_id = ANY($1::int[])', [ createdStoreIds, ]); } // Clean up user await cleanupDb({ userIds: [userId], }); }); it('should complete budget journey: Register -> Create Budget -> Track Spending -> Update -> Delete', async () => { // Step 1: Register a new user const registerResponse = await apiClient.registerUser( userEmail, userPassword, 'Budget E2E User', ); expect(registerResponse.status).toBe(201); // Step 2: Login to get auth token const { response: loginResponse, responseBody: loginResponseBody } = await poll( async () => { const response = await apiClient.loginUser(userEmail, userPassword, false); const responseBody = response.ok ? await response.clone().json() : {}; return { response, responseBody }; }, (result) => result.response.ok, { timeout: 10000, interval: 1000, description: 'user login after registration' }, ); expect(loginResponse.status).toBe(200); authToken = loginResponseBody.data.token; userId = loginResponseBody.data.userprofile.user.user_id; expect(authToken).toBeDefined(); // Step 3: Create a monthly budget const today = new Date(); const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); const formatDate = (d: Date) => d.toISOString().split('T')[0]; const createBudgetResponse = await authedFetch('/budgets', { method: 'POST', token: authToken, body: JSON.stringify({ name: 'Monthly Groceries', amount_cents: 50000, // $500.00 period: 'monthly', start_date: formatDate(startOfMonth), }), }); expect(createBudgetResponse.status).toBe(201); const createBudgetData = await createBudgetResponse.json(); expect(createBudgetData.data.name).toBe('Monthly Groceries'); expect(createBudgetData.data.amount_cents).toBe(50000); expect(createBudgetData.data.period).toBe('monthly'); const budgetId = createBudgetData.data.budget_id; createdBudgetIds.push(budgetId); // Step 4: Create a weekly budget const weeklyBudgetResponse = await authedFetch('/budgets', { method: 'POST', token: authToken, body: JSON.stringify({ name: 'Weekly Dining Out', amount_cents: 10000, // $100.00 period: 'weekly', start_date: formatDate(today), }), }); expect(weeklyBudgetResponse.status).toBe(201); const weeklyBudgetData = await weeklyBudgetResponse.json(); expect(weeklyBudgetData.data.period).toBe('weekly'); createdBudgetIds.push(weeklyBudgetData.data.budget_id); // Step 5: View all budgets const listBudgetsResponse = await authedFetch('/budgets', { method: 'GET', token: authToken, }); expect(listBudgetsResponse.status).toBe(200); const listBudgetsData = await listBudgetsResponse.json(); expect(listBudgetsData.data.length).toBe(2); // Find our budgets const monthlyBudget = listBudgetsData.data.find( (b: { name: string }) => b.name === 'Monthly Groceries', ); expect(monthlyBudget).toBeDefined(); expect(monthlyBudget.amount_cents).toBe(50000); // Step 6: Update a budget const updateBudgetResponse = await authedFetch(`/budgets/${budgetId}`, { method: 'PUT', token: authToken, body: JSON.stringify({ amount_cents: 55000, // Increase to $550.00 name: 'Monthly Groceries (Updated)', }), }); expect(updateBudgetResponse.status).toBe(200); const updateBudgetData = await updateBudgetResponse.json(); expect(updateBudgetData.data.amount_cents).toBe(55000); expect(updateBudgetData.data.name).toBe('Monthly Groceries (Updated)'); // Step 7: Create test spending data (receipts) to track against budget const pool = getPool(); // Create a test store const storeResult = await pool.query( `INSERT INTO public.stores (name, address, city, province, postal_code) VALUES ('E2E Budget Test Store', '789 Budget St', 'Toronto', 'ON', 'M5V 3A3') RETURNING store_id`, ); const storeId = storeResult.rows[0].store_id; createdStoreIds.push(storeId); // Create receipts with spending const receipt1Result = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents, transaction_date) VALUES ($1, '/uploads/receipts/e2e-budget-1.jpg', 'completed', $2, 12500, $3) RETURNING receipt_id`, [userId, storeId, formatDate(today)], ); createdReceiptIds.push(receipt1Result.rows[0].receipt_id); const receipt2Result = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents, transaction_date) VALUES ($1, '/uploads/receipts/e2e-budget-2.jpg', 'completed', $2, 8750, $3) RETURNING receipt_id`, [userId, storeId, formatDate(today)], ); createdReceiptIds.push(receipt2Result.rows[0].receipt_id); // Step 8: Check spending analysis const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); const spendingResponse = await authedFetch( `/budgets/spending-analysis?startDate=${formatDate(startOfMonth)}&endDate=${formatDate(endOfMonth)}`, { method: 'GET', token: authToken, }, ); expect(spendingResponse.status).toBe(200); const spendingData = await spendingResponse.json(); expect(spendingData.success).toBe(true); expect(Array.isArray(spendingData.data)).toBe(true); // Verify we have spending data // Note: The spending might be $0 or have data depending on how the backend calculates spending // The test is mainly verifying the endpoint works // Step 9: Test budget validation - try to create invalid budget const invalidBudgetResponse = await authedFetch('/budgets', { method: 'POST', token: authToken, body: JSON.stringify({ name: 'Invalid Budget', amount_cents: -100, // Negative amount should be rejected period: 'monthly', start_date: formatDate(today), }), }); expect(invalidBudgetResponse.status).toBe(400); // Step 10: Test budget validation - missing required fields const missingFieldsResponse = await authedFetch('/budgets', { method: 'POST', token: authToken, body: JSON.stringify({ name: 'Incomplete Budget', // Missing amount_cents, period, start_date }), }); expect(missingFieldsResponse.status).toBe(400); // Step 11: Test update validation - empty update const emptyUpdateResponse = await authedFetch(`/budgets/${budgetId}`, { method: 'PUT', token: authToken, body: JSON.stringify({}), // No fields to update }); expect(emptyUpdateResponse.status).toBe(400); // Step 12: Verify another user cannot access our budgets const otherUserEmail = `other-budget-e2e-${uniqueId}@example.com`; await apiClient.registerUser(otherUserEmail, userPassword, 'Other Budget User'); const { responseBody: otherLoginData } = await poll( async () => { const response = await apiClient.loginUser(otherUserEmail, userPassword, false); const responseBody = response.ok ? await response.clone().json() : {}; return { response, responseBody }; }, (result) => result.response.ok, { timeout: 10000, interval: 1000, description: 'other user login' }, ); const otherToken = otherLoginData.data.token; const otherUserId = otherLoginData.data.userprofile.user.user_id; // Other user should not see our budgets const otherBudgetsResponse = await authedFetch('/budgets', { method: 'GET', token: otherToken, }); expect(otherBudgetsResponse.status).toBe(200); const otherBudgetsData = await otherBudgetsResponse.json(); expect(otherBudgetsData.data.length).toBe(0); // Other user should not be able to update our budget const otherUpdateResponse = await authedFetch(`/budgets/${budgetId}`, { method: 'PUT', token: otherToken, body: JSON.stringify({ amount_cents: 99999, }), }); expect(otherUpdateResponse.status).toBe(404); // Should not find the budget // Other user should not be able to delete our budget const otherDeleteAttemptResponse = await authedFetch(`/budgets/${budgetId}`, { method: 'DELETE', token: otherToken, }); expect(otherDeleteAttemptResponse.status).toBe(404); // Clean up other user await cleanupDb({ userIds: [otherUserId] }); // Step 13: Delete the weekly budget const deleteBudgetResponse = await authedFetch(`/budgets/${weeklyBudgetData.data.budget_id}`, { method: 'DELETE', token: authToken, }); expect(deleteBudgetResponse.status).toBe(204); // Remove from cleanup list const deleteIndex = createdBudgetIds.indexOf(weeklyBudgetData.data.budget_id); if (deleteIndex > -1) { createdBudgetIds.splice(deleteIndex, 1); } // Step 14: Verify deletion const verifyDeleteResponse = await authedFetch('/budgets', { method: 'GET', token: authToken, }); expect(verifyDeleteResponse.status).toBe(200); const verifyDeleteData = await verifyDeleteResponse.json(); expect(verifyDeleteData.data.length).toBe(1); // Only monthly budget remains const deletedBudget = verifyDeleteData.data.find( (b: { budget_id: number }) => b.budget_id === weeklyBudgetData.data.budget_id, ); expect(deletedBudget).toBeUndefined(); // Step 15: Delete account const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, { tokenOverride: authToken, }); expect(deleteAccountResponse.status).toBe(200); userId = null; }); });