diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..90b787f --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,17 @@ +import { test as base } from '@playwright/test'; + +type TestFixtures = { + backendUrl: string; + pricingUrl: string; +}; + +export const test = base.extend({ + backendUrl: async ({}, use) => { + await use(process.env.BACKEND_URL || 'http://localhost:5000'); + }, + pricingUrl: async ({}, use) => { + await use(process.env.PRICING_PROVIDER_URL || 'http://localhost:5001'); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts new file mode 100644 index 0000000..d6317ad --- /dev/null +++ b/tests/e2e/helpers/api.ts @@ -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 { + 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 { + 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 +): Promise { + 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}`); + } +} diff --git a/tests/e2e/helpers/interactions.ts b/tests/e2e/helpers/interactions.ts new file mode 100644 index 0000000..646663b --- /dev/null +++ b/tests/e2e/helpers/interactions.ts @@ -0,0 +1,65 @@ +import { Page } from '@playwright/test'; + +export async function navigateToProduct( + page: Page, + productId: string, + storeType: 'hotel' | 'airline' = 'hotel' +): Promise { + await page.goto(`/${storeType}/products/${productId}`); + await page.waitForLoadState('networkidle'); +} + +export async function rapidViewProduct( + page: Page, + productId: string, + count: number, + delayMs: number = 100, + storeType: 'hotel' | 'airline' = 'hotel' +): Promise { + for (let i = 0; i < count; i++) { + await navigateToProduct(page, productId, storeType); + await page.waitForTimeout(delayMs); + } +} + +export async function humanLikeViewProduct( + page: Page, + productId: string, + storeType: 'hotel' | 'airline' = 'hotel' +): Promise { + await navigateToProduct(page, productId, 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); + } +} + +export async function addToCart(page: Page): Promise { + const addBtn = page.locator('button:has-text("Add to Cart")'); + await addBtn.click(); + await page.waitForTimeout(500); +} + +export async function getSessionId(page: Page): Promise { + const cookies = await page.context().cookies(); + const sessionCookie = cookies.find(c => c.name === 'phantom_session_id'); + return sessionCookie?.value || null; +} + +export async function createFreshSession(page: Page): Promise { + await page.context().clearCookies(); + await page.goto('/'); + await page.waitForTimeout(500); + + const sid = await getSessionId(page); + if (!sid) throw new Error('Session not created'); + return sid; +} diff --git a/tests/e2e/helpers/kafka.ts b/tests/e2e/helpers/kafka.ts new file mode 100644 index 0000000..9b4f081 --- /dev/null +++ b/tests/e2e/helpers/kafka.ts @@ -0,0 +1,63 @@ +interface InteractionEvent { + sessionId: string; + event: string; + productId?: string; + timestamp: string; + metadata?: Record; +} + +export async function dumpKafkaTopic( + backendUrl: string, + topic: string +): Promise { + const resp = await fetch(`${backendUrl}/api/kafka/dump?topic=${topic}`); + if (!resp.ok) { + throw new Error(`Kafka dump failed: ${resp.status}`); + } + const data = await resp.json(); + return data.messages || []; +} + +export async function waitForInteractionEvent( + backendUrl: string, + sessionId: string, + eventType: string, + maxRetries: number = 10, + pollInterval: number = 500 +): Promise { + for (let i = 0; i < maxRetries; i++) { + const msgs = await dumpKafkaTopic(backendUrl, 'user-interactions'); + + for (const msg of msgs) { + if (msg.sessionId === sessionId && msg.event === eventType) { + return msg as InteractionEvent; + } + } + + await new Promise(r => setTimeout(r, pollInterval)); + } + + return null; +} + +export async function countProductViews( + backendUrl: string, + productId: string +): Promise { + const msgs = await dumpKafkaTopic(backendUrl, 'user-interactions'); + + return msgs.reduce((cnt, msg) => { + if (msg.productId === productId && msg.event === 'view_item_page') { + return cnt + 1; + } + return cnt; + }, 0); +} + +export async function getSessionEvents( + backendUrl: string, + sessionId: string +): Promise { + const msgs = await dumpKafkaTopic(backendUrl, 'user-interactions'); + return msgs.filter(m => m.sessionId === sessionId); +}