All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
// 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<Response> => {
|
|
const { token, ...fetchOptions } = options;
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(fetchOptions.headers as Record<string, string>),
|
|
};
|
|
|
|
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;
|
|
});
|
|
});
|