more e2e from the AI
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m42s

This commit is contained in:
2026-01-18 20:25:04 -08:00
parent c14bef4448
commit 9cb03c1ede
3 changed files with 710 additions and 0 deletions

View File

@@ -36,6 +36,14 @@ vi.mock('../config/passport', () => ({
next(); 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. // Define a reusable matcher for the logger object.

View File

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

View File

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