Files
edr-platform/docs/e2e-test-matrix.md
Muluhabt c4f54a666b test: add EDR passenger pricing/config E2E bug-hunt harness
Hermetic E2E harness targeting pricing integrity and backoffice config:
- e2e/ docker Postgres (5544) + prepare.sh/run.sh one-command runner + HTML report
- 6 suites / 23 tests reproducing pricing, FX, wallet, refund, config and auth
  defects (see docs/ISSUES.md); docs/e2e-test-matrix.md documents the matrix
- two-tier harness (slim module boot + direct service instantiation) to work
  around the IAM/RabbitMQ/file-type boot wall
- .env.test.example tracked; loader falls back to it for fresh checkouts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:22:40 +03:00

15 KiB
Raw Permalink Blame History

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/<provider> 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).