Files
edr-platform/e2e-ui/fixtures/booking-flow.ts

368 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 6110. 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<Nationality, string> = {
Ethiopian: "912345678",
Djiboutian: "77123456",
Other: "14155552671",
};
const PASSPORT_COUNTRY: Record<Nationality, string> = {
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<BookingResult> {
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 };
}