functional-shopping-list.test.tsβ’21.9 kB
/**
* Functional Test: Complete Shopping List Workflow
*
* This test simulates a real user creating a shopping list,
* adding items, optimizing it, and finding nearby stores.
*
* Uses MOCK data to avoid hitting real APIs.
*/
import { describe, it, expect, beforeAll } from '@jest/globals';
// Mock API client
class MockSuperPrecioApiClient {
private lists: any[] = [];
private alerts: any[] = [];
private nextListId = 1;
private nextAlertId = 1;
// Mock supermarket data (11 supermarkets in Argentina)
private mockSupermarkets = [
{ id: 1, name: 'Carrefour', logo: 'carrefour.png', url: 'https://carrefour.com.ar' },
{ id: 2, name: 'Disco', logo: 'disco.png', url: 'https://disco.com.ar' },
{ id: 3, name: 'Jumbo', logo: 'jumbo.png', url: 'https://jumbo.com.ar' },
{ id: 4, name: 'Dia', logo: 'dia.png', url: 'https://dia.com.ar' },
{ id: 5, name: 'Coto', logo: 'coto.png', url: 'https://coto.com.ar' },
];
// Mock product prices by supermarket
private mockPrices: Record<string, Record<string, number>> = {
'leche descremada': { Carrefour: 850, Disco: 920, Jumbo: 880, Dia: 790, Coto: 870 },
'pan lactal': { Carrefour: 450, Disco: 480, Jumbo: 460, Dia: 420, Coto: 465 },
'arroz integral': { Carrefour: 620, Disco: 680, Jumbo: 650, Dia: 600, Coto: 640 },
'aceite girasol': { Carrefour: 1200, Disco: 1350, Jumbo: 1280, Dia: 1150, Coto: 1300 },
'azucar': { Carrefour: 380, Disco: 420, Jumbo: 400, Dia: 350, Coto: 390 },
'cafe torrado': { Carrefour: 950, Disco: 1020, Jumbo: 980, Dia: 890, Coto: 960 },
'yerba mate': { Carrefour: 1100, Disco: 1200, Jumbo: 1150, Dia: 1050, Coto: 1120 },
'fideos': { Carrefour: 280, Disco: 320, Jumbo: 300, Dia: 260, Coto: 290 },
'tomate': { Carrefour: 180, Disco: 200, Jumbo: 190, Dia: 170, Coto: 185 },
'lechuga': { Carrefour: 150, Disco: 170, Jumbo: 160, Dia: 140, Coto: 155 },
};
// Mock supermarket locations (Buenos Aires area)
private mockLocations = [
{
id: 1,
supermarketId: 1,
supermarketName: 'Carrefour',
branchName: 'Carrefour Express Palermo',
address: 'Av. Santa Fe 3253',
city: 'Buenos Aires',
latitude: -34.5889,
longitude: -58.4044,
phone: '011-4823-5000',
openingHours: '8:00 - 22:00',
},
{
id: 2,
supermarketId: 1,
supermarketName: 'Carrefour',
branchName: 'Carrefour Recoleta',
address: 'Av. Callao 1234',
city: 'Buenos Aires',
latitude: -34.5950,
longitude: -58.3920,
phone: '011-4812-6000',
openingHours: '8:00 - 22:00',
},
{
id: 3,
supermarketId: 4,
supermarketName: 'Dia',
branchName: 'Dia % Villa Crespo',
address: 'Av. Corrientes 5678',
city: 'Buenos Aires',
latitude: -34.5990,
longitude: -58.4350,
phone: '011-4855-2000',
openingHours: '7:30 - 21:30',
},
];
async createShoppingList(data: any) {
const list = {
id: this.nextListId++,
name: data.name,
description: data.description || null,
userId: data.userId || null,
isActive: true,
items: data.items || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Add IDs to items
list.items = list.items.map((item: any, index: number) => ({
id: index + 1,
shoppingListId: list.id,
productName: item.productName,
barcode: item.barcode || null,
quantity: item.quantity || 1,
notes: item.notes || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
this.lists.push(list);
return {
success: true,
data: list,
message: 'Shopping list created successfully',
};
}
async addItemsToList(listId: number, items: any[]) {
const list = this.lists.find((l) => l.id === listId);
if (!list) {
return {
success: false,
message: 'Shopping list not found',
};
}
const newItems = items.map((item, index) => ({
id: list.items.length + index + 1,
shoppingListId: list.id,
productName: item.productName,
barcode: item.barcode || null,
quantity: item.quantity || 1,
notes: item.notes || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
list.items.push(...newItems);
list.updatedAt = new Date().toISOString();
return {
success: true,
data: list,
addedItems: newItems.length,
message: `${newItems.length} items added successfully`,
};
}
async getShoppingLists(params?: any) {
let filtered = [...this.lists];
if (params?.userId) {
filtered = filtered.filter((l) => l.userId === params.userId);
}
return {
success: true,
data: filtered,
count: filtered.length,
};
}
async optimizeShoppingList(listId: number) {
const list = this.lists.find((l) => l.id === listId);
if (!list) {
return {
success: false,
message: 'Shopping list not found',
};
}
if (list.items.length === 0) {
return {
success: false,
message: 'Shopping list has no items',
};
}
// Calculate totals for each supermarket
const results: Record<string, any> = {};
this.mockSupermarkets.forEach((market) => {
results[market.name] = {
marketId: market.id,
marketName: market.name,
marketLogo: market.logo,
marketUrl: market.url,
total: 0,
items: [],
foundItems: 0,
missingItems: 0,
};
});
// Calculate prices
list.items.forEach((item: any) => {
const productName = item.productName.toLowerCase();
const prices = this.mockPrices[productName];
if (prices) {
Object.keys(prices).forEach((marketName) => {
if (results[marketName]) {
const unitPrice = prices[marketName];
const totalPrice = unitPrice * item.quantity;
results[marketName].total += totalPrice;
results[marketName].foundItems++;
results[marketName].items.push({
listItemId: item.id,
productName: item.productName,
quantity: item.quantity,
unitPrice,
totalPrice,
image: `https://example.com/${productName.replace(/\s/g, '-')}.jpg`,
link: `https://example.com/product/${productName.replace(/\s/g, '-')}`,
});
}
});
} else {
// Product not found in mock data
this.mockSupermarkets.forEach((market) => {
results[market.name].missingItems++;
});
}
});
// Sort by total (lowest first)
const ranked = Object.values(results)
.filter((r: any) => r.foundItems > 0)
.sort((a: any, b: any) => a.total - b.total);
const bestMarket = ranked[0];
const worstMarket = ranked[ranked.length - 1];
const savings = worstMarket.total - bestMarket.total;
const savingsPercent = ((savings / worstMarket.total) * 100).toFixed(1);
return {
success: true,
listId: list.id,
listName: list.name,
totalItems: list.items.length,
bestMarket: {
name: bestMarket.marketName,
logo: bestMarket.marketLogo,
total: parseFloat(bestMarket.total.toFixed(2)),
foundItems: bestMarket.foundItems,
missingItems: bestMarket.missingItems,
items: bestMarket.items,
},
savings: {
amount: parseFloat(savings.toFixed(2)),
percentage: parseFloat(savingsPercent),
comparedTo: worstMarket.marketName,
},
allMarkets: ranked.map((r: any) => ({
name: r.marketName,
logo: r.marketLogo,
total: parseFloat(r.total.toFixed(2)),
foundItems: r.foundItems,
missingItems: r.missingItems,
})),
marketsCompared: ranked.length,
};
}
async findNearbySupermarkets(params: { lat: number; lng: number; radius?: number }) {
const { lat, lng, radius = 5 } = params;
// Calculate distance using Haversine formula
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
const R = 6371; // Earth radius in km
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const locationsWithDistance = this.mockLocations.map((loc) => ({
...loc,
distance: parseFloat(calculateDistance(lat, lng, loc.latitude, loc.longitude).toFixed(2)),
distanceMeters: Math.round(
calculateDistance(lat, lng, loc.latitude, loc.longitude) * 1000
),
coordinates: {
latitude: loc.latitude,
longitude: loc.longitude,
},
}));
const filtered = locationsWithDistance
.filter((loc) => loc.distance <= radius)
.sort((a, b) => a.distance - b.distance);
return {
success: true,
searchLocation: { latitude: lat, longitude: lng },
radiusKm: radius,
found: filtered.length,
locations: filtered,
};
}
async createPriceAlert(data: any) {
const alert = {
id: this.nextAlertId++,
userId: data.userId || null,
productName: data.productName,
barcode: data.barcode || null,
targetPrice: data.targetPrice,
currentPrice: null,
isActive: true,
notifyEnabled: data.notifyEnabled !== undefined ? data.notifyEnabled : true,
lastCheckedAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.alerts.push(alert);
return {
success: true,
data: alert,
message: 'Price alert created successfully',
};
}
async checkPriceAlerts(params?: any) {
let alertsToCheck = [...this.alerts];
if (params?.userId) {
alertsToCheck = alertsToCheck.filter((a) => a.userId === params.userId);
}
if (params?.alertId) {
alertsToCheck = alertsToCheck.filter((a) => a.id === params.alertId);
}
const results = alertsToCheck.map((alert) => {
const productName = alert.productName.toLowerCase();
const prices = this.mockPrices[productName];
if (prices) {
const lowestPrice = Math.min(...Object.values(prices));
const marketWithLowestPrice = Object.keys(prices).find(
(k) => prices[k] === lowestPrice
)!;
const previousPrice = alert.currentPrice;
alert.currentPrice = lowestPrice;
alert.lastCheckedAt = new Date().toISOString();
const isTriggered = lowestPrice <= alert.targetPrice;
const priceDifference = lowestPrice - alert.targetPrice;
return {
alertId: alert.id,
productName: alert.productName,
barcode: alert.barcode,
targetPrice: alert.targetPrice,
currentPrice: lowestPrice,
previousPrice,
priceChanged: previousPrice !== lowestPrice,
priceDifference: parseFloat(priceDifference.toFixed(2)),
isTriggered,
bestMarket: marketWithLowestPrice,
bestMarketLogo: `${marketWithLowestPrice.toLowerCase()}.png`,
productImage: `https://example.com/${productName.replace(/\s/g, '-')}.jpg`,
productLink: `https://example.com/product/${productName.replace(/\s/g, '-')}`,
allPrices: Object.entries(prices).map(([market, price]) => ({
market,
price,
})),
};
}
return {
alertId: alert.id,
productName: alert.productName,
barcode: alert.barcode,
targetPrice: alert.targetPrice,
currentPrice: null,
isTriggered: false,
error: 'Product not found in any supermarket',
};
});
const triggeredCount = results.filter((r) => r.isTriggered).length;
return {
success: true,
checked: alertsToCheck.length,
triggered: triggeredCount,
results,
message: `Checked ${alertsToCheck.length} alerts, ${triggeredCount} triggered`,
};
}
}
// Tests
describe('Functional Test: Complete Shopping Workflow', () => {
let client: MockSuperPrecioApiClient;
let listId: number;
beforeAll(() => {
client = new MockSuperPrecioApiClient();
});
describe('π Step 1: Create Shopping List', () => {
it('should create a shopping list with initial items', async () => {
const response = await client.createShoppingList({
name: 'Compra Semanal',
description: 'Lista para la semana del 18-25 Octubre',
items: [
{ productName: 'Leche Descremada', quantity: 2 },
{ productName: 'Pan Lactal', quantity: 1 },
{ productName: 'Arroz Integral', quantity: 1 },
],
});
expect(response.success).toBe(true);
expect(response.data.id).toBe(1);
expect(response.data.name).toBe('Compra Semanal');
expect(response.data.items.length).toBe(3);
expect(response.data.items[0].productName).toBe('Leche Descremada');
expect(response.data.items[0].quantity).toBe(2);
listId = response.data.id;
console.log('\nβ
Shopping list created:');
console.log(` ID: ${response.data.id}`);
console.log(` Name: ${response.data.name}`);
console.log(` Items: ${response.data.items.length}`);
});
});
describe('β Step 2: Add More Items', () => {
it('should add additional items to the list', async () => {
const response = await client.addItemsToList(listId, [
{ productName: 'Aceite Girasol', quantity: 1 },
{ productName: 'Azucar', quantity: 1 },
{ productName: 'Cafe Torrado', quantity: 1 },
{ productName: 'Yerba Mate', quantity: 2 },
]);
expect(response.success).toBe(true);
expect(response.addedItems).toBe(4);
expect(response.data.items.length).toBe(7); // 3 initial + 4 new
console.log('\nβ
Items added:');
console.log(` Added: ${response.addedItems} items`);
console.log(` Total items in list: ${response.data.items.length}`);
});
});
describe('π Step 3: View All Lists', () => {
it('should retrieve all shopping lists', async () => {
const response = await client.getShoppingLists();
expect(response.success).toBe(true);
expect(response.count).toBe(1);
expect(response.data[0].items.length).toBe(7);
console.log('\nβ
Lists retrieved:');
console.log(` Total lists: ${response.count}`);
console.log(` First list: "${response.data[0].name}" with ${response.data[0].items.length} items`);
});
});
describe('π₯ Step 4: Optimize Shopping List (THE STAR FEATURE!)', () => {
it('should find the best supermarket for the entire list', async () => {
const response = await client.optimizeShoppingList(listId);
expect(response.success).toBe(true);
expect(response.bestMarket).toBeDefined();
expect(response.savings).toBeDefined();
expect(response.allMarkets.length).toBeGreaterThan(0);
// Verify best market has lowest total
const bestTotal = response.bestMarket.total;
response.allMarkets.forEach((market: any) => {
expect(market.total).toBeGreaterThanOrEqual(bestTotal);
});
console.log('\nπ₯ OPTIMIZATION RESULTS:');
console.log(` List: "${response.listName}"`);
console.log(` Total items: ${response.totalItems}`);
console.log(` Markets compared: ${response.marketsCompared}`);
console.log('\n π BEST SUPERMARKET: ' + response.bestMarket.name);
console.log(` π΅ Total: $${response.bestMarket.total.toLocaleString('es-AR')}`);
console.log(` β
Found: ${response.bestMarket.foundItems}/${response.totalItems} items`);
console.log('\n π° SAVINGS:');
console.log(` Amount: $${response.savings.amount.toLocaleString('es-AR')}`);
console.log(` Percentage: ${response.savings.percentage}%`);
console.log(` vs. ${response.savings.comparedTo}`);
console.log('\n π ALL MARKETS (sorted by price):');
response.allMarkets.forEach((market: any, i: number) => {
const badge = i === 0 ? 'π' : i === response.allMarkets.length - 1 ? 'β' : ' ';
console.log(` ${badge} ${i + 1}. ${market.name.padEnd(15)} $${market.total.toLocaleString('es-AR').padStart(8)}`);
});
});
it('should provide detailed breakdown of items at best market', async () => {
const response = await client.optimizeShoppingList(listId);
expect(response.bestMarket.items).toBeDefined();
expect(response.bestMarket.items.length).toBeGreaterThan(0);
console.log(`\n π ITEMS AT ${response.bestMarket.name}:`);
response.bestMarket.items.forEach((item: any, i: number) => {
console.log(
` ${i + 1}. ${item.productName} - Qty: ${item.quantity} x $${item.unitPrice} = $${item.totalPrice}`
);
});
});
});
describe('π Step 5: Find Nearby Supermarkets', () => {
it('should find supermarkets near Palermo, Buenos Aires', async () => {
// Coordinates for Palermo, Buenos Aires
const response = await client.findNearbySupermarkets({
lat: -34.5889,
lng: -58.4044,
radius: 5,
});
expect(response.success).toBe(true);
expect(response.found).toBeGreaterThan(0);
expect(response.locations[0].distance).toBeLessThanOrEqual(5);
// Verify sorted by distance
for (let i = 1; i < response.locations.length; i++) {
expect(response.locations[i].distance).toBeGreaterThanOrEqual(
response.locations[i - 1].distance
);
}
console.log('\nπ NEARBY SUPERMARKETS:');
console.log(` Search location: ${response.searchLocation.latitude}, ${response.searchLocation.longitude}`);
console.log(` Radius: ${response.radiusKm}km`);
console.log(` Found: ${response.found} supermarkets\n`);
response.locations.forEach((loc: any, i: number) => {
console.log(` ${i + 1}. ${loc.supermarketName} - ${loc.branchName}`);
console.log(` Distance: ${loc.distance}km`);
console.log(` Address: ${loc.address}, ${loc.city}`);
console.log(` Hours: ${loc.openingHours}\n`);
});
});
});
describe('π Step 6: Set Price Alert', () => {
it('should create a price alert for an expensive product', async () => {
const response = await client.createPriceAlert({
productName: 'Aceite Girasol',
targetPrice: 1000, // Alert when price drops below $1000
});
expect(response.success).toBe(true);
expect(response.data.id).toBe(1);
expect(response.data.productName).toBe('Aceite Girasol');
expect(response.data.targetPrice).toBe(1000);
expect(response.data.isActive).toBe(true);
console.log('\nπ PRICE ALERT CREATED:');
console.log(` Product: ${response.data.productName}`);
console.log(` Target Price: $${response.data.targetPrice}`);
console.log(` ID: ${response.data.id}`);
});
});
describe('π Step 7: Check Price Alerts', () => {
it('should check current prices and trigger alerts if needed', async () => {
const response = await client.checkPriceAlerts();
expect(response.success).toBe(true);
expect(response.checked).toBe(1);
expect(response.results.length).toBe(1);
const alert = response.results[0];
expect(alert.currentPrice).toBeDefined();
expect(alert.bestMarket).toBeDefined();
console.log('\nπ ALERT CHECK RESULTS:');
console.log(` Alerts checked: ${response.checked}`);
console.log(` Alerts triggered: ${response.triggered}\n`);
console.log(` Product: ${alert.productName}`);
console.log(` Target: $${alert.targetPrice}`);
console.log(` Current: $${alert.currentPrice}`);
console.log(` Status: ${alert.isTriggered ? 'π TRIGGERED!' : 'π Monitoring'}`);
console.log(` Best market: ${alert.bestMarket}`);
console.log(` Price difference: ${alert.priceDifference >= 0 ? '+' : ''}$${alert.priceDifference}`);
});
});
describe('π Final Summary', () => {
it('should display complete workflow summary', async () => {
const lists = await client.getShoppingLists();
const optimization = await client.optimizeShoppingList(listId);
const alerts = await client.checkPriceAlerts();
console.log('\n' + '='.repeat(60));
console.log('π COMPLETE WORKFLOW SUMMARY');
console.log('='.repeat(60));
console.log('\nβ
Shopping Lists:');
console.log(` Total: ${lists.count}`);
console.log(` Items in main list: ${lists.data[0].items.length}`);
console.log('\nπ Best Supermarket:');
console.log(` ${optimization.bestMarket.name}`);
console.log(` Total: $${optimization.bestMarket.total.toLocaleString('es-AR')}`);
console.log(` Savings: $${optimization.savings.amount.toLocaleString('es-AR')} (${optimization.savings.percentage}%)`);
console.log('\nπ Price Alerts:');
console.log(` Active: ${alerts.checked}`);
console.log(` Triggered: ${alerts.triggered}`);
console.log('\n' + '='.repeat(60));
console.log('β
All tests passed! MCP V2 is working perfectly!');
console.log('='.repeat(60) + '\n');
expect(lists.success).toBe(true);
expect(optimization.success).toBe(true);
expect(alerts.success).toBe(true);
});
});
});