// src/tests/e2e/inventory-journey.e2e.test.ts /** * End-to-End test for the Inventory/Expiry management user journey. * Tests the complete flow from adding inventory items to tracking expiry and alerts. */ 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 Inventory/Expiry Management Journey', () => { const uniqueId = Date.now(); const userEmail = `inventory-e2e-${uniqueId}@example.com`; const userPassword = 'StrongInventoryPassword123!'; let authToken: string; let userId: string | null = null; const createdInventoryIds: number[] = []; afterAll(async () => { const pool = getPool(); // Clean up alert logs if (createdInventoryIds.length > 0) { await pool.query( 'DELETE FROM public.expiry_alert_log WHERE pantry_item_id = ANY($1::bigint[])', [createdInventoryIds], ); } // Clean up inventory items (pantry_items table) if (createdInventoryIds.length > 0) { await pool.query('DELETE FROM public.pantry_items WHERE pantry_item_id = ANY($1::bigint[])', [ createdInventoryIds, ]); } // Clean up user alert settings (expiry_alerts table) if (userId) { await pool.query('DELETE FROM public.expiry_alerts WHERE user_id = $1', [userId]); } // Clean up user await cleanupDb({ userIds: [userId], }); }); it('should complete inventory journey: Register -> Add Items -> Track Expiry -> Consume -> Configure Alerts', async () => { // Step 1: Register a new user const registerResponse = await apiClient.registerUser( userEmail, userPassword, 'Inventory 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(); // Calculate dates for testing const today = new Date(); const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000); const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); const nextMonth = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); const formatDate = (d: Date) => d.toISOString().split('T')[0]; // Step 3: Add multiple inventory items with different expiry dates // Note: API requires 'source' field (manual, receipt_scan, upc_scan) // Also: pantry_items table requires master_item_id, so we need to create master items first const pool = getPool(); // Create master grocery items for our test items const masterItemNames = ['E2E Milk', 'E2E Frozen Pizza', 'E2E Bread', 'E2E Apples', 'E2E Rice']; const masterItemIds: number[] = []; for (const name of masterItemNames) { const result = await pool.query( `INSERT INTO public.master_grocery_items (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING master_grocery_item_id`, [name], ); masterItemIds.push(result.rows[0].master_grocery_item_id); } const items = [ { item_name: 'E2E Milk', master_item_id: masterItemIds[0], quantity: 2, location: 'fridge', expiry_date: formatDate(tomorrow), source: 'manual', }, { item_name: 'E2E Frozen Pizza', master_item_id: masterItemIds[1], quantity: 3, location: 'freezer', expiry_date: formatDate(nextMonth), source: 'manual', }, { item_name: 'E2E Bread', master_item_id: masterItemIds[2], quantity: 1, location: 'pantry', expiry_date: formatDate(nextWeek), source: 'manual', }, { item_name: 'E2E Apples', master_item_id: masterItemIds[3], quantity: 6, location: 'fridge', expiry_date: formatDate(nextWeek), source: 'manual', }, { item_name: 'E2E Rice', master_item_id: masterItemIds[4], quantity: 1, location: 'pantry', source: 'manual', // No expiry date - non-perishable }, ]; for (const item of items) { const addResponse = await authedFetch('/inventory', { method: 'POST', token: authToken, body: JSON.stringify(item), }); expect(addResponse.status).toBe(201); const addData = await addResponse.json(); expect(addData.data.item_name).toBe(item.item_name); createdInventoryIds.push(addData.data.inventory_id); } // Add an expired item directly to the database for testing expired endpoint // First create a master_grocery_item and pantry_location for the direct insert // (pool already defined above) // Create or get the master grocery item const masterItemResult = await pool.query( `INSERT INTO public.master_grocery_items (name) VALUES ('Expired Yogurt E2E') ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING master_grocery_item_id`, ); const masterItemId = masterItemResult.rows[0].master_grocery_item_id; // Create or get the pantry location const locationResult = await pool.query( `INSERT INTO public.pantry_locations (user_id, name) VALUES ($1, 'fridge') ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name RETURNING pantry_location_id`, [userId], ); const pantryLocationId = locationResult.rows[0].pantry_location_id; // Insert the expired pantry item const expiredResult = await pool.query( `INSERT INTO public.pantry_items (user_id, master_item_id, quantity, pantry_location_id, best_before_date, source) VALUES ($1, $2, 1, $3, $4, 'manual') RETURNING pantry_item_id`, [userId, masterItemId, pantryLocationId, formatDate(yesterday)], ); createdInventoryIds.push(expiredResult.rows[0].pantry_item_id); // Step 4: View all inventory const listResponse = await authedFetch('/inventory', { method: 'GET', token: authToken, }); expect(listResponse.status).toBe(200); const listData = await listResponse.json(); expect(listData.data.items.length).toBe(6); // All our items expect(listData.data.total).toBe(6); // Step 5: Filter by location const fridgeResponse = await authedFetch('/inventory?location=fridge', { method: 'GET', token: authToken, }); expect(fridgeResponse.status).toBe(200); const fridgeData = await fridgeResponse.json(); fridgeData.data.items.forEach((item: { location: string }) => { expect(item.location).toBe('fridge'); }); expect(fridgeData.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt // Step 6: View expiring items const expiringResponse = await authedFetch('/inventory/expiring?days=3', { method: 'GET', token: authToken, }); expect(expiringResponse.status).toBe(200); const expiringData = await expiringResponse.json(); // Should include the Milk (tomorrow) expect(expiringData.data.items.length).toBeGreaterThanOrEqual(1); // Step 7: View expired items const expiredResponse = await authedFetch('/inventory/expired', { method: 'GET', token: authToken, }); expect(expiredResponse.status).toBe(200); const expiredData = await expiredResponse.json(); expect(expiredData.data.items.length).toBeGreaterThanOrEqual(1); // Find the expired yogurt const expiredYogurt = expiredData.data.items.find( (i: { item_name: string }) => i.item_name === 'Expired Yogurt E2E', ); expect(expiredYogurt).toBeDefined(); // Step 8: Get specific item details const milkId = createdInventoryIds[0]; const detailResponse = await authedFetch(`/inventory/${milkId}`, { method: 'GET', token: authToken, }); expect(detailResponse.status).toBe(200); const detailData = await detailResponse.json(); expect(detailData.data.item.item_name).toBe('Milk'); expect(detailData.data.item.quantity).toBe(2); // Step 9: Update item quantity and location const updateResponse = await authedFetch(`/inventory/${milkId}`, { method: 'PUT', token: authToken, body: JSON.stringify({ quantity: 1, notes: 'One bottle used', }), }); expect(updateResponse.status).toBe(200); const updateData = await updateResponse.json(); expect(updateData.data.quantity).toBe(1); // Step 10: Consume some apples (partial consume via update, then mark fully consumed) // First, reduce quantity via update const applesId = createdInventoryIds[3]; const partialConsumeResponse = await authedFetch(`/inventory/${applesId}`, { method: 'PUT', token: authToken, body: JSON.stringify({ quantity: 4 }), // 6 - 2 = 4 }); expect(partialConsumeResponse.status).toBe(200); const partialConsumeData = await partialConsumeResponse.json(); expect(partialConsumeData.data.quantity).toBe(4); // Step 11: Configure alert settings for email // The API uses PUT /inventory/alerts/:alertMethod with days_before_expiry and is_enabled const alertSettingsResponse = await authedFetch('/inventory/alerts/email', { method: 'PUT', token: authToken, body: JSON.stringify({ is_enabled: true, days_before_expiry: 3, }), }); expect(alertSettingsResponse.status).toBe(200); const alertSettingsData = await alertSettingsResponse.json(); expect(alertSettingsData.data.is_enabled).toBe(true); expect(alertSettingsData.data.days_before_expiry).toBe(3); // Step 12: Verify alert settings were saved const getSettingsResponse = await authedFetch('/inventory/alerts', { method: 'GET', token: authToken, }); expect(getSettingsResponse.status).toBe(200); const getSettingsData = await getSettingsResponse.json(); // Should have email alerts enabled const emailAlert = getSettingsData.data.find( (s: { alert_method: string }) => s.alert_method === 'email', ); expect(emailAlert?.is_enabled).toBe(true); // Step 13: Get recipe suggestions based on expiring items const suggestionsResponse = await authedFetch('/inventory/recipes/suggestions', { method: 'GET', token: authToken, }); expect(suggestionsResponse.status).toBe(200); const suggestionsData = await suggestionsResponse.json(); expect(Array.isArray(suggestionsData.data.suggestions)).toBe(true); // Step 14: Fully consume an item (marks as consumed, returns 204) const breadId = createdInventoryIds[2]; const fullConsumeResponse = await authedFetch(`/inventory/${breadId}/consume`, { method: 'POST', token: authToken, }); expect(fullConsumeResponse.status).toBe(204); // Verify the item is now marked as consumed const consumedItemResponse = await authedFetch(`/inventory/${breadId}`, { method: 'GET', token: authToken, }); expect(consumedItemResponse.status).toBe(200); const consumedItemData = await consumedItemResponse.json(); expect(consumedItemData.data.item.is_consumed).toBe(true); // Step 15: Delete an item const riceId = createdInventoryIds[4]; const deleteResponse = await authedFetch(`/inventory/${riceId}`, { method: 'DELETE', token: authToken, }); expect(deleteResponse.status).toBe(204); // Remove from tracking list const deleteIndex = createdInventoryIds.indexOf(riceId); if (deleteIndex > -1) { createdInventoryIds.splice(deleteIndex, 1); } // Step 16: Verify deletion const verifyDeleteResponse = await authedFetch(`/inventory/${riceId}`, { method: 'GET', token: authToken, }); expect(verifyDeleteResponse.status).toBe(404); // Step 17: Verify another user cannot access our inventory const otherUserEmail = `other-inventory-e2e-${uniqueId}@example.com`; await apiClient.registerUser(otherUserEmail, userPassword, 'Other Inventory 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 inventory const otherDetailResponse = await authedFetch(`/inventory/${milkId}`, { method: 'GET', token: otherToken, }); expect(otherDetailResponse.status).toBe(404); // Other user's inventory should be empty const otherListResponse = await authedFetch('/inventory', { method: 'GET', token: otherToken, }); expect(otherListResponse.status).toBe(200); const otherListData = await otherListResponse.json(); expect(otherListData.data.total).toBe(0); // Clean up other user await cleanupDb({ userIds: [otherUserId] }); // Step 18: Move frozen item to fridge (simulating thawing) const pizzaId = createdInventoryIds[1]; const moveResponse = await authedFetch(`/inventory/${pizzaId}`, { method: 'PUT', token: authToken, body: JSON.stringify({ location: 'fridge', expiry_date: formatDate(nextWeek), // Update expiry since thawed notes: 'Thawed for dinner', }), }); expect(moveResponse.status).toBe(200); const moveData = await moveResponse.json(); expect(moveData.data.location).toBe('fridge'); // Step 19: Final inventory check const finalListResponse = await authedFetch('/inventory', { method: 'GET', token: authToken, }); expect(finalListResponse.status).toBe(200); const finalListData = await finalListResponse.json(); // We should have: Milk (1), Pizza (thawed, 3), Bread (consumed), Apples (4), Expired Yogurt (1) // Rice was deleted, Bread was consumed expect(finalListData.data.total).toBeLessThanOrEqual(5); // Step 20: Delete account const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, { tokenOverride: authToken, }); expect(deleteAccountResponse.status).toBe(200); userId = null; }); });