Files
edr-platform/e2e-ui

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.tsbookTrip(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-1…BC-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):

# 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 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:

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.