39 KiB
EDR Passenger Platform — Playwright UI E2E: Scenario Matrix + Phase 2 Harness Plan
Phase 1 synthesis (MAPPING ONLY). Consolidates the four mapping passes (portal booking, backoffice config, API/network contracts, auth+seed gaps) plus the adversarial review into a reviewable plan. Ties every scenario to an existing finding in docs/ISSUES.md (C-/H-/M-/L-) and docs/e2e-test-matrix.md (Suites A–K). No tests written, no code changed, stack not run.
Two framing facts inherited from Phase 1: (a) fare-engine is the live pricing pipeline; configurable-fare is dormant. (b) The domain seed (prisma/seed.ts) is disabled — the harness must build fixtures. Two blockers discovered this pass that flip several scenarios from "green/repro" to "invalid as written": (1) the portal never applies promo discounts to the booked total (§2 note), and (2) both web apps have ZERO data-testid (grep -rn data-testid src → 0 in both). Section 6 is the prerequisite testid checklist.
Ports: portal 5174, backoffice 5184, passenger-api 4000 (bare paths, no /v1 except IAM /v1/auth/*), payment-api 3003 (/webhooks/*). Test DB port 5544 (.env.test).
1. Master assertion recipe — capture price at every hop
Each UI test drives the browser but asserts the money chain via (a) Playwright network interception (page.route / page.waitForResponse), (b) direct DB reads against the 5544 test DB (Prisma client or SQL), and (c) DOM text assertions on rendered price nodes. The master invariant (matrix "Suite A"), trimmed to links that actually have backing in the maps:
portal card price (displayAmountMinor)
── [BREAK] on-select stored fare == Math.min(baseFareMinor) (results/page.tsx:320) ──
== /search/fare-breakdown displayFareMinor
== review computedTotal (reviewedTotalMinor sent, review/page.tsx:587)
== Booking.totalMinor/displayTotalMinor
== PaymentIntent.amountMinor
== charged amount (WALLET debit OR gateway webhook)
Removed from the stated invariant (over-claimed):
loyalty accrual— no §1.2 DB read captures aLoyaltyAccount.pointsBalanceincrement and no green row asserts accrual vs price; andrefund basis— there is no refund endpoint anywhere in the API map and no refund scenario. If a loyalty-accrual assertion is wanted, add aLoyaltyAccount.pointsBalanceread to a green WALLET row (§1.2) and re-add only that link. Refund is out of scope until a refund surface is mapped (see §7).
1.1 Network interception targets (exact method + path, in flow order)
| Hop | Method + Path (passenger-api :4000) | Capture for assertion | Source |
|---|---|---|---|
| Fayda gate | GET /config/fayda-status (confirm prefix — see §5.2) |
enabled — must be false to expose manual passenger form |
system-config.controller.ts:12,16 |
| Stations load | GET /stations |
station list (search inventory) | portal search page.tsx:606 |
| Search | POST /search |
body {originStationId,destinationStationId,date,adultCount,childCount,nationality,journeyType,returnDate?}; resp outbound[].coachTypes[].classes[].{displayAmountMinor,baseFareMinor} |
results/page.tsx:234; search.controller.ts:13 |
| On-select stored fare | (client-side, no request) | minFare = Math.min(...classes.map(c => c.baseFareMinor)) — baseFareMinor, NOT the card's displayAmountMinor; diverges for USD/DJF and flows downstream as baseFareAdult |
results/page.tsx:320,821 |
| Promo (URL-injected) | POST /promos/validate {code} — note: no in-portal "apply promo" input; promo enters via ?promoCode= → searchCriteria.promoCode |
discount echo (does NOT reach booked total, see §2) | results/page.tsx:142 |
| Save passengers | POST /passengers/save-details |
{passengers[],userId,deviceId} |
passengers/page.tsx:1062 |
| Seatmap | GET /seats/seatmap/{scheduleId}?coachTypeId=&journeyDirection= |
seat fares (displayAmountMinor??baseFareMinor) |
seats/page.tsx:352 |
| Hold | POST /seats/hold {scheduleId,origin,dest,journeyDirection,passengers:[{passengerId,seatId}]} |
resp {holdId,expiresAt} — capture expiresAt for PB-9 |
seats/page.tsx:617; seats.controller.ts:164 |
| Fare breakdown | GET /search/fare-breakdown?scheduleId=&...&passengers=<JSON>&displayCurrency=[&promoCode] |
resp per-pax {fareMinor,displayFareMinor,isFree} (undiscounted) + separate top-level discountMinor/totalMinor (ignored by portal) |
review/page.tsx:550,559,574; search.service.ts:906-975 |
| Create booking | POST /bookings (auth) or POST /bookings/guest |
body reviewedTotalMinor (undiscounted per-pax sum), per-pax seatFareMinor; resp {bookingId/pnr,totalMinor} |
review/page.tsx:209,394,457; bookings.controller.ts:364/181 |
| Booking amount | GET /payments/booking-amount?bookingId=¤cy= |
resp {amount (MAJOR, plain /100), currency} — portal ×100; currency is driven by the PaymentMethod.currency, not the booking |
payment/page.tsx:68,73 |
| Initiate | POST /payments/initiate {bookingId,method,paymentMethodId,payerAccount?,platform} |
resp clientAction{type,url} and merchantOrderId (required to key the forged webhook) |
payment/page.tsx:123,149; payments.controller.ts:108 |
| Confirm (CAC) | POST /payments/{bookingId}/confirm {otp} |
— | payment/page.tsx:174 |
| Poll intent | GET /payments/intents/{bookingId} |
status transitions | confirmation/page.tsx:125 |
| Ticket | GET /bookings/{bookingId} |
{status,totalMinor,payment:{amountMinor,currency},tickets[].barcodePayload} |
confirmation/page.tsx:105 |
Payment methods are DB-driven and must be seeded. The portal renders only PaymentMethod rows where enabled=true (payment/page.tsx:593); getSupportedPaymentMethods returns enabled rows from the DB (payments.controller.ts:273). seed-core.ts seeds none → the pay page is empty and every Track A row (WALLET included) hangs before paying. See §5.4.
WALLET path (fully offline, no payment-api/webhook): POST /payments/initiate {method:"WALLET"} short-circuits server-side to finalizePaymentSuccess, debiting booking.totalMinor directly (payments.service.ts:461-523). Best UI settlement path for green tests. Note: WALLET produces no edr_payment.payment_intent and bypasses the charge-currency conversion — so the DJF whole-franc rounding is not observable here (see UA-3/UA-17, §2).
Settlement injection for gateway tests (no real gateway):
- Forge webhook to payment-api :3003 —
POST /webhooks/telebirror/webhooks/dmoney(bothsignatureValid=truehardcoded) withmerch_order_id = <captured merchantOrderId>,trade_status=success. Card/Waafi require valid HMAC — avoid. A TELEBIRR initiate returnsclientAction REDIRECTand the portal doeswindow.location.href = url(payment/page.tsx:149) → the test mustpage.route-abort that navigation to the non-existent gateway, forge the webhook, then drive to/booking/confirmation. - Direct internal —
POST /internal/payments/mark-paidon :4000 with{version:1,eventType:"payment.succeeded",service:"PASSENGER",referenceType:"BOOKING",referenceId:<bookingId>,...}.ServiceAuthGuardreturns true whenSERVICE_AUTH_TOKENunset (dev). Fastest deterministic settlement — but it will not reproduce a late webhook race (C-5, see UA-15) nor the charge-currency conversion (DJF, see UA-3).
1.2 DB reads to assert (test DB 5544)
- passenger.Booking (schema.prisma:510):
totalMinor(:520),currency(:519),displayCurrency(:523),displayTotalMinor(:524),status(:518 →"CONFIRMED"on settle, payments.service.ts:846),paidAt(:550),bookingType,returnLegStatus. - passenger.BookingSeat:
fareMinor,displayCurrency,displayFareMinor(:597-599). - passenger.PaymentIntent (:621):
amountMinorFloat (:624 — assert numeric, not int-exact),currency,status,method,merchantOrderId(unique),paidAt. - edr_payment.payment_intent (payment-api source of truth):
amount_minor/confirmed_amount_minordouble precision (migration 1782000000000). Assert as numeric. Scope: gateway rows only (UA-15) — WALLET creates no payment-api intent. - WALLET extras:
WalletLedgerEntryDEBIT oftotalMinorw/relatedBookingId;WalletAccount.balanceMinordecremented; ticket row /GET /tickets/{bookingRef}.
1.3 Currency-formatting assertion (the L-1 target)
Portal renders every price through formatFare(amountMinor, code) = `${code} ${(amountMinor/100).toFixed(2)}` (fare-utils.ts:86) — always /100, always 2 decimals. So DJF renders DJF 1234.56. Whole-franc rounding lives on the charge conversion (currency.service.ts:9-13, CHARGE_CURRENCY_DECIMALS; payments.service.ts:223-298), which WALLET short-circuits past.
- ETB / USD: assert DOM shows 2dp; assert
renderedMajor*100 == amountMinor. - DJF (WALLET): can only assert the shape mismatch — DOM shows 2dp (
DJF x.yy) whileGET /payments/booking-amountreturns whole-franc-less major via plain/100. No settled 0-decimalamount_minorexists on this path. - DJF (gateway / forged-telebirr): the real L-1 settle-side repro — assert DOM 2dp vs the charge-currency-converted, whole-franc
amount_minor/confirmed_amount_minor. UA-3/UA-17 must route here to observe it.
2. TRACK A — Booking combinations matrix (pruned cross-product)
Axes: booking type {one-way, round-trip} × pax mix {1A, 2A, 1A+1C-free, 1A+2C (1 free/1 paid), 2A+3C} × class/berth {Economy Regular, Economy Bed} × nationality/currency {Ethiopian→ETB / LOCAL, Djiboutian→DJF / LOCAL, Other→USD / INTERNATIONAL} × promo {none, %valid, expired} × payment {WALLET, forged-telebirr}. Pruned to meaningful, finding-bearing rows.
PROMO REALITY (blocking correction).
GET /search/fare-breakdownreturns undiscounted per-paxdisplayFareMinorand puts the discount only in separate top-leveldiscountMinor/totalMinor(search.service.ts:906-975). The portal review page ignores that top-level total and client-reduces the per-pax fares (review/page.tsx:587), sending that undiscounted sum asreviewedTotalMinor. The (guest) booking service then overrides its own discounted total withreviewedTotalMinorwhen>0and clamps its fallback withMath.max(0,…)(guest-booking.service.ts:240-244,487,536-537,744). Consequences: through the browser, a valid promo is silently dropped andBooking.totalMinor= full price, and a negative total is not reproducible via UI. Promo enters only via?promoCode=URL param (no selector); lookup isfindUnique({where:{code}})(search.service.ts:941) — seed codes must be unique and exact. Whether the authed/bookingspath shares the same override+clamp is unverified (§7).
| ID | Scenario | Key inputs | Price cross-check expectation | Finding tie-in |
|---|---|---|---|---|
| UA-1 | One-way, 1 adult, Economy Regular, Ethiopian/ETB, WALLET, no promo | ETB LOCAL regular class | Baseline green: card price == fare-breakdown == reviewedTotalMinor == Booking.totalMinor == PaymentIntent.amountMinor == wallet DEBIT. All equal, 2dp. (Optionally assert LoyaltyAccount.pointsBalance accrual here if the accrual link is kept.) |
matrix A6 (baseline) |
| UA-1b | One-way, 1 adult, Other/USD — assert card displayAmountMinor vs internal baseFareMinor |
nationality OTHER → USD; INTL class | ✅ FIXED — the card shows the USD fare (displayAmountMinor), the internal baseFareMinor is the ETB source it was converted from (rate apart, coherent). The portal now carries the USD value forward (results/page.tsx on-select uses displayAmountMinor, aligning with the already-USD seats-page logic). |
div #1; H-3/H-4 |
| UA-2 | One-way, 1 adult, Other/USD, INTERNATIONAL Regular, WALLET | OTHER → displayCurrency USD | ✅ FIXED — money chain COHERENT: displayCurrency=USD/displayTotalMinor = what the passenger saw (=reviewedTotalMinor); currency=ETB/totalMinor = the ETB charge basis (=displayTotalMinor × rate). The prior currency:USD-on-an-ETB-amount mislabel is gone. |
H-3/H-4, matrix B3 |
| UA-3 | One-way, 1 adult, Djiboutian/DJF, forged-telebirr | DJIBOUTIAN → DJF; gateway path | DJF displayed 2dp (DJF x.yy) but charged whole-franc — assert DOM-2dp vs settled amount_minor (0-decimal). Must be a gateway row (WALLET bypasses the charge conversion). |
L-1 ✅, matrix C6/K3 |
| UA-3w | One-way, 1 adult, Djiboutian/DJF, WALLET (shape-only) | DJF, WALLET | Assert only the DOM-2dp vs booking-amount-major shape mismatch (no settle-side rounding on WALLET). |
L-1 (partial) |
| UA-4 | One-way, 1A + 1 child ≤5yr (free), ETB, WALLET | childCount 1, DOB<5yr | Child shows "CHILD - FREE"; free child excluded from total; fare-breakdown.isFree==true agrees with client fare-utils.isFirstChild |
H-12, matrix B6 |
| UA-5 | One-way, 1A + 2 children (first free, second paid), ETB, WALLET | childCount 2 | Second child paid; client reduce (review:587) == breakdown sum; assert booking vs quote free-child count agree (quote uses min(child,adult); booking uses child-1) | H-12, matrix B6 |
| UA-6 | Round-trip, 1 adult, Economy Regular, ETB, WALLET | ROUND_TRIP, outbound+inbound holds | ✅ FIXED — the fare engine now prices the reverse (C→A) leg by absolute distance (was: threw "origin must come before destination", leaving the inbound leg with seats but no priced coach → unbookable). Full two-leg flow completes: 2 seats (one per leg), total = 2× the one-way fare. | div #6; matrix A3 |
| UA-7 | Round-trip, 2 adults, Economy Bed / berth, INTERNATIONAL/USD, WALLET | bed seat-class; berth seats (bedPosition) |
Berth priced as separate class; getSeatFare bedPosition match (seats:433) == breakdown; INTL berth surcharge consistent |
requires berth seed (§5); matrix B3 |
| UA-8 | One-way, 1 adult, ETB, valid % promo via ?promoCode=, WALLET |
valid percentOff:10 in URL |
✅ FIXED — the browser still sends the undiscounted reviewedTotalMinor, but the authed bookings.service recomputes the authoritative fare and applies the promo, so Booking.totalMinor = subtotal − discount. |
H-13 fixed & guarded; matrix D |
| UA-11 | One-way, 1 adult, ETB, expired promo (validUntil past, active:true) via URL, WALLET | expired code | Promo rejected/ignored; total unaffected; UI shows no discount (consistent with UA-8 drop). | matrix D5 |
| UA-13 | One-way, 1 adult, ETB, client-forged low total (intercept POST /bookings, rewrite reviewedTotalMinor:1 + every seatFareMinor:1) |
mutate body via page.route |
✅ FIXED — server recomputes the authoritative fare and REJECTS the underpayment (4xx); no booking persisted | C-1 fixed & guarded, matrix A1 |
| UA-14 | One-way guest, forged per-pax seatFareMinor:0 (+ reviewedTotalMinor:0) |
intercept /bookings/guest |
✅ FIXED — server recomputes the authoritative fare and REJECTS the free-ride underpayment (4xx); no booking persisted | C-1 fixed & guarded, matrix A2/A4 |
| UA-15 | One-way, 1 adult, ETB, forged-telebirr short-pay | booking total in the thousands; forge /internal/payments/mark-paid success with amountMinor:1 |
✅ FIXED — the server compares the settled amount to the booking's display total and REFUSES a short payment; booking stays unconfirmed, no ticket | C-4 fixed & guarded, matrix G1/G7 |
| UA-16 | Round-trip, 2A+3C, mixed, ETB, WALLET | max pax spread | Stress free-child + per-leg split + total reduce; every backed hop equal | H-12, div #6 |
Moved to the API-level harness (no valid browser path):
- UA-9 / UA-10 / UA-17 — over-100%
percentOff:150, fixedamountOffMinor > subtotal, DJF×promo negative total. Not reproducible via UI:reviewedTotalMinoris positive-undiscounted and the server clamps to 0 (guest-booking.service.ts). Keep as API-only for H-1. - UA-12 — loyalty over-redeem (C-2). No browser path:
grep loyalty|redeemacrossportal/src/app/booking/**+booking-store.ts→ zero hits;loyaltyRedemptionPointsexists only onPOST /search/fare-quote, which the portal never calls (it uses fare-breakdown, no loyalty param). Keep as API-only.
Payment method: default all rows to WALLET (deterministic, offline). UA-3 and UA-15 use forged-telebirr. Rows tagged ✅ have an existing API-level repro in
docs/ISSUES.md; the UI test proves the defect surfaces through the real browser flow (closing the "Suite K not yet run" gap, ISSUES.md L285-290).
3. TRACK B — Config→portal propagation matrix
Each row: change made in backoffice UI (:5184) → API write → portal read (:5174) → propagation + staleTime → predicted finding. Backoffice self-refreshes immediately (each mutation invalidates its own React-Query key). Staleness only bites the portal.
| ID | Config change (backoffice UI) | Write endpoint | Portal read path | Propagation + staleTime | Predicted |
|---|---|---|---|---|---|
| PB-1 | /currencies → edit ETB↔USD rate |
PATCH /currencies/{id} {rate} |
GET /currencies via useCurrencies.ts:19 |
staleTime 5min — up to 5 min stale in portal | 🟠 matrix I1; ties H-2/H-3 |
| PB-2 | /tariff-rates Tab1 → edit seat-class base |
PATCH /seat-classes/{id} {basePrice} (field basePrice) |
next POST /search (staleTime:0) + GET /search/fare-breakdown |
Live, no cache | 🟢 matrix I2/K4; field-name split (basePrice vs baseFareMinor) — verify which fare-engine reads (§7) |
| PB-3 | /tariff-rates Tab2/3 → route/segment fare override |
POST /schedules/routes/{routeId}/fare-rules / POST /schedules/segment-fares |
POST /search results |
Live | 🟠 segment rule may not bite: engine matches dto.nationality or null; seeder writes 'LOCAL'/'INTERNATIONAL' → won't match (§5 note); matrix B5 / H-11 |
| PB-4 | /stations → add station |
POST /stations |
GET /stations (SearchWidget staleTime 60s; root prefetch raw fetch) |
≤60s stale in SearchWidget; prefetch uncached | 🟠 matrix K5 |
| PB-5 | /stations → disable station (isOperational=false) |
PATCH /stations/{id} |
GET /stations (portal passes no operational filter) |
Only disappears if API omits non-operational server-side — verify (§7) | 🔴/🟠 matrix K5/I3 |
| PB-6 | /classes → create seat class |
POST /fleet/classes {baseFareMinor,...} (field baseFareMinor, different endpoint than PB-2) |
POST /search + seats page |
Live (search staleTime:0) | 🟠 two seat-class stores (/seat-classes vs /fleet/classes) — confirm which live search reads (§7) |
| PB-7 | /promos (URL, nav commented) → create promo |
POST /promos |
POST /promos/validate {code} at results:142 |
On demand | 🔴 field-name mismatch: UI sends discountType/discountValue/isActive; DTO expects percentOff/amountOffMinor/active → possibly inert promo. Verify; own finding. matrix K6 |
| PB-8 | /schedules → create schedule for search date |
POST /schedules |
POST /search |
Live | 🟢 must satisfy all 9 searchability rules (§5); matrix I2 |
| PB-9 | /settings → change seat-hold TTL |
PATCH /config (raw, no RQ, no invalidate) |
no portal read path — config is ignored by the hold | Server-side runtime | 🔴 reframed: capture expiresAt from POST /seats/hold and assert it does NOT track the config value (hold uses a fixed TTL — reconcile 15-min seats.service.ts:~272 vs the "20-min" claim). G6 |
| PB-10 | /currencies → delete a rate pair |
DELETE /currencies/{id} |
POST /search (USD/Other) faresByClass |
✅ FIXED — getExchangeRate fails closed (throws) instead of substituting 1.0; the USD search returns NO priced class (no bogus ~100×-underpriced fare), and a booking would be rejected too |
M-5 / H-2 fixed & guarded, matrix I6 |
Deferred config surfaces (mapped, out of Phase 2 scope — stated so the matrix doesn't read as complete): /fare-management (schedule-scoped FareRule, fare-source #3), /pricing (/admin/segment-fares, the dead-@Roles route), and /routes fare-rule CRUD beyond PB-3.
4. HIGH-VALUE bug-class scenarios (concrete steps)
4A. Config-mid-flight (edit/disable between quote and pay) — matrix I7
- BC-1: Portal: search → results → select → hold →
/booking/review(fare frozen). Second (backoffice) context:PATCH /seat-classes/{id}to triple the base. Back in portal: Confirm. Assert booking created at the frozen review price (reviewedTotalMinor), not the new one — booking never re-quotes; payment never re-validates. (ties C-1/L-2) - BC-2: Same, but disable the station mid-flight (PB-5). Assert the in-flight booking still completes (no re-validation of station operational state).
4B. Delete-referenced (M-5 / matrix I3–I6)
- BC-3: Create a CONFIRMED booking (UA-1). Backoffice
/stations→ delete the origin station (accept cascade if FK 400 offered). Assert either a referential block OR an orphaned booking (GET /bookings/{id}resolves but station lookups break).stations.service.ts:110-137ignores bookings. - BC-4:
/classesdelete a seat-class referenced by a booking'sbookingSeat. Assert orphan/FK behavior. matrix I4. - BC-5:
/currenciesdelete the USD↔ETB pair with active INTL fares. Next portal INTL search → fare collapses ~100× (1.0 fallback). H-2, matrix I6. - BC-3b (new):
/routes→ delete a route referenced by a live schedule; assert orphaned schedule vs referential block.routes.controller.ts. - BC-4b (new):
/schedules→ cancel a schedule with a CONFIRMED booking; assert whether the booking is stranded. (Both new rows may be explicitly deferred if Phase 2 scope is tight.)
4C. Staleness (matrix I1)
- BC-6: Backoffice edit ETB↔USD rate. Immediately do a portal USD search → assert portal may show the OLD rate (useCurrencies staleTime 5×60×1000). Then force a reload / navigation / window-focus to trigger the refetch (React-Query
staleTimedoes NOT auto-refetch on its own), and assert the new rate. Distinguishes the 5-min window from live search pricing.
4D. Validation-via-UI vs direct-API (Suite H — direct-API bypass class; UI proves the client gaps)
- BC-7 ✅ FIXED:
PATCH /seat-classeswithbasePrice:-500is now rejected with 400 —CreateSeatClassDto.basePrice(andinsuranceFeeMinor) carry@Min(0), applied to updates viaPartialType. M-1, matrix H1/H2. - BC-8 ✅ FIXED:
POST /promoswithpercentOff:200is now rejected with 400 —CreatePromotionDto.percentOffcarries@Min(0) @Max(100)(andamountOffMinor@Min(0)). A valid ≤100% promo still succeeds. M-2, matrix H4. - BC-9 ✅ FIXED:
PATCH /config {seat_hold_duration_minutes:"-1"}is now rejected with 400 — a whitelistedUpdateSystemConfigDtocoerces each known key to a positive integer (seat_hold_duration_minutesbounded 1..60). A sane value still stores. M-3, matrix H7. - BC-10 ✅ FIXED:
POST /scheduleswith a pastdepartureAtis now rejected with 400 —schedules.service.createScheduleguardsdepartureAt >= nowalongside the existingarrival > departurecheck. A future schedule still creates. M-4, matrix H3. - BC-11 ✅ FIXED:
PUT/PATCH /fare-engine/exchange-ratesnow carry@PassengerAdmin()(as DELETE already did). Anon → 401, regular passenger → 403 forbidden, staff admin → 200. C-8, matrix J1.
5. PHASE 2 harness plan
5.1 playwright.config.ts structure
e2e-ui/ # new; sibling to existing e2e/ (API harness)
playwright.config.ts
global-setup.ts # boot+await stack (VERIFAYDA_ENABLED=false), seed, mint storageStates
fixtures/
storage/passenger.json # generated by global-setup
storage/staff.json # generated by global-setup
seed-ui.ts # domain fixtures (see 5.4)
specs/
portal/*.spec.ts # Track A (UA-*), BC-1/2/6
backoffice/*.spec.ts # Track B config CRUD
propagation/*.spec.ts # BC-3..BC-11 cross-app
- projects:
portal(baseURLhttp://localhost:5174, storageStatepassenger.json),backoffice(baseURLhttp://localhost:5184, storageStatestaff.json), plus aguestproject (no storageState) for guest rows (UA-14). Pinviewportper project — portal desktop layout ishidden md:block; mobile diverges heavily. One sharedglobalSetup. webServer: optionally let Playwright start portal+backoffice (pnpm --filter @edr/passenger-portal devetc.); reuseExistingServer in local dev.
5.2 global-setup
- Ensure Postgres :5544 up and migrated (
.env.test,JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000…). - Boot passenger-api :4000 (with
VERIFAYDA_ENABLED=falseso the portal exposes the manual passenger form — otherwise Fayda defaults ON and every booking flow is blocked; the flag isenabled = process.env.VERIFAYDA_ENABLED !== 'false', system-config.controller.ts:12,16) and payment-api :3003 (or assert reachable). Await/health-style ping. Confirm whichfayda-statusprefix the portal hits (/configvsfare-engine.controller.ts:65, which defaultsfalse) so the right flag is set. - Run
seed-core.ts+ newseed-ui.ts(§5.4). - Mint the two storageStates (§5.3), write to
fixtures/storage/.
5.3 The two storageState fixtures (grounded in auth map)
The passenger-API JwtGuard is DB-backed against iam.sessions — a fake JWT 401s. JWT payload is { id: <sessionId> } (NOT userId); roles/permissions live in the session's userInfo jsonb.
Passenger storageState (portal :5174) — no server gate, but /auth/profile runs on load and self-ejects on 401:
- Insert
iam.users(individual, active). - Insert
iam.sessions(status='ACTIVE', future expiry,userInfo.roles=[]). - Insert Prisma
Passenger{iamUserId}+LoyaltyAccount+WalletAccount(funded balanceMinor) +UserPreferences— required orgetProfilethrows "Passenger not found" (passenger-auth.service.ts:262) and the portal logs out. - Mint JWT
{id: sessionId}withJWT_ACCESS_TOKEN_SECRET. - Write storageState
localStoragefor origin :5174:auth_token=<jwt>,auth_user=<profile JSON matching getProfile shape>. - Simplest alternative: drive real
POST /auth/loginonce with a seeded passenger, snapshot localStorage.
Staff/admin storageState (backoffice :5184) — server middleware requires the auth_token cookie; API staff calls require userInfo.roles carrying super_admin/organization_admin or the right permission keys:
- Path A (robust): set
SEED_EDR_PASSENGER_ORG=true+SEED_PASSENGER_STAFF=true, boot API → seeds orgedr, roles, users (passenger.admin@edr.local/Test@1234). ThenPOST /v1/auth/login→GET /v1/auth/me, snapshotlocalStorage(auth_token,auth_user,auth_refresh_token) and setauth_tokencookie. - Path B (fast): insert
iam.users+iam.sessionswithuserInfo.roles=[{key:'super_admin'}], mint JWT, write storageState localStorage +auth_tokencookie for :5184,auth_userwithisSuperAdmin:true. Config pages don't usePermissionGuard— only middleware cookie + API guards matter. - Note: the
auth_tokencookie is host-scoped (localhost), not port-scoped, so it is also sent to the portal origin. Harmless (portal reads localStorage, not this cookie) but relevant if a single shared browser context is reused across projects.
5.4 Seed extensions (add to seed-core.ts or new seed-ui.ts)
seed-core.ts today has CoachType×1, SeatClass×2 (LOCAL 300 / INTL 500, both regular), Station×3 (A/B/C), Route×1 + 3 RouteStop (0/100/250km), 4 FX rows. No Train/Schedule/Coach/Seat/Passenger/PaymentMethod. Add:
- PaymentMethod rows (BLOCKING — pay page is empty without them): at minimum an enabled
WALLET(currency ETB) and an enabledTELEBIRR(for UA-15). The method's.currencydrivesbooking-amountand the displayed pay total (payment/page.tsx:68), so a DJF booking paid by an ETB wallet renders ETB on the pay page — relevant to UA-3/UA-3w. - Bookable trip (all 9 searchability rules):
Train×1 →TrainSchedule(A→C,status:'SCHEDULED',isPackageOnly:false,departureAt = now+2d, whole-day in Addis TZ, >30min ahead) → 3TripStopTime(A/B/C seq 1/2/3, futureplannedDepartureAt) →Coach×1(status:'ACTIVE') →CoachAssignment(isOperational:true) →Seat×N (AVAILABLE, non-emptyseatNumber,bedPosition:null). Fares resolve viaSEAT_CLASS_BASE_FAREdistance formula with the existing USD→ETB row — no fare-rule rows needed for the green path. - Seat-class names (pin exactly): the review flow builds a
seatClassName → seatClassIdmap fromGET /seat-classes(review/page.tsx:277) and fare-quote expects exact names"Economy Regular"|"Economy Bed"(search.dto.ts). SetSeatClass.nameto the exact client strings, or those rows won't resolve. (The axis's "VIP Bed" has no seed/scenario — seed it or drop it from the axis; this matrix drops it.) - Berth combos (UA-7): LOCAL+INTL SeatClasses with
bedPosition IN ('UPPER','MIDDLE','LOWER')+ a bedCoach+Seats with lowercasebedPosition:'upper'|'middle'|'lower'. - Promotions (UA-8/11): valid
percentOff:10; expired (validUntilpast,active:true). Use schema field namespercentOff/amountOffMinor/active— NOT the backoffice UI field names. Pin exact, uniquecodevalues (lookup isfindUnique({where:{code}}), search.service.ts:941); tests navigate with?promoCode=<code>. (Over-100% / over-subtotal promos belong to the API harness, not Track A.) - BaggageAllowance ×1 per seat class (for excess-baggage rows).
- Passenger satellite for logged-in/WALLET:
Passenger{iamUserId}+ fundedWalletAccount(balanceMinor)+LoyaltyAccount. - Blocked-seat negative case: one
SeatBlockrow. - Segment override that actually bites (PB-3): seed
SegmentFareRulewithnationality:null(engine matchesdto.nationalitystring or null; 'LOCAL'/'INTERNATIONAL' rows won't match a real search). - FX: existing 4 rows suffice for ETB/USD/DJF via ETB pivot; add
USD↔DJFonly if a direct-path currency test needs it.
5.5 Two smoke tests
- Portal smoke (
guestproject): home → search (seeded A→C, date = Addis date ofdepartureAt) → results shows ≥1 card with a price →formatFarerendersETB N.NN. Asserts stack+seed+search+Fayda-flag wired. - Backoffice smoke (
backofficeproject): staff storageState →/currenciesloads list → open "Add Rate" modal. Asserts staff auth (cookie+localStorage+API token) all valid.
5.6 pnpm scripts + turbo task
- Root
package.json:"test:e2e:ui": "playwright test -c e2e-ui/playwright.config.ts". - turbo
test:e2e:uitask"cache": false; global-setup owns boot/seed. Single command:pnpm test:e2e:ui. - Specs under
e2e-ui/specs/{portal,backoffice,propagation}.
6. SELECTORS TO ADD — data-testid checklist (PREREQUISITE; both apps have 0 today)
Without these, every locator hangs off role/text/name=/placeholder, which is brittle across the portal's mobile/desktop breakpoint split. Recommend adding these before authoring (out of scope this phase; flag for user approval). Promo has no selector — it enters via ?promoCode= URL param.
Portal (apps/edr-passenger-web/portal/src)
- Search:
search-trip-type-oneway/-roundtrip(page.tsx:777/789),search-origin-input(:1234),search-dest-input(:1274),search-swap(:1261),search-depart-date(:1305),search-return-date(:1486),search-pax-trigger(:1334),pax-adult-plus/-minus,pax-child-plus/-minus(PassengerModal:319/330),nationality-eth/-dji/-other(:352),search-submit(:1362). - Results:
result-card(per schedule),result-card-price(:821 — "starting from"),result-select-btn(:831),coach-option(:487),coach-class-price(:609),continue-passenger-details(:642),modify-search(:1282). - Passengers:
pax-name-{i},pax-dob-btn(:327),pax-gender,pax-nationality,pax-phone,pax-passport,verify-fayda-btn,enter-manually-toggle(:1003),create-account-checkbox,passengers-continue. - DOB picker (
DobPickerModal— required for UA-4/UA-5 free-child):dob-cal-etgc-toggle(:346),dob-manual-toggle(:354),dob-manual-day/-month/-yearinputs,dob-day-cell-{n},dob-confirm. - Seats:
seat-cell-{label}(SeatButton:119),berth-cell-{label}(BedCard:38),passenger-tab-{i},auto-assign-seats(:2006),:1989),seats-continue(fare-change-confirm(CustomModal). - Review:
review-total(:683 desktop / :1031 mobile),review-pax-fare-{i}(:662),review-outbound-line/-return-line(:670/674),review-child-badge(:657),confirm-and-pay(:694),seat-hold-timer(:719). - Payment:
pay-method-{type}(:597),pay-total(:390 / :651 mobile),pay-submit(:406),cac-phone-input(:488),cac-otp-input(:531). - Confirmation:
confirmation-pnr(:404),confirmation-status(:615),confirmation-total-paid(:631),ticket-number-{i}(:659),download-voucher(:794),book-another(:815).
Backoffice (apps/edr-passenger-web/backoffice/src)
- Login:
login-email(:165),login-password(:189),login-submit(:221). - DataTable / dialogs (shared):
add-entity-btn(ActionButton),row-edit-{id},row-delete-{id},confirm-dialog-confirm,confirm-cascade-checkbox,modal-submit. - Tariff Rates (
/tariff-rates):tab-seatclass/tab-route/tab-segment/tab-baggage; RateModal fields already havename=(name,baseFareMinor,insuranceFeeMinor/surchargeMinor,isActive) — addtestidon submit + modal. - Currencies (
/currencies): controlled form (noname=) — addcurrency-from,currency-to,currency-rate,currency-save,currency-edit-rate. - Classes (
/classes): FormData hasname=(coachTypeId,name,baseFareMinor,insuranceFeeMinor,isActive) — add submit testid. - Schedules (
/schedules): controlledaddForm/DateTimePicker— addschedule-train,schedule-route,schedule-departure,schedule-arrival,schedule-status,schedule-save,schedule-cancel-btn. - Stations (
/stations): FormDataname=present — add submit testid. - Settings (
/settings): realid=(hold-duration,hold-cutoff,boarding-window, throttle-*) — usable, but addconfig-savetestid. - Promos (
/promos, URL-only): FormDataname=present — add submit + note field-name mismatch (PB-7).
7. OPEN QUESTIONS / RISKS (decide before Phase 2)
- Valid IAM token for storageState — Path A (real
/v1/auth/loginafter enablingSEED_EDR_PASSENGER_ORG+SEED_PASSENGER_STAFF) vs Path B (directiam.sessionsinsert withuserInfo.roles=[{key:'super_admin'}]+ self-signed JWT). Recommend Path A for staff, Path B acceptable for passenger. Confirm. - Seed
iam.sessionsvs dev bypass — there is no dev auth bypass in the passenger-APIJwtGuard(DB-backed, no env short-circuit). A session row is mandatory for any authenticated flow. Confirm we may write directly toiam.sessionsin the test DB. - Target DB / stack — resolved: dev is the single schema-separated database at
localhost:5432/edr_database(?schema=passengerfor Prisma), provisioned byinfrastructure/docker/docker-compose.db.dev.yml. The harness deliberately does NOT use it — it stands up its own hermetic Postgres on 5544 (e2e/docker-compose.yml,.env.test) and boots both APIs against that. - Stack-startup reliability — global-setup must boot passenger-api (:4000) + payment-api (:3003) + portal (:5174) + backoffice (:5184) + RabbitMQ (vhost
payment), or route settlement through/internal/payments/mark-paidto avoid RabbitMQ. Recommend the internal-endpoint path for green settlement determinism — but note it will not reproduce a late-webhook race (C-5) nor the charge-currency conversion (DJF, UA-3), which both require a real forged-gateway webhook to :3003. - Gateway webhook signing — Telebirr/dmoney accept forged payloads (
signatureValid=truehardcoded); Card/Waafi require valid HMAC. UA-3/UA-15/gateway rows must use Telebirr/dmoney or the internal endpoint. Confirm we won't need real Card/Waafi HMAC in Phase 2. - Fayda flag & prefix — global-setup must set
VERIFAYDA_ENABLED=false(else the manual passenger form is hidden and every booking flow blocks). Confirm whichfayda-statusroute the portal reads (/config, default-ON, vsfare-engine.controller.ts:65, default-OFF) so the correct flag is set. - Promo money-flow — does the authed path share the guest override+clamp? The guest booking service overrides its discounted total with
reviewedTotalMinorand clamps (guest-booking.service.ts:240-244,487,536-537,744), making promos inert and negative totals unreachable via UI. Verify whetherbookings.service.ts(authedPOST /bookings) has the same override+clamp before finalizing UA-8's "promo silently dropped" assertion for logged-in users. data-testidaddition — Section 6 requires source edits to both web apps (including theDobPickerModalinternals for child-fare rows). Approve adding testids (small, low-risk) vs authoring against fragile role/text selectors. Strongly recommend adding testids first.- Two field-name mismatches to verify at runtime (each may be its own finding): (a) Promos UI sends
discountType/discountValue/isActivebut DTO expectspercentOff/amountOffMinor/active→ possibly inert promos (PB-7). (b) Seat-class base written asbasePrice(Tariff Rates, PB-2) vsbaseFareMinor(Classes page, PB-6), across two endpoints (/seat-classesvs/fleet/classes) — confirm which the livefare-enginereads before asserting PB-2/PB-6. - Portal station operational filter (PB-5) — portal
GET /stationspasses nooperationalfilter; whether a disabled station disappears depends on the server default. Verify before writing the disable-propagation assertion. - Currency-controller collision — two
@Controller('currencies')register the same base path (currencies.controller.ts+currency.controller.ts) with different guards/bodies; confirm which one the backoffice/currenciespage hits before asserting PB-1/PB-10 write semantics. - Seat-hold TTL number (PB-9) — the matrix draft said "20-min cron"; the seed map says
expiresAt = now + 15min(seats.service.ts:~272). Reconcile the actual fixed TTL before asserting that the hold ignores the config value. - On-select fare divergence (UA-1b) — confirm that the value stored on select is
Math.min(baseFareMinor)(results:320) and not the card'sdisplayAmountMinor(results:821), and pin which one downstream fare-breakdown reconciles against for non-ETB currencies. - Scope of Track A vs B — Track A (UA-) covers pricing integrity through the real browser (closes the Suite K gap); Track B/BC- covers config propagation. Confirm both tracks are in Phase 2 scope, or prioritize Track A first (highest money-risk, most ✅ findings to surface in-browser).
- Explicitly out-of-scope money surfaces (deferral, not omission): loyalty redemption (C-2, no browser path), refunds (no endpoint mapped), over-100%/over-subtotal promo negative totals (H-1, API-only), transit /
ROUND_TRIP_TRANSIT(needs a 2nd seeded route), package booking (/packages,isPackageOnlyschedules,packageTierPriceMinor × 2),/pay-balance/[token]partial-payment /returnLegStatus, and config surfaces/fare-management+/pricing. Confirm these stay deferred so the matrix is not read as exhaustive.