# EDR Passenger Platform β€” E2E Test Matrix (Phase 1 deliverable) **Goal:** find real issues, prioritizing pricing integrity and backoffice configuration. **Status:** DRAFT for review. No tests written yet. Nothing runs against production. Two systems were discovered that shape everything below: - **Two parallel fare systems.** `fare-engine` (integer "minor" math) is the **live** pricing pipeline. `configurable-fare` (raw-SQL, `fare_configurations`) is fully built but **never called by the live path** (`fare-engine.calculate` never reads `fare_configurations`). *Assumption for this matrix: we target `fare-engine` as the system of record and treat `configurable-fare` as dormant (test only that it is not wired in).* ⚠️ **Confirm.** - **The domain seed is disabled.** Every step in `prisma/seed.ts main()` (~L894) is commented out β€” `pnpm prisma:seed` creates nothing. The harness must re-enable/call the seeders or build fixtures. Legend for **Predicted**: πŸ”΄ = looks like a confirmed defect from static read (test will document/repro), 🟠 = suspicious, needs runtime verification, 🟒 = expected to pass (guard/happy-path). --- ## The master invariant (Suite A drives everything) For every booking flow, assert the chain is equal at every hop: ``` portal displayed price == API fare-quote == amount stored on booking (totalMinor/displayTotalMinor) == amount sent to payment-api (intent) == amount actually charged (webhook) == amount used for loyalty accrual == refund basis on cancel ``` Any inequality is a finding. The explorers show this chain is **broken by design** in several places (client-supplied totals, pay-time recompute+overwrite, four different currency-conversion routines). --- ## Suite A β€” Pricing integrity & client-trust (API-level, HIGHEST PRIORITY) | ID | Scenario | Expected | Targets (file:line) | Predicted | |----|----------|----------|---------------------|-----------| | A1 | Book with `reviewedTotalMinor: 1` on a real fare | Server rejects / overrides with computed fare | `bookings.service.ts:863-895` | πŸ”΄ books for 1 | | A2 | Book with every `seatFareMinor: 0` | Reject / override | `bookings.service.ts:863` | πŸ”΄ books for 0 | | A3 | Round-trip with forged `returnSeatFareMinor` | Reject / override | `bookings.service.ts:1065-1095` | πŸ”΄ | | A4 | Guest booking with forged total | Reject / override | `guest-booking.service.ts:206-245,494-540` | πŸ”΄ | | A5 | `loyaltyRedemptionPoints: 999999` on a 0-point account | Reject; no discount; no negative total | `bookings.service.ts:1028`; `bookings.dto.ts:155` | πŸ”΄ totalβ†’0, no deduction | | A6 | Confirm displayed==stored==intent==charged for a clean one-way ETB booking | All equal | whole chain | 🟠 baseline | | A7 | Same cross-check for USD/DJF display currency | All equal, correct rounding | `payments.service.ts:250-267` | 🟠 DJF rounding suspect | | A8 | `initiatePayment` overwrites `booking.totalMinor` at pay time | Read path must not mutate order amount | `payments.service.ts:167-185,209-218` | πŸ”΄ mutates DB on read | | A9 | Payment intent `amountMinor` field carries **major** units across service boundary | Consistent unit contract | `payments.service.ts:272-281` | 🟠 unit-confusion | ## Suite B β€” Fare computation correctness (integration against fare-engine) | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | B1 | `insuranceFeeMinor` semantics: multiplier vs flat fee | One consistent meaning | `fare-engine.service.ts:130,154,167` vs schema:95 | πŸ”΄ two meanings, same column | | B2 | Unit scale: `/100` in code vs "Γ—100000" schema comment | Documented, consistent | `fare-engine.service.ts:129,153` vs schema:93 | 🟠 1000Γ— ambiguity | | B3 | INTERNATIONAL 2Γ— surcharge across all 4 fare sources | Applied consistently | `fare-engine.service.ts:120,141` (missing in route/seat-class) | πŸ”΄ inconsistent | | B4 | Global (tripId=null) FareRule that wins priority | Used | `fare-engine.service.ts:139` | πŸ”΄ matched then ignored | | B5 | Overlapping segment/schedule fare rules, no orderBy | Deterministic pick | `fare-engine.service.ts:84`; schema:1113 | πŸ”΄ arbitrary DB order | | B6 | Free-child rule consistency: quote vs booking vs package | Same rule everywhere | `fare-engine.service.ts:172` vs `bookings.service.ts:1690` vs `:1622` | πŸ”΄ 3 divergent rules | | B7 | Package round-trip child fare `round(adult Γ— 0.1)` float | Integer, single rule | `payments.service.ts:135,180`; `bookings.service.ts:34-40` | πŸ”΄ float, 3rd rule | | B8 | Distance from nullable `distanceKm` float subtraction | Guarded, integer-safe | `fare-engine.service.ts:46` | 🟠 | ## Suite C β€” Currency / FX | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | C1 | Missing USDβ†’ETB rate row | Reject / block, not silent 1.0 | `currency.service.ts:142-147` | πŸ”΄ prices at parity, display path only warns | | C2 | Missing rate: display path returns 1.0 but charge path throws | Same behavior both paths | `currency.service.ts:142-147` vs `:108` | πŸ”΄ divergence | | C3 | Future-dated FX rate | Not applied until effective | `currency.service.ts:88-99,137` (no `<= now` filter) | πŸ”΄ applies immediately | | C4 | Stale FX (>2 days) | Blocked or refreshed | `currency.service.ts:149-154` | 🟠 only warns, still used | | C5 | Four conversion routines produce same result for same inputs | Identical rounding | `fare-engine:196`, `currency:61,78`, `payments:733` | πŸ”΄ divergent | | C6 | DJF (0-decimal) display vs charge rounding | Consistent whole-franc | `format.ts:22-28` vs `currency.service.ts:9-13` | πŸ”΄ UI shows 2 decimals | ## Suite D β€” Promos | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | D1 | `percentOff: 200` | Reject (max 100) / clamp total at 0 | `promos.dto.ts:20`; `fare-engine.service.ts:185-192` | πŸ”΄ negative total | | D2 | `amountOffMinor` > subtotal | Clamp at 0 | `promos.dto.ts:26`; `fare-engine.service.ts:187,192` | πŸ”΄ negative total | | D3 | Reuse one promo N times / across users | Usage-limit enforced | `bookings.service.ts:1023-1029`; no limits in schema | πŸ”΄ unlimited | | D4 | `percentOff: 0` legit promo | Applies as 0%, not mislabeled FIXED | `fare-engine.service.ts:185`; `promos.service.ts:172` | 🟠 falsy bug | | D5 | `validUntil` as arbitrary string / past date | Reject invalid, no dead promo | `promos.dto.ts:29-30` (`@IsString`) | πŸ”΄ accepts Invalid Date | | D6 | Promo min-spend / max-cap | Enforced | schema:785 (fields absent) | πŸ”΄ none exist | ## Suite E β€” Excess baggage & supplementary charges | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | E1 | Excess-baggage rate lookup by seat class | Uses booking's class allowance | `excess-baggage.service.ts:53` (oldest global row) | πŸ”΄ wrong allowance | | E2 | Baggage/supp charge to payment: `/100` major units, DJF | Per-currency rounding, correct unit | `excess-baggage.service.ts:166`; `supplementary-charges.service.ts:132` | πŸ”΄ no conversion/rounding | | E3 | Negative `maxWeightKg`/`maxPiecesCount` allowance | Reject | `excess-baggage.controller.ts:15-16` (no `@Min`) | πŸ”΄ accepts negative | | E4 | `markPaid` stores `providerTxnId` | Persisted | `excess-baggage.service.ts:186` | 🟠 discarded | ## Suite F β€” Wallet & loyalty | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | F1 | Top up another passenger's wallet with your JWT | 403 | `wallet.controller.ts:34-39` (no ownership check) | πŸ”΄ credits freely | | F2 | Wallet top-up has payment backing | Backed by real payment | `wallet.service.ts:50-56` | πŸ”΄ free money | | F3 | `GET /wallet/accounts` public | Auth required | `wallet.controller.ts:23-24` (`isPublic`) | πŸ”΄ leaks balances | | F4 | Two concurrent WALLET bookings draining one balance | One fails, no negative | `payments.service.ts:461-484` (no row lock) | πŸ”΄ double-spend | | F5 | Loyalty accrual on non-ETB charge | Points from actual charge currency | `payments.service.ts:1062,1067` | 🟠 uses ETB minor always | | F6 | Loyalty redemption deducts points / has balance | Deducted, capped | `bookings.service.ts:1028` | πŸ”΄ never deducted (=A5) | ## Suite G β€” Booking/payment lifecycle & webhooks | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | G1 | Webhook `confirmedAmount` < booking total (partial) | Not confirmed | `intents.service.ts:541-548` | πŸ”΄ confirms, mismatch only logged | | G2 | Pay a booking >20 min after creation (expired/cancelled) | Reject | `payments.service.ts:809-848`; `bookings.service.ts:2123` | πŸ”΄ re-confirms, re-issues tickets | | G3 | Duplicate webhook | Idempotent | `webhook-processor.service.ts:44-58` | 🟒 handled | | G4 | Cancel a CONFIRMED booking β†’ refund disbursed | Refund paid to wallet/provider | `bookings.service.ts:2017-2027` | πŸ”΄ stuck PENDING forever | | G5 | Refund amount `floor(total Γ— 0.8)` flat | Correct tiered policy | `bookings.service.ts:2021` | 🟠 flat 80%, float | | G6 | Seat-hold TTL (config) vs pending-expiry cron (hardcoded 20m) | Consistent | `seats.service.ts:271` vs `bookings.service.ts:2123` | πŸ”΄ mismatch | | G7 | Payment amount validated against booking anywhere | Validated | passenger-api + payment-api | πŸ”΄ never | | G8 | Booking create + seat confirm + tier increment atomic | Single transaction | `bookings.service.ts:883-926` | 🟠 not atomic | | G9 | `forceConfirmPayment` admin-guarded | Admin only | `payments.service.ts:989` | 🟠 verify guard | ## Suite H β€” Backoffice config validation gaps (API-level, direct-to-API bypassing UI) | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | H1 | Negative `baseFareMinor` fare rule | Reject | `schedules.dto.ts:85,95` (no `@Min`) | πŸ”΄ accepts (sibling DTO has `@Min`) | | H2 | Negative/zero seat-class `basePrice` | Reject | `seat-classes.dto.ts:29` | πŸ”΄ accepts | | H3 | Past `departureAt` schedule | Reject | `schedules.service.ts:105` | πŸ”΄ accepts | | H4 | Same train, two routes, overlapping time (same day) | Reject double-booking | `schedules.service.ts:124-132` | πŸ”΄ accepts | | H5 | Fare rule `validUntil` < `validFrom`; overlapping windows | Reject | `schedules.dto.ts`; no ordering/overlap check | πŸ”΄ accepts | | H6 | Duplicate station `code` | Reject (P2002) | `stations.service.ts:57-61` | 🟠 no catch (verify schema unique) | | H7 | `PATCH /config` arbitrary key/value (e.g. `seat_hold_duration_minutes:-1`) | Validated | `system-config.controller.ts:34` (no DTO) | πŸ”΄ stored raw | | H8 | Unsupported currency code (outside ETB/USD/DJF enum) | 400 not 500 | `currencies.dto.ts:5`; `currencies.service.ts:55` | 🟠 | | H9 | Station lat/lng out of Β±90/Β±180 | Reject | `stations.dto.ts:9-10` | 🟠 | ## Suite I β€” Config propagation & delete/disable semantics | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | I1 | Change exchange rate in backoffice β†’ portal reflects it | Propagates (note 5-min staleTime) | `portal/useCurrencies.ts:20` | 🟠 up to 5 min stale | | I2 | Change a fare in backoffice β†’ next search reflects it | Live (no server cache) | `fare-engine.service.ts:29,59` | 🟒 no cache | | I3 | Delete a station referenced by bookings | Blocked or safe | `stations.service.ts:110-137` (ignores bookings) | πŸ”΄ orphan/FK risk | | I4 | Delete a seat-class referenced by bookings/bookingSeat | Blocked or safe | `seat-classes.service.ts:53-81` | πŸ”΄ ignores bookings | | I5 | Schedule cascade delete fails midway | Transactional, no partial delete | `schedules.service.ts:438-485` | 🟠 non-transactional | | I6 | Delete currency with active fares/rates | Blocked | `currencies.service.ts:119-134` | πŸ”΄ wipes rates β†’ 1.0 fallback | | I7 | Config change mid-flight (edit/disable fare between quote and pay) | Defined behavior | booking freezes at create; pay never re-quotes | 🟠 client-trusted gap | ## Suite J β€” Auth / authorization gaps | ID | Scenario | Expected | Targets | Predicted | |----|----------|----------|---------|-----------| | J1 | Unauthenticated `PUT/PATCH /fare-engine/exchange-rates` | 401 | `fare-engine/currency.controller.ts:25,32` (no guard) | πŸ”΄ anyone rewrites FX | | J2 | Non-admin authenticated user CRUDs `/admin/fare-configurations` | 403 | `configurable-fare.controller.ts` (`@Roles` dead) | πŸ”΄ RolesGuard never wired | | J3 | Non-admin CRUDs `/admin/segment-fares` | 403 | `segment-fare.controller.ts:15` | πŸ”΄ | | J4 | Non-admin reads/writes `/config` | 403 | `system-config.controller.ts:23,32` | πŸ”΄ | | J5 | Public exposure of `/search`, `/currencies`, `/wallet/accounts` | Intended-public only | `search.controller.ts`, `wallet.controller.ts:24` | 🟠 balances shouldn't be public | ## Suite K β€” Browser E2E (Playwright, portal + backoffice) | ID | Scenario | Expected | Layer | |----|----------|----------|-------| | K1 | Portal: search β†’ results price == API `displayAmountMinor` | UI math matches server | portal (`fare-utils.ts`, `results/page.tsx:609`) | | K2 | Portal: review page total == what booking stores == charged | No client-side divergence | portal (`review/page.tsx:160-181,478`) | | K3 | Portal: DJF fare rendered whole-franc, matches charge | Correct formatting | `format.ts:22-28` | | K4 | Backoffice: create fare β†’ portal search shows new price | End-to-end propagation | backofficeβ†’portal | | K5 | Backoffice: disable station β†’ disappears from portal search | Honored | backofficeβ†’portal | | K6 | Backoffice: create promo β†’ apply in portal β†’ correct discount, no negative | End-to-end | backofficeβ†’portal | | K7 | Full happy-path booking (WALLET) through portal to ticket | Issued, amounts consistent | portal+api | --- ## Harness plan (Phase 2 preview) - **API tests (supertest):** reuse the `payments.e2e-spec.ts` fixture-builder pattern (full Prisma object graph + teardown). Most target endpoints are `isPublic`, so auth is cheap. Wire a real config (`test/jest-e2e.json` currently won't even pick up in-src `*.e2e-spec.ts`). - **Browser tests (Playwright):** greenfield β€” add runner + config. Portal has no server-side auth gate; backoffice needs `auth_token` cookie + localStorage seeded. - **Payments:** WALLET is fully offline-testable. Gateway flows driven by POSTing directly to `/webhooks/` on payment-api (Telebirr/CBE/eBirr have loose signature gating; Card/Waafi need valid HMAC). `SERVICE_AUTH_TOKEN` unset in dev = internal endpoints unguarded. - **Seed:** re-enable `prisma/seed.ts` steps or invoke seeder fns from a test bootstrap. Needs stations, routes+stops (distanceKm), schedules, seat classes, fare rules, FX rates, promos. - **DB:** ⚠️ doc drift β€” CLAUDE.md says `postgres-passenger:5434/edr_passenger`; actual `.env.example` says `localhost:5432/edr_database?schema=passenger`; no compose file provisions it. **Need target confirmed.** ## Open decisions (blocking Phase 2) 1. **Environment** β€” is there a dev/staging DB + running stack I should target, or should the harness stand up a local Postgres (Docker) + seed + run the APIs itself? 2. **Fare system** β€” confirm `fare-engine` is the system of record and `configurable-fare` is dormant. 3. **Emphasis** β€” API-level abuse/integration tests (fast, high signal, covers ~90% of the leads above) vs. also full browser Playwright E2E (Suite K, slower, needs both web apps running).