Files
flyer-crawler.projectium.com/src/tests/e2e/deals-journey.e2e.test.ts
Torben Sorensen 2eba66fb71
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m9s
make e2e actually e2e - sigh
2026-01-21 12:34:46 -08:00

384 lines
15 KiB
TypeScript

// 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 supertest from 'supertest';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { getPool } from '../../services/db/connection.db';
import {
createStoreWithLocation,
cleanupStoreLocations,
type CreatedStoreLocation,
} from '../utils/storeHelpers';
import { getServerUrl } from '../setup/e2e-global-setup';
/**
* @vitest-environment node
*/
describe('E2E Deals and Price Tracking Journey', () => {
// Create a getter function that returns supertest instance with the app
const getRequest = () => supertest(getServerUrl());
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 createdStoreLocations: CreatedStoreLocation[] = [];
afterAll(async () => {
const pool = getPool();
// Clean up watched items
if (userId) {
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = $1', [userId]);
}
// Clean up flyer items (master_item_id has ON DELETE SET NULL constraint, so no trigger disable needed)
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 and their locations
await cleanupStoreLocations(pool, createdStoreLocations);
// Clean up user
await cleanupDb({
userIds: [userId],
});
});
it('should complete deals journey: Register -> Watch Items -> View Prices -> Check Deals', async () => {
// Step 0: Demonstrate Category Discovery API (Phase 1 of ADR-023 migration)
// The new category endpoints allow clients to discover and validate category IDs
// before using them in other API calls. This is preparation for Phase 2, which
// will support both category names and IDs in the watched items API.
// Get all available categories
const categoriesResponse = await getRequest().get('/api/categories');
expect(categoriesResponse.status).toBe(200);
expect(categoriesResponse.body.success).toBe(true);
expect(categoriesResponse.body.data.length).toBeGreaterThan(0);
// Find "Dairy & Eggs" category by name using the lookup endpoint
const categoryLookupResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Dairy & Eggs'),
);
expect(categoryLookupResponse.status).toBe(200);
expect(categoryLookupResponse.body.success).toBe(true);
expect(categoryLookupResponse.body.data.name).toBe('Dairy & Eggs');
const dairyEggsCategoryId = categoryLookupResponse.body.data.category_id;
expect(dairyEggsCategoryId).toBeGreaterThan(0);
// Verify we can retrieve the category by ID
const categoryByIdResponse = await getRequest().get(`/api/categories/${dairyEggsCategoryId}`);
expect(categoryByIdResponse.status).toBe(200);
expect(categoryByIdResponse.body.success).toBe(true);
expect(categoryByIdResponse.body.data.category_id).toBe(dairyEggsCategoryId);
expect(categoryByIdResponse.body.data.name).toBe('Dairy & Eggs');
// Look up other category IDs we'll need
const bakeryResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Bakery & Bread'),
);
const bakeryCategoryId = bakeryResponse.body.data.category_id;
const beveragesResponse = await getRequest().get('/api/categories/lookup?name=Beverages');
const beveragesCategoryId = beveragesResponse.body.data.category_id;
const produceResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Fruits & Vegetables'),
);
const produceCategoryId = produceResponse.body.data.category_id;
const meatResponse = await getRequest().get(
'/api/categories/lookup?name=' + encodeURIComponent('Meat & Seafood'),
);
const meatCategoryId = meatResponse.body.data.category_id;
// NOTE: The watched items API now uses category_id (number) as of Phase 3.
// Category names are no longer accepted. Use the category discovery endpoints
// to look up category IDs before creating watched items.
// Step 1: Register a new user
const registerResponse = await getRequest().post('/api/auth/register').send({
email: userEmail,
password: userPassword,
full_name: '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 getRequest()
.post('/api/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();
// Step 3: Create test stores and master items with pricing data
const pool = getPool();
// Create stores with locations
const store1 = await createStoreWithLocation(pool, {
name: 'E2E Test Store 1',
address: '123 Main St',
city: 'Toronto',
province: 'ON',
postalCode: 'M5V 3A1',
});
createdStoreLocations.push(store1);
const store1Id = store1.storeId;
const store2 = await createStoreWithLocation(pool, {
name: 'E2E Test Store 2',
address: '456 Oak Ave',
city: 'Toronto',
province: 'ON',
postalCode: 'M5V 3A2',
});
createdStoreLocations.push(store2);
const store2Id = store2.storeId;
// Create master grocery items with categories
const items = [
{ name: 'E2E Milk 2%', category_id: dairyEggsCategoryId },
{ name: 'E2E Bread White', category_id: bakeryCategoryId },
{ name: 'E2E Coffee Beans', category_id: beveragesCategoryId },
{ name: 'E2E Bananas', category_id: produceCategoryId },
{ name: 'E2E Chicken Breast', category_id: meatCategoryId },
];
for (const item of items) {
const result = await pool.query(
`INSERT INTO public.master_grocery_items (name, category_id)
VALUES ($1, $2)
RETURNING master_grocery_item_id`,
[item.name, item.category_id],
);
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, file_name, image_url, icon_url, valid_from, valid_to, status)
VALUES ($1, 'e2e-flyer-1.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-1.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-1-icon.jpg', $2, $3, 'processed')
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, file_name, image_url, icon_url, valid_from, valid_to, status)
VALUES ($1, 'e2e-flyer-2.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-2.jpg', 'http://localhost:3000/uploads/flyers/e2e-flyer-2-icon.jpg', $2, $3, 'processed')
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, price_in_cents, item, price_display, quantity)
VALUES
($1, $2, 599, 'Milk', '$5.99', 'each'), -- Milk at $5.99
($1, $3, 349, 'Bread', '$3.49', 'each'), -- Bread at $3.49
($1, $4, 1299, 'Coffee', '$12.99', 'each'), -- Coffee at $12.99
($1, $5, 299, 'Bananas', '$2.99', 'lb'), -- Bananas at $2.99
($1, $6, 899, 'Chicken', '$8.99', 'lb') -- 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, price_in_cents, item, price_display, quantity)
VALUES
($1, $2, 499, 'Milk', '$4.99', 'each'), -- Milk at $4.99 (BEST PRICE)
($1, $3, 299, 'Bread', '$2.99', 'each'), -- Bread at $2.99 (BEST PRICE)
($1, $4, 1099, 'Coffee', '$10.99', 'each'), -- Coffee at $10.99 (BEST PRICE)
($1, $5, 249, 'Bananas', '$2.49', 'lb'), -- Bananas at $2.49 (BEST PRICE)
($1, $6, 799, 'Chicken', '$7.99', 'lb') -- Chicken at $7.99 (BEST PRICE)
`,
[flyer2Id, ...createdMasterItemIds],
);
// Step 4: Add items to watch list (using category_id from lookups above)
const watchItem1Response = await getRequest()
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send({
itemName: 'E2E Milk 2%',
category_id: dairyEggsCategoryId,
});
expect(watchItem1Response.status).toBe(201);
expect(watchItem1Response.body.data.name).toBe('E2E Milk 2%');
// Add more items to watch list
const itemsToWatch = [
{ itemName: 'E2E Bread White', category_id: bakeryCategoryId },
{ itemName: 'E2E Coffee Beans', category_id: beveragesCategoryId },
];
for (const item of itemsToWatch) {
const response = await getRequest()
.post('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`)
.send(item);
expect(response.status).toBe(201);
}
// Step 5: View all watched items
const watchedListResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`);
expect(watchedListResponse.status).toBe(200);
expect(watchedListResponse.body.data.length).toBeGreaterThanOrEqual(3);
// Find our watched items
const watchedMilk = watchedListResponse.body.data.find(
(item: { name: string }) => item.name === 'E2E Milk 2%',
);
expect(watchedMilk).toBeDefined();
expect(watchedMilk.category_id).toBe(dairyEggsCategoryId);
// Step 6: Get best prices for watched items
const bestPricesResponse = await getRequest()
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${authToken}`);
expect(bestPricesResponse.status).toBe(200);
expect(bestPricesResponse.body.success).toBe(true);
// Verify we got deals for our watched items
expect(Array.isArray(bestPricesResponse.body.data)).toBe(true);
// Find the milk deal and verify it's the best price (Store 2 at $4.99)
if (bestPricesResponse.body.data.length > 0) {
const milkDeal = bestPricesResponse.body.data.find(
(deal: { item_name: string }) => deal.item_name === 'E2E Milk 2%',
);
if (milkDeal) {
expect(milkDeal.best_price_in_cents).toBe(499); // Best price from Store 2
expect(milkDeal.store.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 getRequest()
.delete(`/api/users/watched-items/${milkMasterItemId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(removeResponse.status).toBe(204);
// Step 9: Verify item was removed
const updatedWatchedListResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${authToken}`);
expect(updatedWatchedListResponse.status).toBe(200);
const milkStillWatched = updatedWatchedListResponse.body.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 getRequest()
.post('/api/auth/register')
.send({ email: otherUserEmail, password: userPassword, full_name: 'Other Deals User' });
const { responseBody: otherLoginData } = await poll(
async () => {
const response = await getRequest()
.post('/api/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's watched items should be empty
const otherWatchedResponse = await getRequest()
.get('/api/users/watched-items')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherWatchedResponse.status).toBe(200);
expect(otherWatchedResponse.body.data.length).toBe(0);
// Other user's deals should be empty
const otherDealsResponse = await getRequest()
.get('/api/deals/best-watched-prices')
.set('Authorization', `Bearer ${otherToken}`);
expect(otherDealsResponse.status).toBe(200);
expect(otherDealsResponse.body.data.length).toBe(0);
// Clean up other user
await cleanupDb({ userIds: [otherUserId] });
// Step 11: Delete account
const deleteAccountResponse = await getRequest()
.delete('/api/user/account')
.set('Authorization', `Bearer ${authToken}`)
.send({ password: userPassword });
expect(deleteAccountResponse.status).toBe(200);
userId = null;
});
});