All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m0s
384 lines
15 KiB
TypeScript
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/users/account')
|
|
.set('Authorization', `Bearer ${authToken}`)
|
|
.send({ password: userPassword });
|
|
|
|
expect(deleteAccountResponse.status).toBe(200);
|
|
userId = null;
|
|
});
|
|
});
|