Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
// 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<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 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;
|
|
});
|
|
});
|