Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

View File

@@ -0,0 +1,367 @@
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 };
}

92
e2e-ui/fixtures/data.ts Normal file
View File

@@ -0,0 +1,92 @@
/** Shared constants mirroring apps/edr-passenger-api/test/fixtures/{seed-core,seed-ui}.ts. */
export const STATIONS = {
A: "00000000-0000-4000-8000-000000000020", // Alpha / AAA
B: "00000000-0000-4000-8000-000000000021", // Bravo / BBB
C: "00000000-0000-4000-8000-000000000022", // Charlie / CCC
} as const;
export const SCHEDULE_ID = "00000000-0000-4000-8000-000000000101";
export const RETURN_SCHEDULE_ID = "00000000-0000-4000-8000-000000000201";
export const SEAT_CLASS_LOCAL = "00000000-0000-4000-8000-000000000010";
export const SEAT_CLASS_INTL = "00000000-0000-4000-8000-000000000011";
export const COACH_TYPE_ID = "00000000-0000-4000-8000-000000000001";
export const ROUTE_ID = "00000000-0000-4000-8000-000000000030";
export const PROMO_VALID = "PROMO10";
export const PROMO_EXPIRED = "EXPIRED50";
export const API_URL = process.env.API_URL ?? "http://localhost:4000";
/** Friendly nationality name → the enum the portal/search expects. */
export const NATIONALITY_ENUM = {
Ethiopian: "ETHIOPIAN",
Djiboutian: "DJIBOUTIAN",
Other: "OTHER",
} as const;
export type Nationality = keyof typeof NATIONALITY_ENUM;
/** Display currency the search returns per nationality (asserted by the currency specs). */
export const CURRENCY_BY_NATIONALITY = {
Ethiopian: "ETB",
Djiboutian: "DJF",
Other: "USD",
} as const;
function tokenFrom(file: string): string {
const fs = require("node:fs") as typeof import("node:fs");
const path = require("node:path") as typeof import("node:path");
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "storage", file), "utf8"));
for (const origin of raw.origins ?? []) {
for (const item of origin.localStorage ?? []) {
if (item.name === "auth_token") return item.value as string;
}
}
throw new Error(`auth_token not found in ${file} — did global-setup run?`);
}
/** Staff/admin auth token minted by global-setup (backoffice storageState). */
export function staffToken(): string {
return tokenFrom("staff.json");
}
/** Regular passenger (non-admin) auth token minted by global-setup (portal storageState). */
export function passengerToken(): string {
return tokenFrom("passenger.json");
}
/** Must match seed-ui.sampleDepartAt(): now + 2 days at 06:00Z. */
export function sampleDepartDate(): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() + 2);
d.setUTCHours(6, 0, 0, 0);
return d.toISOString().slice(0, 10);
}
/**
* Deep-link to the results page (bypasses the search form, which cannot emit tripType/returnDate).
* `nationality` accepts the friendly name ("Ethiopian"|"Djiboutian"|"Other") and is emitted as the
* enum the results page expects. For a round trip, pass tripType "ROUND_TRIP" — returnDate defaults
* to the same calendar day (the seeded return leg departs 8h after the outbound).
*/
export function resultsUrl(opts?: {
adults?: number;
children?: number;
nationality?: string;
tripType?: "ONE_WAY" | "ROUND_TRIP";
returnDate?: string;
}) {
const nat = opts?.nationality ?? "Ethiopian";
const enumNat = (NATIONALITY_ENUM as Record<string, string>)[nat] ?? nat.toUpperCase();
const p = new URLSearchParams({
origin: STATIONS.A,
destination: STATIONS.C,
date: sampleDepartDate(),
tripType: opts?.tripType ?? "ONE_WAY",
adults: String(opts?.adults ?? 1),
children: String(opts?.children ?? 0),
nationality: enumNat,
});
if ((opts?.tripType ?? "ONE_WAY") === "ROUND_TRIP") {
p.set("returnDate", opts?.returnDate ?? sampleDepartDate());
}
return `/booking/results?${p.toString()}`;
}