E2e testing of pricing (#42)

* a simp0le scaffold

* feature: simple npm setup

* feature: testing setup and dummy scenarios

* chore: dumping kafak just via backend

* chore: dcleaning gitignore

* features: boilerplate fixtures and stuff

* test: extra tests

* chore: update the test suite to be callable via makefile

* chore: cleaning

* chore: updating interactions setup

* small cleaning

* chore: cleaning shitty code
This commit is contained in:
Daniel Alves Rösel
2026-01-12 11:02:18 +01:00
committed by GitHub
parent f2271e368e
commit 221e71a503
12 changed files with 713 additions and 13 deletions

69
tests/e2e/helpers/api.ts Normal file
View File

@@ -0,0 +1,69 @@
interface PriceResponse {
price: number;
base_price: number;
markup: number;
model_version?: string;
}
export async function fetchPrice(
baseUrl: string,
productId: string,
mode: string = 'simple_surge',
sessionId?: string
): Promise<PriceResponse> {
const params = new URLSearchParams();
if (sessionId) params.set('sessionId', sessionId);
const url = `${baseUrl}/api/pricing?mode=${mode}&productId=${productId}&${params}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Price fetch failed: ${resp.status}`);
}
return resp.json();
}
export async function waitForPriceChange(
baseUrl: string,
productId: string,
baselinePrice: number,
mode: string,
sessionId?: string,
maxRetries: number = 10,
pollInterval: number = 500
): Promise<PriceResponse> {
for (let i = 0; i < maxRetries; i++) {
const priceResp = await fetchPrice(baseUrl, productId, mode, sessionId);
if (Math.abs(priceResp.price - baselinePrice) > 0.01) {
return priceResp;
}
await new Promise(r => setTimeout(r, pollInterval));
}
throw new Error(`Price did not change after ${maxRetries} retries`);
}
export async function ingestEvent(
baseUrl: string,
sessionId: string,
event: string,
productId?: string,
metadata?: Record<string, any>
): Promise<void> {
const resp = await fetch(`${baseUrl}/api/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
event,
productId,
timestamp: new Date().toISOString(),
metadata,
}),
});
if (!resp.ok) {
throw new Error(`Event ingest failed: ${resp.status}`);
}
}

View File

@@ -0,0 +1,219 @@
import { Page } from '@playwright/test';
export async function getSessionId(page: Page): Promise<string | null> {
const cookies = await page.context().cookies();
const sessionCookie = cookies.find(c => c.name === 'phantom_session_id');
return sessionCookie?.value || null;
}
export async function verifySessionConsistency(page: Page, expectedSessionId: string): Promise<boolean> {
const currentSessionId = await getSessionId(page);
return currentSessionId === expectedSessionId;
}
export async function createFreshSession(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string> {
await page.context().clearCookies();
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
const sid = await getSessionId(page);
if (!sid) throw new Error('Session not created');
return sid;
}
interface SearchParams {
destination?: string;
checkIn?: string;
guests?: number;
rooms?: number;
origin?: string;
departure?: string;
adults?: number;
}
export async function performSearch(page: Page, params: SearchParams, storeType: 'hotel' | 'airline' = 'hotel' ): Promise<void> {
await page.waitForLoadState('networkidle');
if (storeType === 'hotel') {
const destInput = page.locator('input#destination');
await destInput.fill(params.destination || 'New York');
const checkInInput = page.locator('input#checkIn');
const checkInDate = params.checkIn || new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0];
await checkInInput.fill(checkInDate);
const searchBtn = page.locator('button:has-text("Search Rooms")');
await searchBtn.click();
} else {
const originDropdown = page.locator('button:has-text("Select origin")').or(
page.locator('[id="origin"]').locator('button').first()
);
await originDropdown.click();
await page.waitForTimeout(200);
const originOption = page.locator(`button:has-text("${params.origin || 'JFK'}")`).first();
await originOption.click();
await page.waitForTimeout(200);
const destDropdown = page.locator('button:has-text("Select destination")').or(
page.locator('[id="destination"]').locator('button').first()
);
await destDropdown.click();
await page.waitForTimeout(200);
const destOption = page.locator(`button:has-text("${params.destination || 'LAX'}")`).first();
await destOption.click();
await page.waitForTimeout(200);
const departInput = page.locator('input#departDate');
const departDate = params.departure || new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0];
await departInput.fill(departDate);
const searchBtn = page.locator('button:has-text("Search Flights")');
await searchBtn.click();
}
await page.waitForLoadState('networkidle');
}
export async function selectRandomProduct(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string> {
await page.waitForLoadState('networkidle');
const cardClass = storeType === 'hotel' ? '.hotel-card' : '.flight-card';
const productCards = page.locator(cardClass);
const count = await productCards.count();
if (count === 0) throw new Error('No products found on listing page');
const randomIdx = Math.floor(Math.random() * count);
return randomIdx.toString();
}
export async function openProductFromListing(page: Page, productId?: string): Promise<string> {
await page.waitForLoadState('networkidle');
const hotelCards = page.locator('.hotel-card');
const flightCards = page.locator('.flight-card');
const hotelCount = await hotelCards.count();
const flightCount = await flightCards.count();
let productCards;
if (hotelCount > 0) {
productCards = hotelCards;
} else if (flightCount > 0) {
productCards = flightCards;
} else {
throw new Error('No products found on listing page');
}
const count = await productCards.count();
const randomIdx = productId ? 0 : Math.floor(Math.random() * count);
await productCards.nth(randomIdx).click();
await page.waitForLoadState('networkidle');
const url = page.url();
const match = url.match(/\/products\/([^/?]+)/);
if (!match) throw new Error('Cannot parse product ID from URL after navigation');
return match[1];
}
export async function getPriceFromDOM(page: Page): Promise<number> {
await page.waitForLoadState('networkidle');
await page.waitForSelector('.price-amount', { timeout: 15000 }).catch(() => null);
const priceSelectors = [
'.price-amount',
'.price-display',
'[data-testid="price"]',
'[data-price]',
];
for (const selector of priceSelectors) {
const priceEl = page.locator(selector).first();
if (await priceEl.count() > 0) {
const text = await priceEl.textContent();
if (!text) continue;
const match = text.match(/[\$]?\s*([\d,]+(?:\.\d{2})?)/);
if (match) {
const priceStr = match[1].replace(/,/g, '');
return parseFloat(priceStr);
}
}
}
const dataPrice = await page.locator('[data-price]').first().getAttribute('data-price').catch(() => null);
if (dataPrice) return parseFloat(dataPrice);
throw new Error('Cannot extract price from DOM');
}
export async function navigateToProduct(page: Page,productId: string,storeType: 'hotel' | 'airline' = 'hotel'): Promise<void> {
await page.goto(`/products/${productId}`);
await page.waitForLoadState('networkidle');
}
export async function viewProductViaFlow(page: Page, storeType: 'hotel' | 'airline' = 'hotel', searchParams?: SearchParams): Promise<string> {
const params = new URLSearchParams();
params.set('dateIndex', '7');
if (storeType === 'hotel') {
params.set('destination', searchParams?.destination || 'New York');
params.set('adults', '2');
params.set('rooms', '1');
} else {
params.set('origin', searchParams?.origin || 'JFK');
params.set('destination', searchParams?.destination || 'LAX');
params.set('adults', '1');
params.set('children', '0');
params.set('infants', '0');
}
await page.goto(`/products?${params.toString()}`);
await page.waitForLoadState('networkidle');
const productId = await openProductFromListing(page);
await page.waitForTimeout(500);
return productId;
}
export async function rapidViewProductViaFlow(page: Page, count: number, delayMs: number = 100, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string[]> {
const productIds: string[] = [];
for (let i = 0; i < count; i++) {
const productId = await viewProductViaFlow(page, storeType);
productIds.push(productId);
await page.waitForTimeout(delayMs);
}
return productIds;
}
export async function humanLikeViewProduct(page: Page, storeType: 'hotel' | 'airline' = 'hotel'
): Promise<string> {
const productId = await viewProductViaFlow(page, storeType);
await page.hover('h1');
await page.waitForTimeout(800 + Math.random() * 400);
await page.mouse.wheel(0, 200);
await page.waitForTimeout(500 + Math.random() * 300);
const paragraphs = await page.locator('p').all();
if (paragraphs.length > 0) {
await paragraphs[0].hover();
await page.waitForTimeout(600 + Math.random() * 400);
}
return productId;
}
export async function addToCart(page: Page): Promise<void> {
const addBtn = page.locator('button:has-text("Add to Cart")');
await addBtn.click();
await page.waitForTimeout(500);
}

View File

@@ -0,0 +1,39 @@
interface InteractionEvent {
sessionId: string;
event: string;
productId?: string;
timestamp: string;
metadata?: Record<string, any>;
}
const dumpKafkaTopic = async (backendUrl: string, topic: string) => {
const resp = await fetch(`${backendUrl}/api/kafka/dump?topic=${topic}`);
if (!resp.ok) throw new Error(`Kafka dump failed: ${resp.status}`);
const { messages = [] } = await resp.json();
return messages as any[];
};
export const waitForInteractionEvent = async (
backendUrl: string,
sessionId: string,
eventType: string,
maxRetries = 10,
pollInterval = 500
): Promise<InteractionEvent | null> => {
for (let i = 0; i < maxRetries; i++) {
const msgs = await dumpKafkaTopic(backendUrl, "user-interactions");
const hit = msgs.find(m => m.sessionId === sessionId && m.event === eventType);
if (hit) return hit as InteractionEvent;
await new Promise<void>(r => setTimeout(r, pollInterval));
}
return null;
};
export const countProductViews = async (backendUrl: string, productId: string) =>
(await dumpKafkaTopic(backendUrl, "user-interactions")).reduce(
(n, m) => n + (m.productId === productId && m.event === "view_item_page" ? 1 : 0),
0
);
export const getSessionEvents = async (backendUrl: string, sessionId: string) =>
(await dumpKafkaTopic(backendUrl, "user-interactions")).filter(m => m.sessionId === sessionId);