mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding all the tests and fixes to the passengers app
This commit is contained in:
@@ -19,13 +19,13 @@ a thin back-compat wrapper.
|
||||
| ID | Spec | What it proves |
|
||||
|----|------|----------------|
|
||||
| UA-1 | `ua1` | one-way 1A ETB WALLET — full money chain equal, CONFIRMED |
|
||||
| UA-1b | `ua1b-usd-divergence` | 🔴 USD card display fare diverges 100× from internal `baseFareMinor` |
|
||||
| UA-2 | `ua2-usd-booking` | 🔴 USD booking — reviewed total (ETB) vs stored total (USD) diverge 100× |
|
||||
| UA-1b | `ua1b-usd-divergence` | ✅ USD card shows the USD fare, correctly converted from the internal ETB base (coherent) |
|
||||
| UA-2 | `ua2-usd-booking` | ✅ USD booking — passenger amount in USD (display), charge basis stored coherently in ETB (currency mislabel fixed) |
|
||||
| UA-3 | `ua3-djf` | DJF booking settles via forged gateway payment |
|
||||
| UA-3w | `ua3-djf` | 🔴 DJF WALLET — reviewed (DJF) vs stored (ETB) diverge |
|
||||
| UA-3w | `ua3-djf` | ✅ DJF WALLET — passenger amount in DJF, charge basis stored coherently in ETB (mislabel fixed) |
|
||||
| UA-4 | `ua4-child-free` | first child <5 free → total = one adult fare, free child not seated |
|
||||
| UA-5 | `ua5-second-child-paid` | 1A+2C → second child pays full fare (2 seats) |
|
||||
| UA-6 | `ua6-round-trip` | 🔴 round-trip inbound leg has availability but no priced coach (booking blocked) |
|
||||
| UA-6 | `ua6-round-trip` | ✅ round-trip books both legs — reverse-leg pricing fixed (abs distance); 2 seats, total = 2× one-way (M-4-adjacent) |
|
||||
| UA-8 | `ua8-promo-drop` | ✅ valid promo now applied server-side; booking stored at the discounted total (H-13 fixed & guarded) |
|
||||
| UA-11 | `ua11-expired-promo` | expired promo ignored → full fare booked |
|
||||
| UA-13 | `ua13-forged-total` | ✅ client-forged `reviewedTotalMinor=1` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) |
|
||||
@@ -45,13 +45,13 @@ a thin back-compat wrapper.
|
||||
| BC-7 | `pb-config-propagation` | ✅ negative seat-class base price now REJECTED (400, DTO `@Min(0)`) (M-1 fixed & guarded) |
|
||||
| BC-8 | `config-validation` | ✅ promo over 100% now REJECTED (400, DTO `@Max(100)`) (M-2 fixed & guarded) |
|
||||
| BC-9 | `config-validation` | ✅ negative seat-hold duration now REJECTED (400, whitelisted typed `/config` DTO) (M-3 fixed & guarded) |
|
||||
| BC-10 | `config-validation` | 🔴 schedule with a past departure accepted (M-4) |
|
||||
| BC-10 | `config-validation` | ✅ schedule with a past departure now REJECTED (400); future schedules still create (M-4 fixed & guarded) |
|
||||
| BC-11 | `pb-config-propagation` | ✅ non-admin passenger now FORBIDDEN (403) from FX writes; admin still allowed (C-8 fixed & guarded) |
|
||||
|
||||
### Deferred (documented, not silently omitted)
|
||||
|
||||
- **UA-7** (round-trip berth) — `specs/portal/ua7-berth.spec.ts` is `test.skip` with the reason: needs
|
||||
a bed CoachType + reverse-leg pricing, both backend prerequisites (see UA-6 for the reverse-leg gap).
|
||||
- **UA-7** (round-trip berth) — `specs/portal/ua7-berth.spec.ts` is `test.skip`: still needs a bed
|
||||
CoachType seed. (Reverse-leg pricing is no longer a blocker — fixed under UA-6.)
|
||||
- **UA-9 / UA-10 / UA-12 / UA-17** — the matrix moves these to the API-level harness (over-100% /
|
||||
over-subtotal promos, loyalty over-redeem, DJF×promo negative total): no reachable browser path
|
||||
(server clamps `reviewedTotalMinor` ≥ 0; the portal never calls the loyalty/fare-quote path).
|
||||
|
||||
@@ -2,12 +2,14 @@ import { test, expect } from "@playwright/test";
|
||||
import { resultsUrl } from "../../fixtures/data";
|
||||
|
||||
/**
|
||||
* UA-1b — the first money-chain break. For a non-Ethiopian (USD) search the results card shows the
|
||||
* USD-converted `displayAmountMinor`, but the internal `baseFareMinor` stays in raw ETB minor. The
|
||||
* two diverge exactly 100× (the USD→ETB rate). The portal stores `Math.min(baseFareMinor)` on select,
|
||||
* so the ETB value — not the USD one the passenger saw — is what flows downstream.
|
||||
* UA-1b ✅ — for a non-Ethiopian (USD) search the results card shows the USD-converted
|
||||
* `displayAmountMinor`, and the internal `baseFareMinor` is the ETB source it was converted from
|
||||
* (exactly the USD→ETB rate apart — a correct conversion, not a mislabel). The passenger sees and
|
||||
* carries forward the USD value; the ETB basis is stored honestly on the booking as `currency: ETB`
|
||||
* (proven end-to-end by UA-2). This pins the display layer so a regression that shows the raw ETB
|
||||
* number, or drops the conversion, is caught.
|
||||
*/
|
||||
test("UA-1b: USD card display fare diverges 100x from the internal baseFareMinor", async ({ page }) => {
|
||||
test("UA-1b: USD card shows the USD fare, correctly converted from the internal ETB base", async ({ page }) => {
|
||||
const searchDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
@@ -16,11 +18,12 @@ test("UA-1b: USD card display fare diverges 100x from the internal baseFareMinor
|
||||
const cls = out?.faresByClass?.[0];
|
||||
|
||||
expect(out.displayCurrency).toBe("USD");
|
||||
// The display fare is the converted USD amount; baseFareMinor is the untouched ETB minor.
|
||||
// The USD display fare is the ETB base converted at the USD→ETB rate (100×), not a parity mislabel.
|
||||
expect(cls.displayAmountMinor).toBeGreaterThan(0);
|
||||
expect(cls.displayAmountMinor).toBeLessThan(cls.baseFareMinor);
|
||||
expect(cls.baseFareMinor).toBe(cls.displayAmountMinor * 100);
|
||||
|
||||
// The DOM shows the USD value (formatFare divides by 100, 2dp) — e.g. "USD 12.50".
|
||||
// The DOM shows the USD value the passenger pays (formatFare divides by 100, 2dp) — e.g. "USD 12.50".
|
||||
const usdMajor = (cls.displayAmountMinor / 100).toFixed(2);
|
||||
await expect(page.getByText(new RegExp(`USD\\s*${usdMajor.replace(".", "\\.")}`)).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
|
||||
@@ -8,13 +8,12 @@ test.afterAll(async () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-2 🔴 — full one-way USD booking (Other nationality, INTERNATIONAL class, WALLET). This surfaces
|
||||
* the money-chain incoherence for non-ETB currencies: the browser sends reviewedTotalMinor = the raw
|
||||
* ETB baseFareMinor (125000), but the server stores Booking.totalMinor = the USD display value (1250).
|
||||
* The two differ 100× — the client-computed "reviewed total" the C-1 path trusts is in the wrong
|
||||
* currency, yet the stored/charged total is the display one. We pin both so a regression is caught.
|
||||
* UA-2 ✅ — full one-way USD booking (Other nationality, INTERNATIONAL class, WALLET). The money chain
|
||||
* is now COHERENT: the passenger sees and agrees to a USD amount (displayCurrency/displayTotalMinor),
|
||||
* while the stored charge basis is honestly labeled ETB (currency/totalMinor). The two are the same
|
||||
* fare at the USD→ETB rate — no longer a mislabeled 100× divergence.
|
||||
*/
|
||||
test("UA-2: USD booking — reviewed total (ETB) and stored total (USD) diverge 100x", async ({ page }) => {
|
||||
test("UA-2: USD booking — passenger amount in USD, charge basis stored coherently in ETB", async ({ page }) => {
|
||||
const r = await bookTrip(page, { nationality: "Other", paymentMethod: "WALLET" });
|
||||
expect(r.displayCurrency).toBe("USD");
|
||||
expect(r.confirmed).toBe(true);
|
||||
@@ -22,12 +21,17 @@ test("UA-2: USD booking — reviewed total (ETB) and stored total (USD) diverge
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } });
|
||||
|
||||
// The browser sent the USD display value the passenger saw as the reviewed total…
|
||||
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
|
||||
// …but the booking stored the raw ETB base fare (100× larger, un-converted).
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
|
||||
expect(booking.totalMinor).toBe(r.reviewedTotalMinor * 100); // 🔴 the divergence
|
||||
expect(intent.amountMinor).toBe(booking.totalMinor);
|
||||
// Passenger-facing: the USD amount they saw and agreed to (what the browser reviewed).
|
||||
expect(booking.displayCurrency).toBe("USD");
|
||||
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
|
||||
|
||||
// Stored charge basis: ETB, coherently labeled (no more USD mislabel).
|
||||
expect(booking.currency).toBe("ETB");
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // the ETB fare
|
||||
expect(booking.totalMinor).toBe(r.reviewedTotalMinor * 100); // ETB == USD display × rate
|
||||
|
||||
// The charge/intent moves the ETB amount; booking is confirmed.
|
||||
expect(intent.amountMinor).toBe(booking.totalMinor);
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
@@ -8,20 +8,22 @@ test.afterAll(async () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-3w — Djiboutian/DJF, WALLET. DJF is a zero-decimal currency, but the portal renders every fare
|
||||
* through formatFare (always /100, 2dp) → "DJF x.yy". WALLET short-circuits past the charge-currency
|
||||
* conversion, so here we can only assert the display currency is stamped DJF and the chain is intact.
|
||||
* UA-3w ✅ — Djiboutian/DJF, WALLET. The money chain is now COHERENT: the passenger sees and agrees
|
||||
* to a DJF amount (displayCurrency/displayTotalMinor), while the stored charge basis is honestly
|
||||
* labeled ETB (currency/totalMinor). Same fare, two correctly-labeled currencies — no mislabel.
|
||||
*/
|
||||
test("UA-3w: DJF WALLET booking is stamped DJF; reviewed (DJF) vs stored (ETB) diverge", async ({ page }) => {
|
||||
test("UA-3w: DJF WALLET booking — passenger amount in DJF, charge basis stored coherently in ETB", async ({ page }) => {
|
||||
const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "WALLET" });
|
||||
expect(r.displayCurrency).toBe("DJF");
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
// Passenger-facing: the DJF amount they saw and agreed to.
|
||||
expect(booking.displayCurrency).toBe("DJF");
|
||||
// Mirror image of UA-2: here the browser sent the DJF display value as the reviewed total…
|
||||
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
|
||||
// …while the booking stored the raw ETB base. The chain uses different currencies at each hop.
|
||||
// Stored charge basis: ETB, coherently labeled (no more DJF mislabel).
|
||||
expect(booking.currency).toBe("ETB");
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { resultsUrl } from "../../fixtures/data";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-6 🔴 — round-trip return leg is UNBOOKABLE. A ROUND_TRIP search returns an inbound (C→A) leg
|
||||
* that reports seat availability, but its `coachTypes`/`faresByClass` come back EMPTY: the fare engine
|
||||
* cannot price the reverse direction on this route (the segment runs high→low stop sequence, so the
|
||||
* distance resolves non-positive and every class is dropped). With no priced coach the portal renders
|
||||
* no coach option, so the round-trip wizard cannot advance past inbound selection — the full-UI
|
||||
* round-trip booking flow is blocked. This test pins the gap through the portal's own search call.
|
||||
*
|
||||
* When reverse-leg pricing is fixed (priced inbound coachTypes), replace this with the full
|
||||
* two-leg booking flow: `bookTrip(page, { tripType: "ROUND_TRIP" })` asserting total = 2× base fare
|
||||
* and one seat per leg.
|
||||
* UA-6 ✅ — round-trip books BOTH legs. The return leg (C→A) traverses the seeded route high→low; the
|
||||
* fare engine now prices the reverse direction by absolute distance (previously it threw "origin must
|
||||
* come before destination" and dropped every class, leaving the inbound leg with seats but no priced
|
||||
* coach — unbookable). The full two-leg wizard now completes: outbound + return seat, and a total of
|
||||
* 2× the one-way fare.
|
||||
*/
|
||||
test("UA-6: round-trip inbound leg has availability but no priced coach (booking blocked)", async ({
|
||||
test("UA-6: round-trip books both legs — return leg priced, one seat per leg, total = 2× one-way fare", async ({
|
||||
page,
|
||||
}) => {
|
||||
const searchDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
await page.goto(resultsUrl({ tripType: "ROUND_TRIP" }));
|
||||
const data = (await (await searchDone).json())?.data;
|
||||
const r = await bookTrip(page, { tripType: "ROUND_TRIP", paymentMethod: "WALLET" });
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
expect(data.journeyType).toBe("ROUND_TRIP");
|
||||
const inbound = data.inbound?.[0];
|
||||
expect(inbound).toBeTruthy();
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.bookingType).toBe("ROUND_TRIP");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
|
||||
// The return leg exists and shows availability…
|
||||
expect(inbound.hasAvailability).toBe(true);
|
||||
expect(Object.values(inbound.availabilityByClass ?? {}).some((n) => Number(n) > 0)).toBe(true);
|
||||
// …but no coach type is priced, so nothing is selectable in the UI. 🔴
|
||||
expect(inbound.coachTypes ?? []).toHaveLength(0);
|
||||
expect(inbound.faresByClass ?? []).toHaveLength(0);
|
||||
// One seat per leg (leg 1 outbound + leg 2 return) for a single passenger.
|
||||
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
|
||||
expect(seats.length).toBe(2);
|
||||
expect(new Set(seats.map((s) => s.leg)).size).toBe(2);
|
||||
|
||||
// Both legs cover the same A↔C distance, so the round-trip total is 2× the one-way base fare (ETB).
|
||||
expect(r.cardBaseFareMinor).toBeGreaterThan(0);
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user