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

@@ -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).

View File

@@ -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,

View File

@@ -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");
});

View File

@@ -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");
});

View File

@@ -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);
});