/** * Simulated backend for UI tests. * * The end-to-end specs drive the real stack, which is the right way to prove * the system works. But some conditions are impractical to stage for real: * a 500 from the database, a fifteen-second response, a seat sold by someone * else in the half second between opening checkout and confirming, an agency * with a hundred thousand flights, a passenger whose name is 200 characters * long. * * These helpers intercept `/api/**` in the browser and answer it directly, so * a test can put the UI in any of those states deterministically, in * milliseconds, without touching the database. */ import { Page } from '@playwright/test'; // --- canned data ----------------------------------------------------------- export const TENANTS = [ { slug: 'skyline', name: 'Skyline Travel' }, { slug: 'globetrotter', name: 'Globetrotter Tours' }, ]; export const SESSION = { user: { id: 'u-1', tenantId: 't-1', email: 'agent@skyline.test', fullName: 'Marcus Reid', role: 'agent', createdAt: '2026-01-01T00:00:00Z', }, tenant: { id: 't-1', slug: 'skyline', name: 'Skyline Travel', status: 'active', createdAt: '2026-01-01T00:00:00Z', }, }; export const AIRPORTS = [ { id: 'a-jfk', iataCode: 'JFK', name: 'John F. Kennedy International', city: 'New York', country: 'United States', timezone: 'America/New_York' }, { id: 'a-lhr', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'United Kingdom', timezone: 'Europe/London' }, ]; export const AIRLINES = [{ id: 'al-aa', iataCode: 'AA', name: 'American Airlines' }]; export function makeFlight(overrides: Record = {}) { return { id: 'f-1', flightNumber: 'AA100', airline: AIRLINES[0], origin: AIRPORTS[0], destination: AIRPORTS[1], departureTime: '2026-12-19T18:00:00Z', arrivalTime: '2026-12-20T01:00:00Z', durationMinutes: 420, aircraft: 'Boeing 787-9', cabinClass: 'economy', basePrice: 483.3, currency: 'USD', seatsTotal: 180, seatsAvailable: 165, status: 'scheduled', ...overrides, }; } export function makeBooking(overrides: Record = {}) { return { id: 'b-1', bookingReference: 'SIM001', flight: makeFlight(), passengers: [{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield' }], passengerCount: 1, totalAmount: 483.3, currency: 'USD', status: 'confirmed', contactEmail: 'ops@example.com', contactPhone: '', bookedBy: 'Marcus Reid', createdAt: '2026-09-12T13:10:00Z', ...overrides, }; } export function makeDashboard(overrides: Record = {}) { return { totalBookings: 6, confirmedBookings: 5, cancelledBookings: 1, totalRevenue: 7263.16, currency: 'USD', flightsInStock: 294, seatsSold: 12, recentBookings: [makeBooking()], topRoutes: [{ route: 'JFK to LHR', bookings: 3, revenue: 4000 }], ...overrides, }; } export function page$(items: T[], total = items.length, limit = 10, offset = 0) { return { items, total, limit, offset }; } // --- scenario -------------------------------------------------------------- /** * How one endpoint should answer. A bare value is a 200 with that value as the * body; use `reply`, `fail` or `slow` for anything else. * * The envelope carries a symbol brand rather than being recognised by shape. * Domain objects here have a `status` field of their own -- a flight is * "scheduled", a booking is "confirmed" -- so duck-typing on `status` would * silently read a booking as an HTTP response. */ const ENVELOPE = Symbol('travelhub.reply'); export interface ReplyEnvelope { [ENVELOPE]: true; status: number; body: unknown; delayMs?: number; } export type Reply = ReplyEnvelope | unknown; /** An explicit response: status, body and optional latency. */ export function reply(opts: { status?: number; body?: unknown; delayMs?: number }): ReplyEnvelope { return { [ENVELOPE]: true, status: opts.status ?? 200, body: opts.body ?? {}, delayMs: opts.delayMs, }; } /** An error response, in the shape the Go API uses. */ export function fail(status: number, error = 'simulated failure'): ReplyEnvelope { return reply({ status, body: { error } }); } /** A successful but slow response, for asserting on loading states. */ export function slow(body: unknown, delayMs: number): ReplyEnvelope { return reply({ status: 200, body, delayMs }); } export interface Scenario { /** Latency applied to every endpoint that does not set its own. */ delayMs?: number; /** Abort every API call, as though the network were gone. */ offline?: boolean; session?: Reply; tenants?: Reply; airports?: Reply; airlines?: Reply; flights?: Reply; flightById?: Reply; bookings?: Reply; bookingById?: Reply; createBooking?: Reply; cancelBooking?: Reply; dashboard?: Reply; } function normalise(value: Reply): { status: number; body: unknown; delayMs?: number } { if (value !== null && typeof value === 'object' && ENVELOPE in value) { const envelope = value as ReplyEnvelope; return { status: envelope.status, body: envelope.body, delayMs: envelope.delayMs }; } return { status: 200, body: value }; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Installs the simulated API. Anything the scenario does not mention falls * back to a sensible, healthy default, so a test only spells out the one * condition it is about. */ export async function simulateApi(page: Page, scenario: Scenario = {}): Promise { const defaults: Scenario = { session: SESSION, tenants: TENANTS, airports: AIRPORTS, airlines: AIRLINES, flights: page$([makeFlight()]), flightById: makeFlight(), bookings: page$([makeBooking()]), bookingById: makeBooking(), createBooking: reply({ status: 201, body: makeBooking() }), cancelBooking: makeBooking({ status: 'cancelled' }), dashboard: makeDashboard(), }; const merged = { ...defaults, ...scenario }; await page.route('**/api/**', async (route) => { if (scenario.offline) { await route.abort('internetdisconnected'); return; } const request = route.request(); const path = new URL(request.url()).pathname; const method = request.method(); let answer: Reply; if (path === '/api/health') { answer = { status: 'ok' }; } else if (path === '/api/tenants') { answer = merged.tenants; } else if (path === '/api/session') { answer = merged.session; } else if (path === '/api/airports') { answer = merged.airports; } else if (path === '/api/airlines') { answer = merged.airlines; } else if (path === '/api/dashboard') { answer = merged.dashboard; } else if (path === '/api/flights') { answer = merged.flights; } else if (path.startsWith('/api/flights/')) { answer = merged.flightById; } else if (path.endsWith('/cancel')) { answer = merged.cancelBooking; } else if (path === '/api/bookings') { answer = method === 'POST' ? merged.createBooking : merged.bookings; } else if (path.startsWith('/api/bookings/')) { answer = merged.bookingById; } else { answer = fail(404, 'not found'); } const { status, body, delayMs } = normalise(answer); const wait = delayMs ?? merged.delayMs ?? 0; if (wait > 0) { await sleep(wait); } await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body), }); }); } /** * Puts credentials in localStorage before the app boots, so a simulated test * lands straight on the screen under test instead of signing in first. */ export async function primeCredentials(page: Page, tenant = 'skyline'): Promise { await page.addInitScript( ([slug]) => { localStorage.setItem( 'travelhub.credentials', JSON.stringify({ tenant: slug, basic: btoa('agent@skyline.test:demo1234') }), ); }, [tenant], ); }