Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 15:26:29 +03:00
parent f2ae9c883f
commit 7e6778d025
3279 changed files with 117 additions and 12612 deletions

View File

@@ -226,6 +226,20 @@ Two structural facts frame everything:
`getExchangeRate(USD,ETB)=1.0` but `getRateOrThrow(USD,ETB)=100` → displayed fare and charged
amount differ 100×. Matrix C2/C5.
- **Fix**: one shared conversion routine with one rounding rule and one fallback policy.
- **Resolution (booking-record coherence, UA-1b/UA-2/UA-3w)** ✅ — the stored booking record no longer
mislabels its amount. Every `booking.create` path (`bookings.service.ts` one-way/round-trip/
transit/round-trip-transit + the guest equivalents) now stores `currency: Currency.ETB` (the actual
currency of `totalMinor`/the ETB charge basis) instead of the display currency. The passenger-facing
amount stays in `displayCurrency`/`displayTotalMinor` (Birr for Ethiopian, DJF for Djiboutian, USD
for Other), and every read endpoint already prefers those. The portal `results/page.tsx` on-select
now carries the passenger-currency fare (`displayAmountMinor`) forward, aligning with the seats
page's already-`displayAmountMinor` fare logic. Net effect (agreed model **A**): the passenger sees
and is charged in their own currency; the internal charge basis stays ETB (the unit every downstream
calc — wallet debit, loyalty, refund, gateway conversion — already assumes), now honestly labeled.
Proven by `e2e-ui/specs/portal/ua2-usd-booking.spec.ts` and `ua3-djf.spec.ts` (UA-3w): `currency`
is `ETB` while `displayCurrency`/`displayTotalMinor` carry USD/DJF — red before the fix
(`currency` was `USD`/`DJF`), green after; UA-1 (ETB) unchanged. The deeper H-3 (unify
`getExchangeRate`/`getRateOrThrow`) and H-4 (branded Minor/Major units) refactors remain open.
### H-4 ✅ Conversion routines return different UNITS for the same money
- **Where**: `displayMinorToChargeMajor`/`convertMinorToChargeMajor` return **major** units;
@@ -379,6 +393,13 @@ Two structural facts frame everything:
`:124-132` blocks only same-train+same-route+same-day, so the same train can run two routes at
overlapping times. Matrix H3/H4.
- **Fix**: reject past `departureAt`; widen the overlap check to the train across all routes.
- **Resolution (past departure)** ✅ — `schedules.service.ts` `createSchedule` now rejects a
`departureAt` in the past (`dep.getTime() < Date.now()` → 400) alongside the existing
`arrivalAt > departureAt` check. Proven by `e2e-ui/specs/backoffice/config-validation.spec.ts`
(BC-10): a 2020 departure → 400 while a future schedule still creates (red before the guard, green
after). Scoped to creation (an admin may still need to edit metadata on an already-departed
schedule via `updateSchedule`). The cross-route train double-booking overlap widening remains a
follow-up (not exercised by BC-10).
### M-5 🔎 Deletes ignore referencing bookings; one cascade is non-transactional
- **Where**: station delete ignores bookings (`stations.service.ts:110-137`); seat-class delete

View File

@@ -78,13 +78,13 @@ Axes: booking type {one-way, round-trip} × pax mix {1A, 2A, 1A+1C-free, 1A+2C (
| 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 on-select stored `baseFareMinor` | nationality OTHER → USD; INTL class | **First chain break:** assert the value stored on select (`Math.min(baseFareMinor)`, results:320) equals what flows to review, and flag if it differs from the card's `displayAmountMinor` (results:821) | div #1 (new); H-3/H-4 |
| **UA-2** | One-way, 1 adult, Other/USD, INTERNATIONAL Regular, WALLET | OTHER → displayCurrency USD | Chain equal in USD; assert USD 2dp; verify INTL 2× surcharge NOT silently dropped on seat-class base path | H-3/H-4, matrix B3 |
| **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 | Total == outbound+inbound; per-leg split `Math.round(fareMinor/2)` (review:481) reconciles to `outbound/inboundSeatFareMinor`; both `holdId` + `returnHoldId` present | div #6; matrix A3 |
| **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 |
@@ -142,7 +142,7 @@ Each row: change made in backoffice UI (:5184) → API write → portal read (:5
- **BC-7** ✅ FIXED: `PATCH /seat-classes` with `basePrice:-500` is now rejected with **400** — `CreateSeatClassDto.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 **400** — `CreatePromotionDto.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**: `/schedules` past `departureAt`. Client blocks only `arrival<=departure`, no past-date block. Assert schedule created in the past. **M-4**, matrix H3.
- **BC-10** ✅ FIXED: `POST /schedules` with a past `departureAt` is now rejected with **400** — `schedules.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.
---