Files
flyer-crawler.projectium.com/src/tests/e2e/inventory-journey.e2e.test.ts
Torben Sorensen 2075ed199b Complete ADR-008 Phase 1: API Versioning Strategy
Implement URI-based API versioning with /api/v1 prefix across all routes.
This establishes a foundation for future API evolution and breaking changes.

Changes:
- server.ts: All routes mounted under /api/v1/ (15 route handlers)
- apiClient.ts: Base URL updated to /api/v1
- swagger.ts: OpenAPI server URL changed to /api/v1
- Redirect middleware: Added backwards compatibility for /api/* → /api/v1/*
- Tests: Updated 72 test files with versioned path assertions
- ADR documentation: Marked Phase 1 as complete (Accepted status)

Test fixes:
- apiClient.test.ts: 27 tests updated for /api/v1 paths
- user.routes.ts: 36 log messages updated to reflect versioned paths
- swagger.test.ts: 1 test updated for new server URL
- All integration/E2E tests updated for versioned endpoints

All Phase 1 acceptance criteria met:
✓ Routes use /api/v1/ prefix
✓ Frontend requests /api/v1/
✓ OpenAPI docs reflect /api/v1/
✓ Backwards compatibility via redirect middleware
✓ Tests pass with versioned paths

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 21:23:25 -08:00

419 lines
15 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 supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E Inventory/Expiry Management Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
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 getRequest().post('/api/v1/auth/register').send({
email: userEmail,
password: userPassword,
full_name: '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 getRequest()
.post('/api/v1/auth/login')
.send({ email: userEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.status === 200,
{ 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 getRequest()
.post('/api/v1/inventory')
.set('Authorization', `Bearer ${authToken}`)
.send(item);
expect(addResponse.status).toBe(201);
expect(addResponse.body.data.item_name).toBe(item.item_name);
createdInventoryIds.push(addResponse.body.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 getRequest()
.get('/api/v1/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(listResponse.status).toBe(200);
expect(listResponse.body.data.items.length).toBe(6); // All our items
expect(listResponse.body.data.total).toBe(6);
// Step 5: Filter by location
const fridgeResponse = await getRequest()
.get('/api/v1/inventory?location=fridge')
.set('Authorization', `Bearer ${authToken}`);
expect(fridgeResponse.status).toBe(200);
fridgeResponse.body.data.items.forEach((item: { location: string }) => {
expect(item.location).toBe('fridge');
});
expect(fridgeResponse.body.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt
// Step 6: View expiring items
const expiringResponse = await getRequest()
.get('/api/v1/inventory/expiring?days=3')
.set('Authorization', `Bearer ${authToken}`);
expect(expiringResponse.status).toBe(200);
// Should include the Milk (tomorrow)
expect(expiringResponse.body.data.items.length).toBeGreaterThanOrEqual(1);
// Step 7: View expired items
const expiredResponse = await getRequest()
.get('/api/v1/inventory/expired')
.set('Authorization', `Bearer ${authToken}`);
expect(expiredResponse.status).toBe(200);
expect(expiredResponse.body.data.items.length).toBeGreaterThanOrEqual(1);
// Find the expired yogurt
const expiredYogurt = expiredResponse.body.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 getRequest()
.get(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(detailResponse.status).toBe(200);
expect(detailResponse.body.data.item_name).toBe('E2E Milk');
expect(detailResponse.body.data.quantity).toBe(2);
// Step 9: Update item quantity and location
const updateResponse = await getRequest()
.put(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
quantity: 1,
notes: 'One bottle used',
});
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.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 getRequest()
.put(`/api/inventory/${applesId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ quantity: 4 }); // 6 - 2 = 4
expect(partialConsumeResponse.status).toBe(200);
expect(partialConsumeResponse.body.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 getRequest()
.put('/api/v1/inventory/alerts/email')
.set('Authorization', `Bearer ${authToken}`)
.send({
is_enabled: true,
days_before_expiry: 3,
});
expect(alertSettingsResponse.status).toBe(200);
expect(alertSettingsResponse.body.data.is_enabled).toBe(true);
expect(alertSettingsResponse.body.data.days_before_expiry).toBe(3);
// Step 12: Verify alert settings were saved
const getSettingsResponse = await getRequest()
.get('/api/v1/inventory/alerts')
.set('Authorization', `Bearer ${authToken}`);
expect(getSettingsResponse.status).toBe(200);
// Should have email alerts enabled
const emailAlert = getSettingsResponse.body.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 getRequest()
.get('/api/v1/inventory/recipes/suggestions')
.set('Authorization', `Bearer ${authToken}`);
expect(suggestionsResponse.status).toBe(200);
expect(Array.isArray(suggestionsResponse.body.data.recipes)).toBe(true);
// Step 14: Fully consume an item (marks as consumed, returns 204)
const breadId = createdInventoryIds[2];
const fullConsumeResponse = await getRequest()
.post(`/api/inventory/${breadId}/consume`)
.set('Authorization', `Bearer ${authToken}`);
expect(fullConsumeResponse.status).toBe(204);
// Verify the item is now marked as consumed
const consumedItemResponse = await getRequest()
.get(`/api/inventory/${breadId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(consumedItemResponse.status).toBe(200);
expect(consumedItemResponse.body.data.is_consumed).toBe(true);
// Step 15: Delete an item
const riceId = createdInventoryIds[4];
const deleteResponse = await getRequest()
.delete(`/api/inventory/${riceId}`)
.set('Authorization', `Bearer ${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 getRequest()
.get(`/api/inventory/${riceId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(verifyDeleteResponse.status).toBe(404);
// Step 17: Verify another user cannot access our inventory
const otherUserEmail = `other-inventory-e2e-${uniqueId}@example.com`;
await getRequest()
.post('/api/v1/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Inventory User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await getRequest()
.post('/api/v1/auth/login')
.send({ email: otherUserEmail, password: userPassword, rememberMe: false });
const responseBody = response.status === 200 ? response.body : {};
return { response, responseBody };
},
(result) => result.response.status === 200,
{ 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 getRequest()
.get(`/api/inventory/${milkId}`)
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDetailResponse.status).toBe(404);
// Other user's inventory should be empty
const otherListResponse = await getRequest()
.get('/api/v1/inventory')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherListResponse.status).toBe(200);
expect(otherListResponse.body.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 getRequest()
.put(`/api/inventory/${pizzaId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({
location: 'fridge',
expiry_date: formatDate(nextWeek), // Update expiry since thawed
notes: 'Thawed for dinner',
});
expect(moveResponse.status).toBe(200);
expect(moveResponse.body.data.location).toBe('fridge');
// Step 19: Final inventory check
const finalListResponse = await getRequest()
.get('/api/v1/inventory')
.set('Authorization', `Bearer ${authToken}`);
expect(finalListResponse.status).toBe(200);
// We should have: Milk (1), Pizza (thawed, 3), Bread (consumed), Apples (4), Expired Yogurt (1)
// Rice was deleted, Bread was consumed
expect(finalListResponse.body.data.total).toBeLessThanOrEqual(5);
// Step 20: Delete account
const deleteAccountResponse = await getRequest()
.delete('/api/v1/users/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;
});
});