Files
edr-platform/docs/ui-e2e-test-matrix.md
2026-08-25 00:11:39 +03:00

39 KiB
Raw Permalink Blame History

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 AK). 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 a LoyaltyAccount.pointsBalance increment and no green row asserts accrual vs price; and refund basis — there is no refund endpoint anywhere in the API map and no refund scenario. If a loyalty-accrual assertion is wanted, add a LoyaltyAccount.pointsBalance read 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=&currency= 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/telebirr or /webhooks/dmoney (both signatureValid=true hardcoded) with merch_order_id = <captured merchantOrderId>, trade_status=success. Card/Waafi require valid HMAC — avoid. A TELEBIRR initiate returns clientAction REDIRECT and the portal does window.location.href = url (payment/page.tsx:149) → the test must page.route-abort that navigation to the non-existent gateway, forge the webhook, then drive to /booking/confirmation.
  • Direct internalPOST /internal/payments/mark-paid on :4000 with {version:1,eventType:"payment.succeeded",service:"PASSENGER",referenceType:"BOOKING",referenceId:<bookingId>,...}. ServiceAuthGuard returns true when SERVICE_AUTH_TOKEN unset (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): amountMinor Float (: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_minor double precision (migration 1782000000000). Assert as numeric. Scope: gateway rows only (UA-15) — WALLET creates no payment-api intent.
  • WALLET extras: WalletLedgerEntry DEBIT of totalMinor w/ relatedBookingId; WalletAccount.balanceMinor decremented; 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) while GET /payments/booking-amount returns whole-franc-less major via plain /100. No settled 0-decimal amount_minor exists 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-breakdown returns undiscounted per-pax displayFareMinor and puts the discount only in separate top-level discountMinor/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 as reviewedTotalMinor. The (guest) booking service then overrides its own discounted total with reviewedTotalMinor when >0 and clamps its fallback with Math.max(0,…) (guest-booking.service.ts:240-244,487,536-537,744). Consequences: through the browser, a valid promo is silently dropped and Booking.totalMinor = full price, and a negative total is not reproducible via UI. Promo enters only via ?promoCode= URL param (no selector); lookup is findUnique({where:{code}}) (search.service.ts:941) — seed codes must be unique and exact. Whether the authed /bookings path 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, fixed amountOffMinor > subtotal, DJF×promo negative total. Not reproducible via UI: reviewedTotalMinor is 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|redeem across portal/src/app/booking/** + booking-store.ts → zero hits; loyaltyRedemptionPoints exists only on POST /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 /stationsdisable 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 /currenciesdelete 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 I3I6)

  • 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-137 ignores bookings.
  • BC-4: /classes delete a seat-class referenced by a booking's bookingSeat. Assert orphan/FK behavior. matrix I4.
  • BC-5: /currencies delete 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 staleTime does 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-classes with basePrice:-500 is now rejected with 400CreateSeatClassDto.basePrice (and insuranceFeeMinor) carry @Min(0), applied to updates via PartialType. M-1, matrix H1/H2.
  • BC-8 FIXED: POST /promos with percentOff:200 is now rejected with 400CreatePromotionDto.percentOff carries @Min(0) @Max(100) (and amountOffMinor @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 whitelisted UpdateSystemConfigDto coerces each known key to a positive integer (seat_hold_duration_minutes bounded 1..60). A sane value still stores. M-3, matrix H7.
  • BC-10 FIXED: POST /schedules with a past departureAt is now rejected with 400schedules.service.createSchedule guards departureAt >= now alongside the existing arrival > departure check. A future schedule still creates. M-4, matrix H3.
  • BC-11 FIXED: PUT/PATCH /fare-engine/exchange-rates now 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 (baseURL http://localhost:5174, storageState passenger.json), backoffice (baseURL http://localhost:5184, storageState staff.json), plus a guest project (no storageState) for guest rows (UA-14). Pin viewport per project — portal desktop layout is hidden md:block; mobile diverges heavily. One shared globalSetup.
  • webServer: optionally let Playwright start portal+backoffice (pnpm --filter @edr/passenger-portal dev etc.); reuseExistingServer in local dev.

5.2 global-setup

  1. Ensure Postgres :5544 up and migrated (.env.test, JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000…).
  2. Boot passenger-api :4000 (with VERIFAYDA_ENABLED=false so the portal exposes the manual passenger form — otherwise Fayda defaults ON and every booking flow is blocked; the flag is enabled = process.env.VERIFAYDA_ENABLED !== 'false', system-config.controller.ts:12,16) and payment-api :3003 (or assert reachable). Await /health-style ping. Confirm which fayda-status prefix the portal hits (/config vs fare-engine.controller.ts:65, which defaults false) so the right flag is set.
  3. Run seed-core.ts + new seed-ui.ts (§5.4).
  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:

  1. Insert iam.users (individual, active).
  2. Insert iam.sessions (status='ACTIVE', future expiry, userInfo.roles=[]).
  3. Insert Prisma Passenger{iamUserId} + LoyaltyAccount + WalletAccount(funded balanceMinor) + UserPreferences — required or getProfile throws "Passenger not found" (passenger-auth.service.ts:262) and the portal logs out.
  4. Mint JWT {id: sessionId} with JWT_ACCESS_TOKEN_SECRET.
  5. Write storageState localStorage for origin :5174: auth_token=<jwt>, auth_user=<profile JSON matching getProfile shape>.
  6. Simplest alternative: drive real POST /auth/login once 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 org edr, roles, users (passenger.admin@edr.local / Test@1234). Then POST /v1/auth/loginGET /v1/auth/me, snapshot localStorage (auth_token,auth_user,auth_refresh_token) and set auth_token cookie.
  • Path B (fast): insert iam.users+iam.sessions with userInfo.roles=[{key:'super_admin'}], mint JWT, write storageState localStorage + auth_token cookie for :5184, auth_user with isSuperAdmin:true. Config pages don't use PermissionGuard — only middleware cookie + API guards matter.
  • Note: the auth_token cookie 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 enabled TELEBIRR (for UA-15). The method's .currency drives booking-amount and 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) → 3 TripStopTime(A/B/C seq 1/2/3, future plannedDepartureAt) → Coach×1(status:'ACTIVE') → CoachAssignment(isOperational:true) → Seat×N (AVAILABLE, non-empty seatNumber, bedPosition:null). Fares resolve via SEAT_CLASS_BASE_FARE distance 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 → seatClassId map from GET /seat-classes (review/page.tsx:277) and fare-quote expects exact names "Economy Regular"|"Economy Bed" (search.dto.ts). Set SeatClass.name to 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 bed Coach + Seats with lowercase bedPosition:'upper'|'middle'|'lower'.
  • Promotions (UA-8/11): valid percentOff:10; expired (validUntil past, active:true). Use schema field names percentOff/amountOffMinor/active — NOT the backoffice UI field names. Pin exact, unique code values (lookup is findUnique({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} + funded WalletAccount(balanceMinor) + LoyaltyAccount.
  • Blocked-seat negative case: one SeatBlock row.
  • Segment override that actually bites (PB-3): seed SegmentFareRule with nationality:null (engine matches dto.nationality string 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↔DJF only if a direct-path currency test needs it.

5.5 Two smoke tests

  • Portal smoke (guest project): home → search (seeded A→C, date = Addis date of departureAt) → results shows ≥1 card with a price → formatFare renders ETB N.NN. Asserts stack+seed+search+Fayda-flag wired.
  • Backoffice smoke (backoffice project): staff storageState → /currencies loads 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:ui task "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/-year inputs, dob-day-cell-{n}, dob-confirm.
  • Seats: seat-cell-{label} (SeatButton:119), berth-cell-{label} (BedCard:38), passenger-tab-{i}, auto-assign-seats (:2006), seats-continue (:1989), 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 have name= (name, baseFareMinor, insuranceFeeMinor/surchargeMinor, isActive) — add testid on submit + modal.
  • Currencies (/currencies): controlled form (no name=) — add currency-from, currency-to, currency-rate, currency-save, currency-edit-rate.
  • Classes (/classes): FormData has name= (coachTypeId,name,baseFareMinor,insuranceFeeMinor,isActive) — add submit testid.
  • Schedules (/schedules): controlled addForm/DateTimePicker — add schedule-train, schedule-route, schedule-departure, schedule-arrival, schedule-status, schedule-save, schedule-cancel-btn.
  • Stations (/stations): FormData name= present — add submit testid.
  • Settings (/settings): real id= (hold-duration, hold-cutoff, boarding-window, throttle-*) — usable, but add config-save testid.
  • Promos (/promos, URL-only): FormData name= present — add submit + note field-name mismatch (PB-7).

7. OPEN QUESTIONS / RISKS (decide before Phase 2)

  1. Valid IAM token for storageState — Path A (real /v1/auth/login after enabling SEED_EDR_PASSENGER_ORG + SEED_PASSENGER_STAFF) vs Path B (direct iam.sessions insert with userInfo.roles=[{key:'super_admin'}] + self-signed JWT). Recommend Path A for staff, Path B acceptable for passenger. Confirm.
  2. Seed iam.sessions vs dev bypass — there is no dev auth bypass in the passenger-API JwtGuard (DB-backed, no env short-circuit). A session row is mandatory for any authenticated flow. Confirm we may write directly to iam.sessions in the test DB.
  3. Target DB / stack — resolved: dev is the single schema-separated database at localhost:5432/edr_database (?schema=passenger for Prisma), provisioned by infrastructure/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.
  4. 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-paid to 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.
  5. Gateway webhook signing — Telebirr/dmoney accept forged payloads (signatureValid=true hardcoded); 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.
  6. Fayda flag & prefix — global-setup must set VERIFAYDA_ENABLED=false (else the manual passenger form is hidden and every booking flow blocks). Confirm which fayda-status route the portal reads (/config, default-ON, vs fare-engine.controller.ts:65, default-OFF) so the correct flag is set.
  7. Promo money-flow — does the authed path share the guest override+clamp? The guest booking service overrides its discounted total with reviewedTotalMinor and clamps (guest-booking.service.ts:240-244,487,536-537,744), making promos inert and negative totals unreachable via UI. Verify whether bookings.service.ts (authed POST /bookings) has the same override+clamp before finalizing UA-8's "promo silently dropped" assertion for logged-in users.
  8. data-testid addition — Section 6 requires source edits to both web apps (including the DobPickerModal internals for child-fare rows). Approve adding testids (small, low-risk) vs authoring against fragile role/text selectors. Strongly recommend adding testids first.
  9. Two field-name mismatches to verify at runtime (each may be its own finding): (a) Promos UI sends discountType/discountValue/isActive but DTO expects percentOff/amountOffMinor/active → possibly inert promos (PB-7). (b) Seat-class base written as basePrice (Tariff Rates, PB-2) vs baseFareMinor (Classes page, PB-6), across two endpoints (/seat-classes vs /fleet/classes) — confirm which the live fare-engine reads before asserting PB-2/PB-6.
  10. Portal station operational filter (PB-5) — portal GET /stations passes no operational filter; whether a disabled station disappears depends on the server default. Verify before writing the disable-propagation assertion.
  11. 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 /currencies page hits before asserting PB-1/PB-10 write semantics.
  12. 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.
  13. On-select fare divergence (UA-1b) — confirm that the value stored on select is Math.min(baseFareMinor) (results:320) and not the card's displayAmountMinor (results:821), and pin which one downstream fare-breakdown reconciles against for non-ETB currencies.
  14. 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).
  15. 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, isPackageOnly schedules, 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.