mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Adding all the tests and fixes to the passengers app
This commit is contained in:
367
e2e-ui/fixtures/booking-flow.ts
Normal file
367
e2e-ui/fixtures/booking-flow.ts
Normal 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 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 };
|
||||
}
|
||||
92
e2e-ui/fixtures/data.ts
Normal file
92
e2e-ui/fixtures/data.ts
Normal 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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user