diff --git a/src/routes/deals.routes.test.ts b/src/routes/deals.routes.test.ts index 333da44..4e6795c 100644 --- a/src/routes/deals.routes.test.ts +++ b/src/routes/deals.routes.test.ts @@ -36,6 +36,14 @@ vi.mock('../config/passport', () => ({ next(); }), }, + requireAuth: vi.fn((req: Request, res: Response, next: NextFunction) => { + // If req.user is not set by the test setup, simulate unauthenticated access. + if (!req.user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + // If req.user is set, proceed as an authenticated user. + next(); + }), })); // Define a reusable matcher for the logger object. diff --git a/src/tests/e2e/budget-journey.e2e.test.ts b/src/tests/e2e/budget-journey.e2e.test.ts new file mode 100644 index 0000000..4a9ad07 --- /dev/null +++ b/src/tests/e2e/budget-journey.e2e.test.ts @@ -0,0 +1,350 @@ +// 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; + }); +}); diff --git a/src/tests/e2e/deals-journey.e2e.test.ts b/src/tests/e2e/deals-journey.e2e.test.ts new file mode 100644 index 0000000..515c8ea --- /dev/null +++ b/src/tests/e2e/deals-journey.e2e.test.ts @@ -0,0 +1,352 @@ +// src/tests/e2e/deals-journey.e2e.test.ts +/** + * End-to-End test for the Deals/Price Tracking user journey. + * Tests the complete flow from user registration to watching items and viewing best prices. + */ +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 Deals and Price Tracking Journey', () => { + const uniqueId = Date.now(); + const userEmail = `deals-e2e-${uniqueId}@example.com`; + const userPassword = 'StrongDealsPassword123!'; + + let authToken: string; + let userId: string | null = null; + const createdMasterItemIds: number[] = []; + const createdFlyerIds: number[] = []; + const createdStoreIds: number[] = []; + + afterAll(async () => { + const pool = getPool(); + + // Clean up watched items + if (userId) { + await pool.query('DELETE FROM public.watched_items WHERE user_id = $1', [userId]); + } + + // Clean up flyer items + if (createdFlyerIds.length > 0) { + await pool.query('DELETE FROM public.flyer_items WHERE flyer_id = ANY($1::bigint[])', [ + createdFlyerIds, + ]); + } + + // Clean up flyers + if (createdFlyerIds.length > 0) { + await pool.query('DELETE FROM public.flyers WHERE flyer_id = ANY($1::bigint[])', [ + createdFlyerIds, + ]); + } + + // Clean up master grocery items + if (createdMasterItemIds.length > 0) { + await pool.query( + 'DELETE FROM public.master_grocery_items WHERE master_grocery_item_id = ANY($1::int[])', + [createdMasterItemIds], + ); + } + + // 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 deals journey: Register -> Watch Items -> View Prices -> Check Deals', async () => { + // Step 1: Register a new user + const registerResponse = await apiClient.registerUser( + userEmail, + userPassword, + 'Deals 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 test stores and master items with pricing data + const pool = getPool(); + + // Create stores + const store1Result = await pool.query( + `INSERT INTO public.stores (name, address, city, province, postal_code) + VALUES ('E2E Test Store 1', '123 Main St', 'Toronto', 'ON', 'M5V 3A1') + RETURNING store_id`, + ); + const store1Id = store1Result.rows[0].store_id; + createdStoreIds.push(store1Id); + + const store2Result = await pool.query( + `INSERT INTO public.stores (name, address, city, province, postal_code) + VALUES ('E2E Test Store 2', '456 Oak Ave', 'Toronto', 'ON', 'M5V 3A2') + RETURNING store_id`, + ); + const store2Id = store2Result.rows[0].store_id; + createdStoreIds.push(store2Id); + + // Create master grocery items + const items = [ + 'E2E Milk 2%', + 'E2E Bread White', + 'E2E Coffee Beans', + 'E2E Bananas', + 'E2E Chicken Breast', + ]; + + for (const itemName of items) { + const result = await pool.query( + `INSERT INTO public.master_grocery_items (name) + VALUES ($1) + RETURNING master_grocery_item_id`, + [itemName], + ); + createdMasterItemIds.push(result.rows[0].master_grocery_item_id); + } + + // Create flyers for both stores + const today = new Date(); + const validFrom = today.toISOString().split('T')[0]; + const validTo = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const flyer1Result = await pool.query( + `INSERT INTO public.flyers (store_id, flyer_image_url, valid_from, valid_to, processing_status) + VALUES ($1, '/uploads/flyers/e2e-flyer-1.jpg', $2, $3, 'completed') + RETURNING flyer_id`, + [store1Id, validFrom, validTo], + ); + const flyer1Id = flyer1Result.rows[0].flyer_id; + createdFlyerIds.push(flyer1Id); + + const flyer2Result = await pool.query( + `INSERT INTO public.flyers (store_id, flyer_image_url, valid_from, valid_to, processing_status) + VALUES ($1, '/uploads/flyers/e2e-flyer-2.jpg', $2, $3, 'completed') + RETURNING flyer_id`, + [store2Id, validFrom, validTo], + ); + const flyer2Id = flyer2Result.rows[0].flyer_id; + createdFlyerIds.push(flyer2Id); + + // Add items to flyers with prices (Store 1 - higher prices) + await pool.query( + `INSERT INTO public.flyer_items (flyer_id, master_item_id, sale_price_cents, page_number) + VALUES + ($1, $2, 599, 1), -- Milk at $5.99 + ($1, $3, 349, 1), -- Bread at $3.49 + ($1, $4, 1299, 2), -- Coffee at $12.99 + ($1, $5, 299, 2), -- Bananas at $2.99 + ($1, $6, 899, 3) -- Chicken at $8.99 + `, + [flyer1Id, ...createdMasterItemIds], + ); + + // Add items to flyers with prices (Store 2 - better prices) + await pool.query( + `INSERT INTO public.flyer_items (flyer_id, master_item_id, sale_price_cents, page_number) + VALUES + ($1, $2, 499, 1), -- Milk at $4.99 (BEST PRICE) + ($1, $3, 299, 1), -- Bread at $2.99 (BEST PRICE) + ($1, $4, 1099, 2), -- Coffee at $10.99 (BEST PRICE) + ($1, $5, 249, 2), -- Bananas at $2.49 (BEST PRICE) + ($1, $6, 799, 3) -- Chicken at $7.99 (BEST PRICE) + `, + [flyer2Id, ...createdMasterItemIds], + ); + + // Step 4: Add items to watch list + const watchItem1Response = await authedFetch('/users/watched-items', { + method: 'POST', + token: authToken, + body: JSON.stringify({ + itemName: 'E2E Milk 2%', + category: 'Dairy', + }), + }); + + expect(watchItem1Response.status).toBe(201); + const watchItem1Data = await watchItem1Response.json(); + expect(watchItem1Data.data.item_name).toBe('E2E Milk 2%'); + + // Add more items to watch list + const itemsToWatch = [ + { itemName: 'E2E Bread White', category: 'Bakery' }, + { itemName: 'E2E Coffee Beans', category: 'Beverages' }, + ]; + + for (const item of itemsToWatch) { + const response = await authedFetch('/users/watched-items', { + method: 'POST', + token: authToken, + body: JSON.stringify(item), + }); + expect(response.status).toBe(201); + } + + // Step 5: View all watched items + const watchedListResponse = await authedFetch('/users/watched-items', { + method: 'GET', + token: authToken, + }); + + expect(watchedListResponse.status).toBe(200); + const watchedListData = await watchedListResponse.json(); + expect(watchedListData.data.length).toBeGreaterThanOrEqual(3); + + // Find our watched items + const watchedMilk = watchedListData.data.find( + (item: { item_name: string }) => item.item_name === 'E2E Milk 2%', + ); + expect(watchedMilk).toBeDefined(); + expect(watchedMilk.category).toBe('Dairy'); + + // Step 6: Get best prices for watched items + const bestPricesResponse = await authedFetch('/users/deals/best-watched-prices', { + method: 'GET', + token: authToken, + }); + + expect(bestPricesResponse.status).toBe(200); + const bestPricesData = await bestPricesResponse.json(); + expect(bestPricesData.success).toBe(true); + + // Verify we got deals for our watched items + expect(Array.isArray(bestPricesData.data)).toBe(true); + + // Find the milk deal and verify it's the best price (Store 2 at $4.99) + if (bestPricesData.data.length > 0) { + const milkDeal = bestPricesData.data.find( + (deal: { item_name: string }) => deal.item_name === 'E2E Milk 2%', + ); + + if (milkDeal) { + expect(milkDeal.best_price_cents).toBe(499); // Best price from Store 2 + expect(milkDeal.store_id).toBe(store2Id); + } + } + + // Step 7: Search for specific items in flyers + // Note: This would require implementing a flyer search endpoint + // For now, we'll test the watched items functionality + + // Step 8: Remove an item from watch list + const milkMasterItemId = createdMasterItemIds[0]; + const removeResponse = await authedFetch(`/users/watched-items/${milkMasterItemId}`, { + method: 'DELETE', + token: authToken, + }); + + expect(removeResponse.status).toBe(204); + + // Step 9: Verify item was removed + const updatedWatchedListResponse = await authedFetch('/users/watched-items', { + method: 'GET', + token: authToken, + }); + + expect(updatedWatchedListResponse.status).toBe(200); + const updatedWatchedListData = await updatedWatchedListResponse.json(); + + const milkStillWatched = updatedWatchedListData.data.find( + (item: { item_name: string }) => item.item_name === 'E2E Milk 2%', + ); + expect(milkStillWatched).toBeUndefined(); + + // Step 10: Verify another user cannot see our watched items + const otherUserEmail = `other-deals-e2e-${uniqueId}@example.com`; + await apiClient.registerUser(otherUserEmail, userPassword, 'Other Deals 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's watched items should be empty + const otherWatchedResponse = await authedFetch('/users/watched-items', { + method: 'GET', + token: otherToken, + }); + + expect(otherWatchedResponse.status).toBe(200); + const otherWatchedData = await otherWatchedResponse.json(); + expect(otherWatchedData.data.length).toBe(0); + + // Other user's deals should be empty + const otherDealsResponse = await authedFetch('/users/deals/best-watched-prices', { + method: 'GET', + token: otherToken, + }); + + expect(otherDealsResponse.status).toBe(200); + const otherDealsData = await otherDealsResponse.json(); + expect(otherDealsData.data.length).toBe(0); + + // Clean up other user + await cleanupDb({ userIds: [otherUserId] }); + + // Step 11: Delete account + const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, { + tokenOverride: authToken, + }); + + expect(deleteAccountResponse.status).toBe(200); + userId = null; + }); +});