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

4
e2e-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
fixtures/storage/
test-results/
../e2e-ui-report/
.last-run.json

154
e2e-ui/README.md Normal file
View File

@@ -0,0 +1,154 @@
# EDR Passenger — Playwright UI E2E
Browser E2E for the passenger platform. **Track A** = portal booking combinations; **Track B** =
backoffice config → portal propagation. Scenario matrix: `docs/ui-e2e-test-matrix.md`.
## Status
**Track A + Track B implemented and green** — 27 passing specs, 1 documented skip (UA-7). Full suite
runs deterministically in ~1.8 min (`workers:1`, one seeded DB shared serially). The whole booking
flow is factored into `fixtures/booking-flow.ts``bookTrip(page, opts)` drives an arbitrary
passenger mix, nationality, trip type, promo, and payment method end to end (search → select →
passengers → seats → review → pay → confirmation), capturing the price at each hop; `bookOneAdult` is
a thin back-compat wrapper.
### Coverage vs `docs/ui-e2e-test-matrix.md`
**Track A — booking combinations** (`specs/portal`, `specs/guest`):
| ID | Spec | What it proves |
|----|------|----------------|
| UA-1 | `ua1` | one-way 1A ETB WALLET — full money chain equal, CONFIRMED |
| UA-1b | `ua1b-usd-divergence` | 🔴 USD card display fare diverges 100× from internal `baseFareMinor` |
| UA-2 | `ua2-usd-booking` | 🔴 USD booking — reviewed total (ETB) vs stored total (USD) diverge 100× |
| UA-3 | `ua3-djf` | DJF booking settles via forged gateway payment |
| UA-3w | `ua3-djf` | 🔴 DJF WALLET — reviewed (DJF) vs stored (ETB) diverge |
| UA-4 | `ua4-child-free` | first child <5 free total = one adult fare, free child not seated |
| UA-5 | `ua5-second-child-paid` | 1A+2C second child pays full fare (2 seats) |
| UA-6 | `ua6-round-trip` | 🔴 round-trip inbound leg has availability but no priced coach (booking blocked) |
| UA-8 | `ua8-promo-drop` | valid promo now applied server-side; booking stored at the discounted total (H-13 fixed & guarded) |
| UA-11 | `ua11-expired-promo` | expired promo ignored full fare booked |
| UA-13 | `ua13-forged-total` | client-forged `reviewedTotalMinor=1` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) |
| UA-14 | `ua14-forged-seat-fare` | guest forged `seatFareMinor=0` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) |
| UA-15 | `ua15-telebirr-shortpay` | short-paid gateway settlement now REFUSED booking stays unconfirmed (C-4 fixed & guarded) |
| UA-16 | `ua16-family-mix` | 2A+3C two children free, one paid (3 seats) |
**Track B — config → portal propagation & validation gaps** (`specs/backoffice`, `specs/propagation`):
| ID | Spec | What it proves |
|----|------|----------------|
| PB-1 | `pb-config-propagation` | FX-rate change propagates live to portal USD pricing |
| PB-2 / PB-2b | `pb-config-propagation` | seat-class base-price change propagates live; `basePrice` field drives the fare |
| PB-4 | `pb-config-propagation` | station added in backoffice appears in the portal station list |
| PB-7 | `config-validation` | 🔴 promo created with backoffice UI field names is inert (field-name mismatch) |
| PB-10 | `pb-config-propagation` | deleting an FX rate now FAILS CLOSED (no priced fare) instead of a silent 1.0 collapse (M-5/H-2 fixed & guarded) |
| BC-7 | `pb-config-propagation` | negative seat-class base price now REJECTED (400, DTO `@Min(0)`) (M-1 fixed & guarded) |
| BC-8 | `config-validation` | promo over 100% now REJECTED (400, DTO `@Max(100)`) (M-2 fixed & guarded) |
| BC-9 | `config-validation` | negative seat-hold duration now REJECTED (400, whitelisted typed `/config` DTO) (M-3 fixed & guarded) |
| BC-10 | `config-validation` | 🔴 schedule with a past departure accepted (M-4) |
| BC-11 | `pb-config-propagation` | non-admin passenger now FORBIDDEN (403) from FX writes; admin still allowed (C-8 fixed & guarded) |
### Deferred (documented, not silently omitted)
- **UA-7** (round-trip berth) `specs/portal/ua7-berth.spec.ts` is `test.skip` with the reason: needs
a bed CoachType + reverse-leg pricing, both backend prerequisites (see UA-6 for the reverse-leg gap).
- **UA-9 / UA-10 / UA-12 / UA-17** the matrix moves these to the API-level harness (over-100% /
over-subtotal promos, loyalty over-redeem, DJF×promo negative total): no reachable browser path
(server clamps `reviewedTotalMinor` 0; the portal never calls the loyalty/fare-quote path).
- **PB-3/5/6/8/9, BC-1BC-6** additional config surfaces and delete-referenced/mid-flight/staleness
variations of the finding classes already covered above; the matrix marks several as deferrable.
**Gateway settlement:** the real telebirr gateway is unreachable in the test env (`/payments/initiate`
502s), so gateway rows (UA-3, UA-15) create the booking through the real browser flow and then inject
settlement via `POST /internal/payments/mark-paid` exactly the matrix's settlement-injection plan.
Portal testids used: `result-select-btn`, `coach-option`, `continue-passenger-details`,
`pay-method-{TYPE}`. Everything else (passengers form + DOB modal, seats auto-assign, review, payment)
is driven via name/placeholder/role selectors no further source edits were needed.
**Seed note:** `Passenger.id` is set EQUAL to the IAM user id see the comment in `seed-ui.ts`
(`UI_IDS.passenger`) and the SUSPECTED FINDING below. Each coach seeds 48 seats so a full serial run
never exhausts availability across specs.
## Suspected finding (surfaced while building UA-1)
`POST /bookings` (authenticated) overrides `passengerId` with the JWT user id
(`bookings.controller.ts:528-532`, "never trust the request body"). The service only resolves an
iamUserId Passenger when it is **non-UUID** (`bookings.service.ts:773`). IAM user ids are UUIDs, so
the resolver never fires and `booking.create` uses the iamUserId directly as `passengerId` FK
violation unless `Passenger.id == iamUserId`. This is why the seed aligns them. **Verify against a
real IAM-authenticated booking** if `req.user.id` is genuinely the iamUserId in production,
authenticated portal bookings may be broken (guest path unaffected). Candidate for `docs/ISSUES.md`.
## Prerequisites — the running stack
The suite drives a live stack. `global-setup.ts` seeds + mints auth, but assumes the apps are
already up. Bring them up once (leave running across test runs):
```bash
# 1. Infra: test Postgres (5544) + RabbitMQ (5672, payment vhost)
bash e2e/prepare.sh # postgres + migrations
docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e
# 2. Build the shared types package (nest build needs the dist)
pnpm --filter @edr/types build
# 3. passenger-api on :4000 against the 5544 DB, with org+staff seeding on
# (apps/edr-passenger-api/.env sets DATABASE_URL=…5544, PORT=4000,
# RABBITMQ_ENABLED=false, FAYDA_ENABLED=false, SEED_EDR_PASSENGER_ORG=true,
# SEED_PASSENGER_STAFF=true, DEFAULT_PASSWORD=Test@1234)
( cd apps/edr-passenger-api && pnpm dev ) # background
# 4. Web apps (each has .env.local → NEXT_PUBLIC_API_URL=http://localhost:4000)
( cd apps/edr-passenger-web/portal && pnpm dev ) # :5174, background
( cd apps/edr-passenger-web/backoffice && pnpm dev ) # :5184, background
```
> `playwright.config.ts` now declares a `webServer` block that auto-boots api/portal/backoffice and
> **reuses** them if already running, so steps 34 are optional in local dev. Gateway rows settle via
> the internal `mark-paid` endpoint, so `apps/edr-payment-api` (:3003) is **not** required.
## Run
One command (infra build boot seed+auth run open report):
```bash
bash e2e-ui/run.sh # all projects; args pass through to playwright
bash e2e-ui/run.sh --headed # watch it in a real browser
SLOWMO=500 bash e2e-ui/run.sh --headed # slow every action by 500ms
bash e2e-ui/run.sh --project=portal ua4 # one project / filter by title
```
Or, against an already-running stack:
```bash
pnpm test:e2e:ui # all projects
pnpm test:e2e:ui -- --project=guest --project=backoffice # smoke only
```
HTML report `e2e-ui-report/index.html`.
## Layout
```
e2e-ui/
playwright.config.ts projects: portal (passenger auth), guest (none),
backoffice (staff auth), propagation (cross-app)
global-setup.ts seeds test DB (seed-ui.ts) + mints staff.json via real /login
fixtures/
data.ts station IDs, sample depart date, results deep-link helper
storage/staff.json generated staff storageState (gitignored)
specs/{guest,portal,backoffice,propagation}/*.spec.ts
```
Seed lives with the API harness: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (extends
`seed-core.ts` with a bookable Train/Schedule/Coach/Seats, enabled PaymentMethods WALLET+TELEBIRR,
promos, funded wallet). Run standalone: `npx ts-node test/fixtures/seed-ui.ts`.
## Auth model (grounded in the app)
- **Portal (passenger)**: `localStorage.auth_token` only, no server gate. (passenger storageState is
a Phase 3 item smoke uses the `guest` project.)
- **Backoffice (staff)**: middleware requires the `auth_token` **cookie**; API guards check the
session's permissions. `global-setup` logs in as the seeded `passenger.admin@edr.local` through
the real `/login` UI and snapshots both. No hand-crafted tokens.

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()}`;
}

74
e2e-ui/global-setup.ts Normal file
View File

@@ -0,0 +1,74 @@
import { chromium, type FullConfig } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import * as fs from "node:fs";
import * as path from "node:path";
import { seedUi } from "../apps/edr-passenger-api/test/fixtures/seed-ui";
import { seedPassengerSession } from "../apps/edr-passenger-api/test/fixtures/seed-passenger-session";
/**
* Playwright global-setup for the UI E2E suite.
* 1. Seeds the 5544 test DB with the bookable trip + payment methods + promos (seed-ui.ts).
* 2. Mints a passenger IAM session + token → passenger.json storageState (localStorage).
* 3. Logs in as the seeded backoffice admin via the REAL /login UI → staff.json storageState.
*
* Assumes the stack is already running (api :4000, portal :5174, backoffice :5184).
*/
const STORAGE_DIR = path.join(__dirname, "fixtures", "storage");
const API = process.env.API_URL ?? "http://localhost:4000";
const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174";
const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184";
const DB_URL =
process.env.DATABASE_URL ??
"postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger";
const STAFF = { email: "passenger.admin@edr.local", password: process.env.DEFAULT_PASSWORD ?? "Test@1234" };
export default async function globalSetup(_config: FullConfig) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
process.env.DATABASE_URL = DB_URL;
const prisma = new PrismaClient();
try {
console.log("[global-setup] seeding test DB…");
await seedUi(prisma);
console.log("[global-setup] minting passenger session…");
const { token } = await seedPassengerSession(prisma);
const profileRes = await fetch(`${API}/auth/profile`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!profileRes.ok) throw new Error(`/auth/profile failed: HTTP ${profileRes.status}`);
const profile = (await profileRes.json())?.data ?? {};
const passengerState = {
cookies: [],
origins: [
{
origin: PORTAL,
localStorage: [
{ name: "auth_token", value: token },
{ name: "auth_user", value: JSON.stringify(profile) },
],
},
],
};
fs.writeFileSync(path.join(STORAGE_DIR, "passenger.json"), JSON.stringify(passengerState));
console.log("[global-setup] passenger.json written");
} finally {
await prisma.$disconnect();
}
console.log("[global-setup] minting staff storageState via real login…");
const browser = await chromium.launch();
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto(`${BACKOFFICE}/login`, { waitUntil: "domcontentloaded" });
await page.locator('input[type="email"]').fill(STAFF.email);
await page.locator('input[type="password"]').fill(STAFF.password);
await Promise.all([
page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }),
page.locator('button[type="submit"]').click(),
]);
await ctx.storageState({ path: path.join(STORAGE_DIR, "staff.json") });
console.log("[global-setup] staff.json written");
await browser.close();
}

View File

@@ -0,0 +1,94 @@
import { defineConfig, devices } from "@playwright/test";
import * as path from "node:path";
// Defaults so `pnpm test:e2e:ui` runs standalone. Must match apps/edr-passenger-api/.env.
process.env.DATABASE_URL ??=
"postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger";
process.env.JWT_ACCESS_TOKEN_SECRET ??= "test-access-secret-0000000000000000000000";
process.env.DEFAULT_PASSWORD ??= "Test@1234";
/**
* Playwright UI E2E for the EDR passenger platform.
* Track A (portal booking combinations) + Track B (backoffice config → portal propagation).
* See docs/ui-e2e-test-matrix.md. global-setup boots/awaits the stack, seeds the 5544 test DB,
* and mints the passenger + staff storageStates.
*/
const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174";
const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184";
const STORAGE = path.join(__dirname, "fixtures", "storage");
export default defineConfig({
testDir: path.join(__dirname, "specs"),
fullyParallel: false, // shared seeded DB — serialize to keep assertions deterministic
workers: 1,
retries: 0,
timeout: 60_000,
expect: { timeout: 10_000 },
globalSetup: path.join(__dirname, "global-setup.ts"),
reporter: [
["list"],
["html", { outputFolder: path.join(__dirname, "..", "e2e-ui-report"), open: "never" }],
],
// Boot the app tier automatically; reuse it if it's already running (dev). Infra (Postgres 5544,
// RabbitMQ, migrations, @edr/types build) is handled by e2e-ui/run.sh BEFORE Playwright starts.
webServer: [
{
command: "pnpm --filter @edr/passenger-api dev",
url: "http://localhost:4000/stations",
timeout: 180_000,
reuseExistingServer: true,
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
},
{
command: "pnpm --filter @edr/passenger-portal dev",
url: PORTAL,
timeout: 120_000,
reuseExistingServer: true,
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
},
{
command: "pnpm --filter @edr/passenger-backoffice dev",
url: `${BACKOFFICE}/login`,
timeout: 120_000,
reuseExistingServer: true,
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
},
],
use: {
trace: "retain-on-failure",
screenshot: "only-on-failure",
actionTimeout: 15_000,
// SLOWMO=500 bash e2e-ui/run.sh --headed → pause 500ms between each browser action
launchOptions: { slowMo: Number(process.env.SLOWMO ?? 0) },
},
projects: [
{
name: "portal", // Track A — logged-in passenger
testMatch: /specs\/portal\/.*\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
baseURL: PORTAL,
storageState: path.join(STORAGE, "passenger.json"),
},
},
{
name: "guest", // Track A — guest bookings (no auth)
testMatch: /specs\/guest\/.*\.spec\.ts/,
use: { ...devices["Desktop Chrome"], baseURL: PORTAL },
},
{
name: "backoffice", // Track B — staff/admin
testMatch: /specs\/backoffice\/.*\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
baseURL: BACKOFFICE,
storageState: path.join(STORAGE, "staff.json"),
},
},
{
name: "propagation", // cross-app: staff writes config via API → passenger portal reads
testMatch: /specs\/propagation\/.*\.spec\.ts/,
use: { ...devices["Desktop Chrome"], baseURL: PORTAL },
},
],
});

40
e2e-ui/run.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# One-command UI E2E: infra → build → boot app tier (via Playwright webServer) → seed+auth → run →
# open the HTML report. Idempotent; reuses an already-running stack. Any args pass through to
# playwright (e.g. `bash e2e-ui/run.sh --project=portal ua1`).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$HERE/.."
API="$ROOT/apps/edr-passenger-api"
export GITHUB_PACKAGE_TOKEN="${GITHUB_PACKAGE_TOKEN:-dummy}"
echo "==> 1/4 Infra: Postgres (5544) + RabbitMQ (5672) + migrations"
bash "$ROOT/e2e/prepare.sh"
echo " waiting for RabbitMQ healthy"
for _ in $(seq 1 30); do
s="$(docker inspect --format '{{.State.Health.Status}}' edr-passenger-e2e-rmq 2>/dev/null || echo none)"
[ "$s" = "healthy" ] && break; sleep 2
done
echo "==> 2/4 Build shared types (@edr/types dist — nest build needs it)"
pnpm --filter @edr/types build >/dev/null
echo "==> 3/4 Ensure passenger-api dev env (test DB 5544, port 4000, brokers/Fayda off, seeding on)"
if [ ! -f "$API/.env" ]; then
sed -e 's/^PORT=.*/PORT=4000/' \
-e 's/^SEED_EDR_PASSENGER_ORG=.*/SEED_EDR_PASSENGER_ORG=true/' \
-e 's/^SEED_PASSENGER_STAFF=.*/SEED_PASSENGER_STAFF=true/' \
"$API/.env.test" > "$API/.env"
echo " created $API/.env"
fi
echo "==> 4/4 Playwright (boots api/portal/backoffice if not already up, seeds + mints auth, runs)"
npx playwright test -c "$HERE/playwright.config.ts" "$@" || TEST_EXIT=$?
REPORT="$ROOT/e2e-ui-report/index.html"
if [ -f "$REPORT" ]; then
echo "==> Report: $REPORT"
open "$REPORT" 2>/dev/null || true
fi
exit "${TEST_EXIT:-0}"

View File

@@ -0,0 +1,79 @@
import { test, expect } from "@playwright/test";
import { API_URL, ROUTE_ID, staffToken } from "../../fixtures/data";
import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui";
/**
* Track B — server-side validation gaps and the promo field-name mismatch. Each test calls the same
* passenger-api endpoints the backoffice forms hit, proving the client-side guards are the ONLY guard
* (the API accepts values the forms block) or that a UI/DTO field-name split silently breaks a config.
*/
function auth() {
return { Authorization: `Bearer ${staffToken()}` };
}
test("BC-8 ✅ a promo over 100% is rejected by the API (max validation, M-2)", async ({ request }) => {
// percentOff must be bounded 0..100 at the DTO layer (the backoffice form has no such check).
const res = await request.post(`${API_URL}/promos`, {
headers: auth(),
data: { code: `E2E_OVER100_${Date.now()}`, title: "over", percentOff: 200, validUntil: "2030-01-01T00:00:00Z", active: true },
});
expect(res.status()).toBe(400); // ✅ 200% discount rejected
// A valid promo (≤100%) still succeeds.
const okRes = await request.post(`${API_URL}/promos`, {
headers: auth(),
data: { code: `E2E_OK_${Date.now()}`, title: "ok", percentOff: 50, validUntil: "2030-01-01T00:00:00Z", active: true },
});
expect(okRes.ok()).toBeTruthy();
const promo = (await okRes.json())?.data ?? {};
await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {});
});
test("PB-7 🔴 a promo created with the backoffice UI field names is inert (field-name mismatch)", async ({ request }) => {
// The backoffice /promos form sends discountType/discountValue/isActive, but the DTO reads
// percentOff/amountOffMinor/active — so the sent discount is dropped and the promo saves at 0.
const res = await request.post(`${API_URL}/promos`, {
headers: auth(),
data: { code: `E2E_UIFIELDS_${Date.now()}`, title: "uifields", discountType: "PERCENTAGE", discountValue: 25, isActive: true, validUntil: "2030-01-01T00:00:00Z" },
});
expect(res.ok()).toBeTruthy();
const promo = (await res.json())?.data ?? {};
expect(promo.discountValue).toBe(0); // 🔴 the 25% the UI "set" was silently dropped
await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {});
});
test("BC-9 ✅ a negative seat-hold duration is rejected by /config (DTO validation, M-3)", async ({ request }) => {
// The settings form has min=1 max=60; the API must now enforce the same at the DTO layer.
const res = await request.patch(`${API_URL}/config`, {
headers: auth(),
data: { seat_hold_duration_minutes: "-1" },
});
expect(res.status()).toBe(400); // ✅ negative duration rejected
// A sane value in range still succeeds and is stored.
const ok = await request.patch(`${API_URL}/config`, { headers: auth(), data: { seat_hold_duration_minutes: "15" } });
expect(ok.ok()).toBeTruthy();
expect(((await ok.json())?.data ?? {}).seat_hold_duration_minutes).toBe("15");
});
test("BC-10 ✅ a schedule with a past departure is rejected by the API (past-date block, M-4)", async ({ request }) => {
// The schedules form only checks arrival > departure — the API must ALSO reject a past departure.
const past = new Date("2020-01-02T06:00:00.000Z");
const arrive = new Date("2020-01-02T10:00:00.000Z");
const res = await request.post(`${API_URL}/schedules`, {
headers: auth(),
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: past.toISOString(), arrivalAt: arrive.toISOString() },
});
expect(res.status()).toBe(400); // ✅ past-dated schedule rejected
// A future schedule (a different day than the seeded one) is still accepted.
const dep = new Date(Date.now() + 30 * 864e5); dep.setUTCHours(6, 0, 0, 0);
const arr = new Date(dep.getTime() + 4 * 3600e3);
const okRes = await request.post(`${API_URL}/schedules`, {
headers: auth(),
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
});
expect(okRes.ok()).toBeTruthy();
const sched = (await okRes.json())?.data ?? {};
await request.delete(`${API_URL}/schedules/${sched.id}`, { headers: auth() }).catch(() => {});
});

View File

@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
/**
* Backoffice smoke (Track B foundation): the staff storageState authenticates past the middleware
* cookie gate, /currencies loads its list from the API, and the add-rate control is reachable.
* Proves staff auth (cookie + localStorage + API token) is fully wired.
*/
test("backoffice: staff can load /currencies and reach the add-rate control", async ({ page }) => {
await page.goto("/currencies", { waitUntil: "domcontentloaded" });
// Not bounced to /login (middleware cookie gate passed).
await expect(page).not.toHaveURL(/\/login/);
// The page rendered a currencies view with a seeded currency and an add control.
await expect(page.getByText(/ETB|USD|DJF/).first()).toBeVisible({ timeout: 20_000 });
await expect(
page.getByRole("button", { name: /add/i }).first(),
).toBeVisible();
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { resultsUrl, SCHEDULE_ID } from "../../fixtures/data";
/**
* Portal smoke (Track A foundation): deep-link to results → POST /search fires → a priced result
* card for the seeded trip renders. Proves stack + seed + search + currency formatting are wired.
*/
test("portal: seeded trip appears in search results with a price", async ({ page }) => {
const searchResponse = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl());
const res = await searchResponse;
expect([200, 201]).toContain(res.status());
const body = await res.json();
const outbound = body?.data?.outbound ?? [];
expect(outbound.some((t: any) => t.scheduleId === SCHEDULE_ID)).toBe(true);
// The seeded train + a formatted ETB price render in the DOM.
await expect(page.getByText("UI Test Express").first()).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(/ETB\s*[\d,]+/).first()).toBeVisible();
});

View File

@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-14 ✅ — a GUEST (unauthenticated) booking with forged per-passenger seat fares (ISSUES C-1),
* guarded. We intercept POST /bookings/guest and rewrite every seatFareMinor (and reviewedTotalMinor)
* to 0. The server must recompute the authoritative fare and REJECT the underpayment with a 4xx —
* no free ride, nothing persisted.
*/
test("UA-14: server rejects a guest booking with forged seatFareMinor=0 (C-1)", async ({ page }) => {
const r = await bookTrip(page, {
paymentMethod: "WALLET",
tolerateBookingError: true,
mutateBookingBody: (body) => ({
...body,
reviewedTotalMinor: 0,
passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 0 })),
}),
});
expect(r.guest).toBe(true); // proves the /bookings/guest path was used
expect(r.cardBaseFareMinor).toBeGreaterThan(1000);
// The server must REFUSE the forged 0-fare booking with a 4xx…
expect(r.bookingStatus).toBeGreaterThanOrEqual(400);
expect(r.bookingStatus).toBeLessThan(500);
// …return no booking id and persist no free (0-minor) booking.
expect(r.bookingId).toBeFalsy();
const forged = await prisma.booking.findFirst({ where: { totalMinor: 0 } });
expect(forged).toBeNull();
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookOneAdult } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-1 — one-way, 1 adult, ETB, WALLET. The full real-browser booking flow, asserting the money
* chain: card fare > 0, and reviewedTotalMinor == Booking.totalMinor == displayTotalMinor ==
* PaymentIntent.amountMinor == wallet DEBIT, booking CONFIRMED.
*/
test("UA-1: one-way WALLET booking, price cross-check holds end to end", async ({ page }) => {
const r = await bookOneAdult(page, { nationality: "Ethiopian", paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
expect([200, 201]).toContain(r.initiateStatus);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } });
const debit = await prisma.walletLedgerEntry.findFirst({
where: { relatedBookingId: r.bookingId, type: "DEBIT" },
});
expect(booking.totalMinor).toBe(r.reviewedTotalMinor);
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
expect(intent.amountMinor).toBe(r.reviewedTotalMinor);
expect(debit?.amountMinor).toBe(r.reviewedTotalMinor);
expect(booking.status).toBe("CONFIRMED");
});

View File

@@ -0,0 +1,25 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
import { PROMO_EXPIRED } from "../../fixtures/data";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-11 — one-way, ETB, an EXPIRED promo injected via ?promoCode=. The expired code must not discount
* anything: the fare breakdown reports no discount and the booked total is the full fare (consistent
* with the UA-8 promo-drop behaviour, but here the promo is correctly rejected as expired).
*/
test("UA-11: an expired promo code is ignored — full fare is booked", async ({ page }) => {
const r = await bookTrip(page, { paymentMethod: "WALLET", promoCode: PROMO_EXPIRED });
expect(r.confirmed).toBe(true);
// No discount from the expired code.
if (r.fareBreakdown) expect(r.fareBreakdown.discountMinor ?? 0).toBe(0);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // full fare, no discount applied
});

View File

@@ -0,0 +1,40 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookOneAdult } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-13 ✅ — client-forged booking total (matrix A1 / ISSUES C-1), guarded through the REAL browser.
* We intercept the outgoing POST /bookings and rewrite reviewedTotalMinor (and every per-seat
* seatFareMinor) to 1. The server has two trust branches — sum-of-seatFareMinor when all are present,
* else reviewedTotalMinor — so the forge targets both. The server must recompute the authoritative
* fare and REJECT the mismatched client amount with a 4xx, persisting nothing.
*/
test("UA-13: server rejects a client-forged reviewedTotalMinor=1 (C-1)", async ({ page }) => {
const r = await bookOneAdult(page, {
nationality: "Ethiopian",
paymentMethod: "WALLET",
tolerateBookingError: true,
// Forge both the per-seat fares and the reviewed total → 1.
mutateBookingBody: (body) => ({
...body,
reviewedTotalMinor: 1,
passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 1 })),
}),
});
// The real fare the engine computed is far above 1…
expect(r.cardFareMinor).toBeGreaterThan(1000);
// …the browser forced reviewedTotalMinor=1, and the server must REFUSE it with a 4xx.
expect(r.reviewedTotalMinor).toBe(1);
expect(r.bookingStatus).toBeGreaterThanOrEqual(400);
expect(r.bookingStatus).toBeLessThan(500);
// No booking id was returned, and no 1-minor booking was persisted.
expect(r.bookingId).toBeFalsy();
const forged = await prisma.booking.findFirst({ where: { totalMinor: 1 } });
expect(forged).toBeNull();
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-15 ✅ — forged gateway SHORT-PAY (ISSUES C-4), guarded. A booking with a real fare in the
* thousands is settled by a forged payment.succeeded event carrying amountMinor = 1. The server must
* compare the settled amount against what the passenger was quoted (the booking's display total) and
* REFUSE to confirm a short payment — the booking stays unconfirmed and no ticket is issued.
*/
test("UA-15: a short-paid gateway settlement does NOT confirm the booking (C-4)", async ({ page }) => {
const r = await bookTrip(page, {
paymentMethod: "TELEBIRR",
forgeSettlement: { amountMinor: 1 }, // settle for 1 minor against a multi-thousand fare
});
expect(r.cardBaseFareMinor).toBeGreaterThan(1000);
expect(r.confirmed).toBe(false); // short-pay must NOT confirm the booking
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.status).not.toBe("CONFIRMED");
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-16 — one-way, 2 adults + 3 children under 5, ETB, WALLET (max passenger spread). One free child
* per adult → 2 free children, 1 paid. Total = 3 fares (2 adults + 1 paid child); 3 seats booked.
* Stresses the free-child reduce + multi-passenger seat assignment through the real browser.
*/
test("UA-16: 2 adults + 3 children — two children free, one paid", async ({ page }) => {
const r = await bookTrip(page, { adults: 2, children: 3, paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
const fare = r.cardBaseFareMinor;
expect(r.reviewedTotalMinor).toBe(fare * 3);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(fare * 3);
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
expect(seats.length).toBe(3);
});

View File

@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { resultsUrl } from "../../fixtures/data";
/**
* UA-1b — the first money-chain break. For a non-Ethiopian (USD) search the results card shows the
* USD-converted `displayAmountMinor`, but the internal `baseFareMinor` stays in raw ETB minor. The
* two diverge exactly 100× (the USD→ETB rate). The portal stores `Math.min(baseFareMinor)` on select,
* so the ETB value — not the USD one the passenger saw — is what flows downstream.
*/
test("UA-1b: USD card display fare diverges 100x from the internal baseFareMinor", async ({ page }) => {
const searchDone = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl({ nationality: "Other" }));
const out = (await (await searchDone).json())?.data?.outbound?.[0];
const cls = out?.faresByClass?.[0];
expect(out.displayCurrency).toBe("USD");
// The display fare is the converted USD amount; baseFareMinor is the untouched ETB minor.
expect(cls.displayAmountMinor).toBeLessThan(cls.baseFareMinor);
expect(cls.baseFareMinor).toBe(cls.displayAmountMinor * 100);
// The DOM shows the USD value (formatFare divides by 100, 2dp) — e.g. "USD 12.50".
const usdMajor = (cls.displayAmountMinor / 100).toFixed(2);
await expect(page.getByText(new RegExp(`USD\\s*${usdMajor.replace(".", "\\.")}`)).first()).toBeVisible({
timeout: 20_000,
});
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-2 🔴 — full one-way USD booking (Other nationality, INTERNATIONAL class, WALLET). This surfaces
* the money-chain incoherence for non-ETB currencies: the browser sends reviewedTotalMinor = the raw
* ETB baseFareMinor (125000), but the server stores Booking.totalMinor = the USD display value (1250).
* The two differ 100× — the client-computed "reviewed total" the C-1 path trusts is in the wrong
* currency, yet the stored/charged total is the display one. We pin both so a regression is caught.
*/
test("UA-2: USD booking — reviewed total (ETB) and stored total (USD) diverge 100x", async ({ page }) => {
const r = await bookTrip(page, { nationality: "Other", paymentMethod: "WALLET" });
expect(r.displayCurrency).toBe("USD");
expect(r.confirmed).toBe(true);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } });
// The browser sent the USD display value the passenger saw as the reviewed total…
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
// …but the booking stored the raw ETB base fare (100× larger, un-converted).
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
expect(booking.totalMinor).toBe(r.reviewedTotalMinor * 100); // 🔴 the divergence
expect(intent.amountMinor).toBe(booking.totalMinor);
expect(booking.displayCurrency).toBe("USD");
expect(booking.status).toBe("CONFIRMED");
});

View File

@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-3w — Djiboutian/DJF, WALLET. DJF is a zero-decimal currency, but the portal renders every fare
* through formatFare (always /100, 2dp) → "DJF x.yy". WALLET short-circuits past the charge-currency
* conversion, so here we can only assert the display currency is stamped DJF and the chain is intact.
*/
test("UA-3w: DJF WALLET booking is stamped DJF; reviewed (DJF) vs stored (ETB) diverge", async ({ page }) => {
const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "WALLET" });
expect(r.displayCurrency).toBe("DJF");
expect(r.confirmed).toBe(true);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.displayCurrency).toBe("DJF");
// Mirror image of UA-2: here the browser sent the DJF display value as the reviewed total…
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
// …while the booking stored the raw ETB base. The chain uses different currencies at each hop.
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
expect(booking.status).toBe("CONFIRMED");
});
/**
* UA-3 — Djiboutian/DJF paid via a forged gateway settlement. The real telebirr gateway is
* unreachable in the test env, so (per the matrix's settlement-injection plan) the booking is created
* through the real browser flow and settled by forging the payment.succeeded event. Proves the DJF
* booking reaches a CONFIRMED, ticketed state through the gateway (non-WALLET) path.
*/
test("UA-3: DJF booking settles through a forged gateway payment", async ({ page }) => {
const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "TELEBIRR" });
expect(r.displayCurrency).toBe("DJF");
expect(r.confirmed).toBe(true);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.displayCurrency).toBe("DJF");
expect(booking.status).toBe("CONFIRMED");
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-4 — one-way, 1 adult + 1 child under 5, ETB, WALLET. The "first child per adult" policy makes
* the child free: the booked total is exactly one adult fare and the free child is not seated.
*/
test("UA-4: first child under 5 travels free, total = one adult fare", async ({ page }) => {
const r = await bookTrip(page, { adults: 1, children: 1, paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
// The child is free → the browser sent one adult fare as the reviewed total.
expect(r.reviewedTotalMinor).toBe(r.cardBaseFareMinor);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
// The free first-child is filtered out of the booked passengers → only the adult is seated.
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
expect(seats.length).toBe(1);
});

View File

@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-5 — one-way, 1 adult + 2 children under 5, ETB, WALLET. One free child per adult: the first
* child is free, the second is charged a full adult fare. Total = 2 fares; 2 passengers are seated.
*/
test("UA-5: with 1 adult + 2 children, the second child pays full fare", async ({ page }) => {
const r = await bookTrip(page, { adults: 1, children: 2, paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
const fare = r.cardBaseFareMinor;
// adult (paid) + first child (free) + second child (paid) = 2 fares.
expect(r.reviewedTotalMinor).toBe(fare * 2);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(fare * 2);
// Only the free first-child is dropped → adult + paid second child are seated.
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
expect(seats.length).toBe(2);
});

View File

@@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
import { resultsUrl } from "../../fixtures/data";
/**
* UA-6 🔴 — round-trip return leg is UNBOOKABLE. A ROUND_TRIP search returns an inbound (C→A) leg
* that reports seat availability, but its `coachTypes`/`faresByClass` come back EMPTY: the fare engine
* cannot price the reverse direction on this route (the segment runs high→low stop sequence, so the
* distance resolves non-positive and every class is dropped). With no priced coach the portal renders
* no coach option, so the round-trip wizard cannot advance past inbound selection — the full-UI
* round-trip booking flow is blocked. This test pins the gap through the portal's own search call.
*
* When reverse-leg pricing is fixed (priced inbound coachTypes), replace this with the full
* two-leg booking flow: `bookTrip(page, { tripType: "ROUND_TRIP" })` asserting total = 2× base fare
* and one seat per leg.
*/
test("UA-6: round-trip inbound leg has availability but no priced coach (booking blocked)", async ({
page,
}) => {
const searchDone = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl({ tripType: "ROUND_TRIP" }));
const data = (await (await searchDone).json())?.data;
expect(data.journeyType).toBe("ROUND_TRIP");
const inbound = data.inbound?.[0];
expect(inbound).toBeTruthy();
// The return leg exists and shows availability…
expect(inbound.hasAvailability).toBe(true);
expect(Object.values(inbound.availabilityByClass ?? {}).some((n) => Number(n) > 0)).toBe(true);
// …but no coach type is priced, so nothing is selectable in the UI. 🔴
expect(inbound.coachTypes ?? []).toHaveLength(0);
expect(inbound.faresByClass ?? []).toHaveLength(0);
});

View File

@@ -0,0 +1,17 @@
import { test } from "@playwright/test";
/**
* UA-7 — round trip, berth/bed class, INTERNATIONAL/USD. DEFERRED (documented, not silently omitted).
*
* A berth booking needs a bed coach type whose seat classes carry a bedPosition the fare engine can
* price. The current seed has only regular (bedPosition=null) classes, and UA-6 already shows the
* reverse-leg (round-trip) pricing returns empty coach types on this route. Enabling UA-7 requires
* two backend/seed prerequisites that are out of scope here:
* 1. A bed CoachType + LOCAL/INTL SeatClasses with bedPosition IN (UPPER,MIDDLE,LOWER) + a bed
* Coach with lowercase-bedPosition Seats (matrix §5.4), priced by the fare engine.
* 2. Reverse-direction (return-leg) fare resolution, currently unsupported (see UA-6).
*
* Once both exist, drive: bookTrip with a bed seat class + tripType ROUND_TRIP, asserting the berth
* surcharge is applied consistently on both legs.
*/
test.skip("UA-7: round-trip berth booking (needs bed coach-type seed + reverse-leg pricing)", () => {});

View File

@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookOneAdult } from "../../fixtures/booking-flow";
import { PROMO_VALID } from "../../fixtures/data";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-8 ✅ — a VALID promo is applied server-side even though the browser drops it (H-13). The portal
* still sums UNDISCOUNTED per-passenger fares into reviewedTotalMinor, but the server recomputes the
* authoritative fare (promo included, via the promoCode it forwards) and books the DISCOUNTED total —
* so the customer is charged the promo price, not full price.
*/
test("UA-8: valid promo is applied server-side to the booked total (H-13)", async ({ page }) => {
const r = await bookOneAdult(page, {
nationality: "Ethiopian",
paymentMethod: "WALLET",
promoCode: PROMO_VALID,
});
const fb = r.fareBreakdown;
expect(fb).toBeTruthy();
// The breakdown recognized the promo and computed a discount…
expect(fb.discountMinor).toBeGreaterThan(0);
expect(fb.totalMinor).toBeLessThan(fb.subtotalMinor);
// The browser still sends the UNDISCOUNTED subtotal (the frontend drops the promo)…
expect(r.reviewedTotalMinor).toBe(fb.subtotalMinor);
// …but the SERVER now applies the promo: the booking is stored at the discounted total.
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBeLessThan(fb.subtotalMinor); // ✅ discount honored
expect(booking.totalMinor).toBe(fb.subtotalMinor - fb.discountMinor);
});

View File

@@ -0,0 +1,200 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { API_URL, SEAT_CLASS_LOCAL, STATIONS, resultsUrl, sampleDepartDate, staffToken, passengerToken } from "../../fixtures/data";
/**
* Track B — backoffice config → portal propagation. A staff user changes config via the same API the
* backoffice calls; the passenger portal is then observed. Each test restores what it changed so the
* shared seeded DB stays consistent for other specs.
*/
/** The "starting from" fare the portal shows for the seeded ETB trip (captured from POST /search). */
async function portalCardFareMinor(page: Page): Promise<number> {
const done = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl(), { waitUntil: "domcontentloaded" });
const body = await (await done).json();
return body?.data?.outbound?.[0]?.faresByClass?.[0]?.baseFareMinor;
}
/** The USD display fare the portal shows for a non-Ethiopian (Other) search. */
async function portalUsdFare(page: Page): Promise<number> {
const done = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl({ nationality: "Other" }), { waitUntil: "domcontentloaded" });
const body = await (await done).json();
return body?.data?.outbound?.[0]?.faresByClass?.[0]?.displayAmountMinor;
}
function authHeader() {
return { Authorization: `Bearer ${staffToken()}` };
}
/** Find a CurrencyExchangeRate row id by its currency pair. */
async function rateId(request: APIRequestContext, from: string, to: string): Promise<string> {
const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? [];
const row = rows.find((r: any) => r.fromCurrency === from && r.toCurrency === to);
if (!row) throw new Error(`no ${from}->${to} currency rate`);
return row.id;
}
test("PB-2: a backoffice seat-class base-price change propagates LIVE to portal search", async ({
page,
request,
}) => {
const before = await portalCardFareMinor(page);
expect(before).toBeGreaterThan(0);
// Staff doubles the base price via the API the backoffice tariff-rates form uses.
const patched = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 600 }, // seed-core seeds 300
});
expect(patched.ok()).toBeTruthy();
try {
const after = await portalCardFareMinor(page);
// No server-side config cache → the new price shows on the very next search.
expect(after).toBe(before * 2);
} finally {
await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 300 },
});
}
});
test("BC-11 ✅ a non-admin PASSENGER is forbidden from rewriting exchange rates (C-8)", async ({
request,
}) => {
// A global JwtGuard (SharedAuthModule) means anonymous requests get 401 — so this is NOT an
// unauthenticated hole. The PUT/PATCH handlers must ALSO carry @PassengerAdmin (as DELETE does) so
// a regular authenticated passenger cannot rewrite FX rates.
const anon = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999 },
});
expect(anon.status()).toBe(401); // authentication IS required
const asPassenger = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
headers: { Authorization: `Bearer ${passengerToken()}` },
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999, source: "E2E" },
});
expect(asPassenger.status()).toBe(403); // ✅ a regular passenger is forbidden (admin-only)
// The PATCH-by-id handler must be equally protected.
const patchAsPassenger = await request.patch(`${API_URL}/fare-engine/exchange-rates/${crypto.randomUUID()}`, {
headers: { Authorization: `Bearer ${passengerToken()}` },
data: { rate: 999 },
});
expect(patchAsPassenger.status()).toBe(403);
// A staff admin can still write (proves the endpoint isn't simply broken).
const asStaff = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
headers: { Authorization: `Bearer ${staffToken()}` },
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100, source: "E2E" },
});
expect(asStaff.ok()).toBeTruthy();
});
test("BC-7 ✅ negative seat-class base price is rejected by the live API (M-1)", async ({
request,
}) => {
const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: -500 }, // the API must now reject this (DTO @Min(0)), like the backoffice form
});
expect(res.status()).toBe(400); // ✅ negative fare rejected at the DTO layer
// The stored fare is unchanged — a valid write still succeeds and returns the seeded 300.
const restore = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 300 },
});
expect(restore.ok()).toBeTruthy();
expect(((await restore.json())?.data ?? {}).baseFareMinor).toBe(300);
});
test("PB-2b: base-price field-name — /seat-classes accepts `basePrice` and it drives the fare", async ({
request,
}) => {
// Documents which field the live seat-class endpoint reads (basePrice → baseFareMinor). If a future
// change renames it, this fails loudly (the tariff-rates vs /fleet/classes split, matrix §7 Q9).
const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 300 }, // no-op value, just asserts the field is accepted
});
expect(res.ok()).toBeTruthy();
const json = await res.json();
const updated = json?.data ?? json;
expect(updated.baseFareMinor ?? updated.basePrice).toBe(300);
});
test("PB-1: a backoffice FX-rate change propagates LIVE to portal USD pricing", async ({ page, request }) => {
const id = await rateId(request, "USD", "ETB");
const before = await portalUsdFare(page);
expect(before).toBeGreaterThan(0);
try {
// Doubling the USD→ETB rate doubles the fare-engine's ETB fare and therefore the USD display fare.
const patched = await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 200 } });
expect(patched.ok()).toBeTruthy();
const after = await portalUsdFare(page);
expect(after).toBe(before * 2); // search has no cache → the new rate shows immediately
} finally {
await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 100 } });
}
});
test("PB-4: a station added in the backoffice appears in the portal station list", async ({ request }) => {
const code = `E2E${Date.now() % 100000}`;
const created = await request.post(`${API_URL}/stations`, {
headers: authHeader(),
data: { code, name: `E2E Station ${code}`, city: "Testville", countryCode: "ET", sequence: 99, isOperational: true },
});
expect(created.ok()).toBeTruthy();
const id = ((await created.json())?.data ?? {}).id;
try {
const rows = (await (await request.get(`${API_URL}/stations`)).json())?.data ?? [];
expect(rows.some((s: any) => s.code === code)).toBe(true); // portal SearchWidget reads this list
} finally {
await request.delete(`${API_URL}/stations/${id}?cascade=true`, { headers: authHeader() }).catch(() => {});
}
});
test("PB-10 ✅ deleting an FX rate makes pricing FAIL CLOSED, not a silent 1.0 fallback (M-5/H-2)", async ({ request }) => {
const searchBody = {
originStationId: STATIONS.A,
destinationStationId: STATIONS.C,
date: sampleDepartDate(),
adultCount: 1,
nationality: "OTHER", // USD — the fare engine needs the USD↔ETB rate to price
};
const usdFaresByClass = async (): Promise<any[]> => {
const res = await request.post(`${API_URL}/search`, { headers: authHeader(), data: searchBody });
expect(res.ok()).toBeTruthy();
return (await res.json())?.data?.outbound?.[0]?.faresByClass ?? [];
};
// Control: with the USD→ETB rate present, the USD search returns a real priced fare.
const before = await usdFaresByClass();
expect(before.length).toBeGreaterThan(0);
expect(before[0].displayAmountMinor).toBeGreaterThan(0);
try {
// Remove EVERY USD→ETB rate row (an earlier spec may have left a duplicate) so the pair is truly gone.
const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? [];
for (const r of rows.filter((x: any) => x.fromCurrency === "USD" && x.toCurrency === "ETB")) {
await request.delete(`${API_URL}/currencies/${r.id}`, { headers: authHeader() });
}
// With the USD→ETB pair gone, the fare engine must NOT silently substitute rate 1.0 (~100×
// underpricing). It fails closed — no priced class is returned for the USD trip, instead of a
// bogus parity-priced fare. (A booking attempt would likewise be rejected, not swallowed.)
const after = await usdFaresByClass();
expect(after.length).toBe(0); // ✅ no silent underpricing — no bogus fare offered
} finally {
// Restore the pair so later specs price correctly.
await request.post(`${API_URL}/currencies`, {
headers: authHeader(),
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100 },
});
}
});