mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
368 lines
17 KiB
TypeScript
368 lines
17 KiB
TypeScript
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<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 };
|
||
}
|