import { expect, type Locator, type Page, type Route } from "@playwright/test"; import { API_URL, CURRENCY_BY_NATIONALITY, resultsUrl } from "./data"; export type Nationality = "Ethiopian" | "Djiboutian" | "Other"; export interface PaxSpec { category: "ADULT" | "CHILD"; name: string; gender: "Male" | "Female"; /** Date of birth. Adults: age 6–110. Children: age < 5 (to be free-eligible). */ dob: { d: number; m: number; y: number }; /** Adults only. */ phone?: string; /** Non-Ethiopian adults only. */ passport?: { number: string; country: string; issue: string; expiry: string }; } export interface TripOptions { nationality?: Nationality; tripType?: "ONE_WAY" | "ROUND_TRIP"; /** If `passengers` is omitted, N adults + M children are generated. */ adults?: number; children?: number; passengers?: PaxSpec[]; /** Promo code injected via the results URL (`?promoCode=`) — the portal has no promo input. */ promoCode?: string; /** Mutate the outgoing POST /bookings(/guest) body (e.g. forge reviewedTotalMinor). */ mutateBookingBody?: (body: any) => any; /** * When the POST /bookings(/guest) is expected to be rejected (e.g. a forged total the server * must refuse): don't assert a bookingId and return early with `bookingStatus` set, instead of * driving on to payment. Lets a spec assert the server refused the booking. */ tolerateBookingError?: boolean; paymentMethod?: "WALLET" | "TELEBIRR"; /** * For TELEBIRR: after initiate, abort the external gateway redirect and forge settlement via the * internal mark-paid endpoint. `amountMinor` lets a test short-pay (settle for the wrong amount). * Defaults to settling for the real booking total. */ forgeSettlement?: { amountMinor?: number }; } export interface BookingResult { /** displayAmountMinor on the results card (what the passenger sees — passenger currency). */ cardDisplayMinor: number; /** baseFareMinor on the results card (internal ETB fare; diverges from display for USD/DJF). */ cardBaseFareMinor: number; /** The search response's displayCurrency (ETB/USD/DJF). */ displayCurrency: string; /** reviewedTotalMinor the browser actually sent to POST /bookings. */ reviewedTotalMinor: number; /** HTTP status the POST /bookings(/guest) returned (2xx on success, 4xx when the server rejects). */ bookingStatus: number; bookingId: string; /** Whether the flow used /bookings/guest. */ guest: boolean; initiateStatus: number; /** merchantOrderId returned by POST /payments/initiate (gateway methods). */ merchantOrderId?: string; /** true once /booking/confirmation is reached. */ confirmed: boolean; /** The GET /search/fare-breakdown payload seen on the review page (per-pax fares + discount). */ fareBreakdown: any; } const PHONE_BY_NATIONALITY: Record = { Ethiopian: "912345678", Djiboutian: "77123456", Other: "14155552671", }; const PASSPORT_COUNTRY: Record = { Ethiopian: "", Djiboutian: "Djibouti", Other: "Canada", }; /** Build a default passenger list: adults first, then children (matches form index → category). */ export function makePassengers(adults: number, children: number, nationality: Nationality): PaxSpec[] { const list: PaxSpec[] = []; for (let i = 0; i < adults; i++) { list.push({ category: "ADULT", name: `Adult ${i + 1}`, gender: i % 2 === 0 ? "Male" : "Female", dob: { d: 15, m: 6, y: 1990 }, phone: PHONE_BY_NATIONALITY[nationality], passport: nationality === "Ethiopian" ? undefined : { number: "P1234567", country: PASSPORT_COUNTRY[nationality], issue: "2020-01-01", expiry: "2032-01-01" }, }); } for (let j = 0; j < children; j++) { // Age ~3 as of 2026 → strictly under 5, so isChild() and the free-child policy apply. list.push({ category: "CHILD", name: `Child ${j + 1}`, gender: "Female", dob: { d: 10, m: 3, y: 2023 } }); } return list; } /** Passenger card locator (scoped by the "Passenger N" heading; N is 1-based). */ function card(page: Page, i: number): Locator { return page.locator("div.card").filter({ hasText: new RegExp(`Passenger ${i + 1}\\b`) }); } /** Open the DOB modal for a passenger card, enter the date manually, and confirm. */ async function fillDob(page: Page, c: Locator, dob: { d: number; m: number; y: number }) { await c.getByRole("button", { name: /select date of birth/i }).click(); await page.getByRole("button", { name: /enter manually/i }).click(); await page.getByPlaceholder("DD").fill(String(dob.d)); await page.getByPlaceholder("MM").fill(String(dob.m)); await page.getByPlaceholder("YYYY").fill(String(dob.y)); await page.getByRole("button", { name: /^confirm/i }).click(); } /** Fill one passenger card (adult or child), revealing the manual form if it's gated. */ async function fillPassenger(page: Page, i: number, spec: PaxSpec) { const c = card(page, i); const nameInput = page.locator(`input[name="passengers.${i}.name"]`); // Adults may sit behind a Fayda gate that must be toggled open. Wait for whichever appears first — // the name field (already expanded) or the reveal button — so we never toggle an open form closed. const reveal = c.getByRole("button", { name: /enter details manually|skip for now/i }).first(); await Promise.race([ nameInput.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}), reveal.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}), ]); if (!(await nameInput.isVisible().catch(() => false)) && (await reveal.isVisible().catch(() => false))) { await reveal.click(); } await nameInput.waitFor({ state: "visible", timeout: 15_000 }); await nameInput.fill(spec.name); await page.locator(`select[name="passengers.${i}.gender"]`).selectOption(spec.gender); if (spec.category === "ADULT" && spec.phone) { await c.locator('input[type="tel"]').first().fill(spec.phone); } if (spec.passport) { await page.locator(`input[name="passengers.${i}.passportNumber"]`).fill(spec.passport.number); await page.locator(`select[name="passengers.${i}.passportCountry"]`).selectOption(spec.passport.country); await page.locator(`input[name="passengers.${i}.passportIssueDate"]`).fill(spec.passport.issue); await page.locator(`input[name="passengers.${i}.passportExpiryDate"]`).fill(spec.passport.expiry); } await fillDob(page, c, spec.dob); } /** Select a coach + continue, once for a one-way leg or twice for a round trip. */ async function selectResultsAndContinue(page: Page, roundTrip: boolean) { const pickCoach = async (scope: Locator | Page) => { await (scope as Page).getByTestId("result-select-btn").first().click(); await page.getByTestId("coach-option").first().click(); await page.getByTestId("continue-passenger-details").first().click(); }; await pickCoach(page); // outbound (advances to the inbound step for a round trip) if (roundTrip) { // The inbound step re-renders result cards; scope to the inbound section if present. const inbound = page.locator("#inbound-section"); const scope = (await inbound.count()) > 0 ? inbound : page; await scope.getByTestId("result-select-btn").first().click(); await page.getByTestId("coach-option").first().click(); await page.getByTestId("continue-passenger-details").first().click(); } } /** Auto-assign seats (fills all passengers at once and auto-continues). Twice for a round trip. */ async function assignSeatsAndContinue(page: Page, roundTrip: boolean) { const autoAssign = () => page.getByRole("button", { name: /auto assign seats/i }).first().click(); await autoAssign(); // outbound if (roundTrip) { // After the outbound hold, the page switches to the return-seat map. await page.getByRole("heading", { name: /return seats/i }).waitFor({ timeout: 20_000 }); await autoAssign(); // inbound } await page.waitForURL(/\/booking\/review/, { timeout: 30_000 }); } /** * Drives the real portal booking flow end to end for an arbitrary passenger mix, nationality, * trip type, promo, and payment method. Captures the price at each hop for DB cross-checks. * Runs as a guest when the page context has no auth token (the `guest` Playwright project). */ export async function bookTrip(page: Page, opts: TripOptions = {}): Promise { const nationality = opts.nationality ?? "Ethiopian"; const tripType = opts.tripType ?? "ONE_WAY"; const roundTrip = tripType === "ROUND_TRIP"; const passengers = opts.passengers ?? makePassengers(opts.adults ?? 1, opts.children ?? 0, nationality); const adults = passengers.filter((p) => p.category === "ADULT").length; const children = passengers.filter((p) => p.category === "CHILD").length; // Optional: forge the POST /bookings body before it leaves the browser. if (opts.mutateBookingBody) { await page.route(/\/bookings(\/guest)?(\?|$)/, async (route: Route) => { if (route.request().method() !== "POST") return route.continue(); const body = route.request().postDataJSON(); await route.continue({ postData: JSON.stringify(opts.mutateBookingBody!(body)) }); }); } // ── Search / results ──────────────────────────────────────────────────────── const searchDone = page.waitForResponse( (r) => r.url().includes("/search") && r.request().method() === "POST", ); const base = resultsUrl({ nationality, adults, children, tripType }); await page.goto(opts.promoCode ? `${base}&promoCode=${encodeURIComponent(opts.promoCode)}` : base); const search = await searchDone; const out = (await search.json())?.data?.outbound?.[0]; const cardCls = out?.faresByClass?.[0]; const cardBaseFareMinor = cardCls?.baseFareMinor; const cardDisplayMinor = cardCls?.displayAmountMinor ?? cardBaseFareMinor; const displayCurrency = out?.displayCurrency ?? CURRENCY_BY_NATIONALITY[nationality]; expect(cardBaseFareMinor).toBeGreaterThan(0); await selectResultsAndContinue(page, roundTrip); // Both authenticated users and guests may pass through the auth-check interstitial: authenticated // users auto-forward to passengers, guests must click "Continue as guest". Handle whichever wins. await page.waitForURL(/\/booking\/(passengers|auth-check)/, { timeout: 30_000 }); if (/\/booking\/auth-check/.test(page.url())) { await Promise.race([ page.waitForURL(/\/booking\/passengers/, { timeout: 15_000 }).catch(() => {}), page .getByRole("button", { name: /continue as guest/i }) .click({ timeout: 15_000 }) .catch(() => {}), ]); await page.waitForURL(/\/booking\/passengers/, { timeout: 30_000 }); } // ── Passenger form ──────────────────────────────────────────────────────────── for (let i = 0; i < passengers.length; i++) await fillPassenger(page, i, passengers[i]); await page.getByRole("button", { name: /continue to seat selection/i }).click(); await page.waitForURL(/\/booking\/seats/, { timeout: 30_000 }); // ── Seats: auto-assign → hold → review ──────────────────────────────────────── const fbDone = page .waitForResponse((r) => r.url().includes("/search/fare-breakdown"), { timeout: 25_000 }) .catch(() => null); await assignSeatsAndContinue(page, roundTrip); const fbRes = await fbDone; const fbJson = fbRes ? await fbRes.json() : null; const fareBreakdown = fbJson?.data ?? fbJson; // ── Review: confirm → POST /bookings(/guest) ────────────────────────────────── const bookingDone = page.waitForResponse( (r) => /\/bookings(\/guest)?(\?|$)/.test(r.url()) && r.request().method() === "POST", ); await page.getByRole("button", { name: /^confirm/i }).first().click(); const bookingRes = await bookingDone; const bookingStatus = bookingRes.status(); const guest = bookingRes.url().includes("/bookings/guest"); const reviewedTotalMinor = bookingRes.request().postDataJSON()?.reviewedTotalMinor; // Expected-rejection path: the server refused the booking (e.g. a forged total). Return early // with the status so the caller can assert the refusal; there is no booking to drive to payment. if (opts.tolerateBookingError && !bookingRes.ok()) { return { cardDisplayMinor, cardBaseFareMinor, displayCurrency, reviewedTotalMinor, bookingStatus, bookingId: "", guest, initiateStatus: 0, confirmed: false, fareBreakdown, }; } const bookingData = (await bookingRes.json())?.data ?? {}; const bookingId = bookingData.id ?? bookingData.bookingId; expect(bookingId).toBeTruthy(); await page.waitForURL(/\/booking\/(payment|confirmation)/, { timeout: 30_000 }); const result: BookingResult = { cardDisplayMinor, cardBaseFareMinor, displayCurrency, reviewedTotalMinor, bookingStatus, bookingId, guest, initiateStatus: 0, confirmed: false, fareBreakdown, }; // A zero-total booking skips payment and lands straight on confirmation. if (/\/booking\/confirmation/.test(page.url())) { result.confirmed = true; return result; } // ── Payment ───────────────────────────────────────────────────────────────── const method = opts.paymentMethod ?? "WALLET"; if (method === "WALLET") { // WALLET settles fully server-side, synchronously → straight to /booking/confirmation. const initiateDone = page.waitForResponse( (r) => r.url().includes("/payments/initiate") && r.request().method() === "POST", ); await page.getByTestId("pay-method-WALLET").first().click(); await page.getByRole("button", { name: /^pay\b/i }).first().click(); result.initiateStatus = (await initiateDone).status(); result.confirmed = await page .waitForURL(/\/booking\/confirmation/, { timeout: 25_000 }) .then(() => true) .catch(() => false); return result; } // Gateway (TELEBIRR): the real provider is unreachable in the test env (initiate 502s), so we do // what the matrix prescribes — inject settlement. The booking is already created through the real // browser flow and sits in PENDING_PAYMENT; we forge the payment.succeeded event to the internal // mark-paid endpoint (ungated when SERVICE_AUTH_TOKEN is unset), then let the confirmation page's // poll flip to CONFIRMED. `forgeSettlement.amountMinor` lets a test short-pay (settle wrong amount). const amountMinor = opts.forgeSettlement?.amountMinor ?? reviewedTotalMinor; // mark-paid sits behind the global JwtGuard (any valid token passes; ServiceAuthGuard is a no-op // when SERVICE_AUTH_TOKEN is unset). Reuse the logged-in passenger's token from localStorage. const authToken = await page.evaluate(() => localStorage.getItem("auth_token")); const markPaid = await page.request.post(`${API_URL}/internal/payments/mark-paid`, { headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, data: { version: 1, eventId: crypto.randomUUID(), // @IsUUID eventType: "payment.succeeded", occurredAt: new Date().toISOString(), service: "PASSENGER", intentId: crypto.randomUUID(), // @IsUUID referenceType: "BOOKING", referenceId: bookingId, merchantOrderId: `e2e-${bookingId}`, provider: "TELEBIRR", amountMinor, currency: "ETB", providerTxnId: `e2e-txn-${bookingId}`, paidAt: new Date().toISOString(), }, }); result.initiateStatus = markPaid.status(); // mark-paid finalizes synchronously; confirm authoritatively via the booking status API (the // confirmation page's DOM depends on the client store, which a direct navigation may not carry). await page.goto("/booking/confirmation"); for (let attempt = 0; attempt < 10 && !result.confirmed; attempt++) { const res = await page.request.get(`${API_URL}/bookings/${bookingId}`, { headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, }); const status = ((await res.json().catch(() => ({})))?.data ?? {})?.status; if (status === "CONFIRMED") result.confirmed = true; else await page.waitForTimeout(500); } return result; } /** Back-compat wrapper: one-way single adult (used by the original UA-1/8/13 specs). */ export interface BookingOptions { nationality?: Nationality; promoCode?: string; mutateBookingBody?: (body: any) => any; tolerateBookingError?: boolean; paymentMethod?: "WALLET" | "TELEBIRR"; } export async function bookOneAdult(page: Page, opts: BookingOptions = {}) { const r = await bookTrip(page, { ...opts, adults: 1, children: 0, tripType: "ONE_WAY" }); // Preserve the original field name used by the existing specs. return { ...r, cardFareMinor: r.cardBaseFareMinor }; }