Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

View File

@@ -0,0 +1,79 @@
import { test, expect } from "@playwright/test";
import { API_URL, ROUTE_ID, staffToken } from "../../fixtures/data";
import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui";
/**
* Track B — server-side validation gaps and the promo field-name mismatch. Each test calls the same
* passenger-api endpoints the backoffice forms hit, proving the client-side guards are the ONLY guard
* (the API accepts values the forms block) or that a UI/DTO field-name split silently breaks a config.
*/
function auth() {
return { Authorization: `Bearer ${staffToken()}` };
}
test("BC-8 ✅ a promo over 100% is rejected by the API (max validation, M-2)", async ({ request }) => {
// percentOff must be bounded 0..100 at the DTO layer (the backoffice form has no such check).
const res = await request.post(`${API_URL}/promos`, {
headers: auth(),
data: { code: `E2E_OVER100_${Date.now()}`, title: "over", percentOff: 200, validUntil: "2030-01-01T00:00:00Z", active: true },
});
expect(res.status()).toBe(400); // ✅ 200% discount rejected
// A valid promo (≤100%) still succeeds.
const okRes = await request.post(`${API_URL}/promos`, {
headers: auth(),
data: { code: `E2E_OK_${Date.now()}`, title: "ok", percentOff: 50, validUntil: "2030-01-01T00:00:00Z", active: true },
});
expect(okRes.ok()).toBeTruthy();
const promo = (await okRes.json())?.data ?? {};
await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {});
});
test("PB-7 🔴 a promo created with the backoffice UI field names is inert (field-name mismatch)", async ({ request }) => {
// The backoffice /promos form sends discountType/discountValue/isActive, but the DTO reads
// percentOff/amountOffMinor/active — so the sent discount is dropped and the promo saves at 0.
const res = await request.post(`${API_URL}/promos`, {
headers: auth(),
data: { code: `E2E_UIFIELDS_${Date.now()}`, title: "uifields", discountType: "PERCENTAGE", discountValue: 25, isActive: true, validUntil: "2030-01-01T00:00:00Z" },
});
expect(res.ok()).toBeTruthy();
const promo = (await res.json())?.data ?? {};
expect(promo.discountValue).toBe(0); // 🔴 the 25% the UI "set" was silently dropped
await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {});
});
test("BC-9 ✅ a negative seat-hold duration is rejected by /config (DTO validation, M-3)", async ({ request }) => {
// The settings form has min=1 max=60; the API must now enforce the same at the DTO layer.
const res = await request.patch(`${API_URL}/config`, {
headers: auth(),
data: { seat_hold_duration_minutes: "-1" },
});
expect(res.status()).toBe(400); // ✅ negative duration rejected
// A sane value in range still succeeds and is stored.
const ok = await request.patch(`${API_URL}/config`, { headers: auth(), data: { seat_hold_duration_minutes: "15" } });
expect(ok.ok()).toBeTruthy();
expect(((await ok.json())?.data ?? {}).seat_hold_duration_minutes).toBe("15");
});
test("BC-10 ✅ a schedule with a past departure is rejected by the API (past-date block, M-4)", async ({ request }) => {
// The schedules form only checks arrival > departure — the API must ALSO reject a past departure.
const past = new Date("2020-01-02T06:00:00.000Z");
const arrive = new Date("2020-01-02T10:00:00.000Z");
const res = await request.post(`${API_URL}/schedules`, {
headers: auth(),
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: past.toISOString(), arrivalAt: arrive.toISOString() },
});
expect(res.status()).toBe(400); // ✅ past-dated schedule rejected
// A future schedule (a different day than the seeded one) is still accepted.
const dep = new Date(Date.now() + 30 * 864e5); dep.setUTCHours(6, 0, 0, 0);
const arr = new Date(dep.getTime() + 4 * 3600e3);
const okRes = await request.post(`${API_URL}/schedules`, {
headers: auth(),
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
});
expect(okRes.ok()).toBeTruthy();
const sched = (await okRes.json())?.data ?? {};
await request.delete(`${API_URL}/schedules/${sched.id}`, { headers: auth() }).catch(() => {});
});

View File

@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
/**
* Backoffice smoke (Track B foundation): the staff storageState authenticates past the middleware
* cookie gate, /currencies loads its list from the API, and the add-rate control is reachable.
* Proves staff auth (cookie + localStorage + API token) is fully wired.
*/
test("backoffice: staff can load /currencies and reach the add-rate control", async ({ page }) => {
await page.goto("/currencies", { waitUntil: "domcontentloaded" });
// Not bounced to /login (middleware cookie gate passed).
await expect(page).not.toHaveURL(/\/login/);
// The page rendered a currencies view with a seeded currency and an add control.
await expect(page.getByText(/ETB|USD|DJF/).first()).toBeVisible({ timeout: 20_000 });
await expect(
page.getByRole("button", { name: /add/i }).first(),
).toBeVisible();
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { resultsUrl, SCHEDULE_ID } from "../../fixtures/data";
/**
* Portal smoke (Track A foundation): deep-link to results → POST /search fires → a priced result
* card for the seeded trip renders. Proves stack + seed + search + currency formatting are wired.
*/
test("portal: seeded trip appears in search results with a price", async ({ page }) => {
const searchResponse = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl());
const res = await searchResponse;
expect([200, 201]).toContain(res.status());
const body = await res.json();
const outbound = body?.data?.outbound ?? [];
expect(outbound.some((t: any) => t.scheduleId === SCHEDULE_ID)).toBe(true);
// The seeded train + a formatted ETB price render in the DOM.
await expect(page.getByText("UI Test Express").first()).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(/ETB\s*[\d,]+/).first()).toBeVisible();
});

View File

@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-14 ✅ — a GUEST (unauthenticated) booking with forged per-passenger seat fares (ISSUES C-1),
* guarded. We intercept POST /bookings/guest and rewrite every seatFareMinor (and reviewedTotalMinor)
* to 0. The server must recompute the authoritative fare and REJECT the underpayment with a 4xx —
* no free ride, nothing persisted.
*/
test("UA-14: server rejects a guest booking with forged seatFareMinor=0 (C-1)", async ({ page }) => {
const r = await bookTrip(page, {
paymentMethod: "WALLET",
tolerateBookingError: true,
mutateBookingBody: (body) => ({
...body,
reviewedTotalMinor: 0,
passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 0 })),
}),
});
expect(r.guest).toBe(true); // proves the /bookings/guest path was used
expect(r.cardBaseFareMinor).toBeGreaterThan(1000);
// The server must REFUSE the forged 0-fare booking with a 4xx…
expect(r.bookingStatus).toBeGreaterThanOrEqual(400);
expect(r.bookingStatus).toBeLessThan(500);
// …return no booking id and persist no free (0-minor) booking.
expect(r.bookingId).toBeFalsy();
const forged = await prisma.booking.findFirst({ where: { totalMinor: 0 } });
expect(forged).toBeNull();
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookOneAdult } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-1 — one-way, 1 adult, ETB, WALLET. The full real-browser booking flow, asserting the money
* chain: card fare > 0, and reviewedTotalMinor == Booking.totalMinor == displayTotalMinor ==
* PaymentIntent.amountMinor == wallet DEBIT, booking CONFIRMED.
*/
test("UA-1: one-way WALLET booking, price cross-check holds end to end", async ({ page }) => {
const r = await bookOneAdult(page, { nationality: "Ethiopian", paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
expect([200, 201]).toContain(r.initiateStatus);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } });
const debit = await prisma.walletLedgerEntry.findFirst({
where: { relatedBookingId: r.bookingId, type: "DEBIT" },
});
expect(booking.totalMinor).toBe(r.reviewedTotalMinor);
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
expect(intent.amountMinor).toBe(r.reviewedTotalMinor);
expect(debit?.amountMinor).toBe(r.reviewedTotalMinor);
expect(booking.status).toBe("CONFIRMED");
});

View File

@@ -0,0 +1,25 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
import { PROMO_EXPIRED } from "../../fixtures/data";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-11 — one-way, ETB, an EXPIRED promo injected via ?promoCode=. The expired code must not discount
* anything: the fare breakdown reports no discount and the booked total is the full fare (consistent
* with the UA-8 promo-drop behaviour, but here the promo is correctly rejected as expired).
*/
test("UA-11: an expired promo code is ignored — full fare is booked", async ({ page }) => {
const r = await bookTrip(page, { paymentMethod: "WALLET", promoCode: PROMO_EXPIRED });
expect(r.confirmed).toBe(true);
// No discount from the expired code.
if (r.fareBreakdown) expect(r.fareBreakdown.discountMinor ?? 0).toBe(0);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // full fare, no discount applied
});

View File

@@ -0,0 +1,40 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookOneAdult } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-13 ✅ — client-forged booking total (matrix A1 / ISSUES C-1), guarded through the REAL browser.
* We intercept the outgoing POST /bookings and rewrite reviewedTotalMinor (and every per-seat
* seatFareMinor) to 1. The server has two trust branches — sum-of-seatFareMinor when all are present,
* else reviewedTotalMinor — so the forge targets both. The server must recompute the authoritative
* fare and REJECT the mismatched client amount with a 4xx, persisting nothing.
*/
test("UA-13: server rejects a client-forged reviewedTotalMinor=1 (C-1)", async ({ page }) => {
const r = await bookOneAdult(page, {
nationality: "Ethiopian",
paymentMethod: "WALLET",
tolerateBookingError: true,
// Forge both the per-seat fares and the reviewed total → 1.
mutateBookingBody: (body) => ({
...body,
reviewedTotalMinor: 1,
passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 1 })),
}),
});
// The real fare the engine computed is far above 1…
expect(r.cardFareMinor).toBeGreaterThan(1000);
// …the browser forced reviewedTotalMinor=1, and the server must REFUSE it with a 4xx.
expect(r.reviewedTotalMinor).toBe(1);
expect(r.bookingStatus).toBeGreaterThanOrEqual(400);
expect(r.bookingStatus).toBeLessThan(500);
// No booking id was returned, and no 1-minor booking was persisted.
expect(r.bookingId).toBeFalsy();
const forged = await prisma.booking.findFirst({ where: { totalMinor: 1 } });
expect(forged).toBeNull();
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-15 ✅ — forged gateway SHORT-PAY (ISSUES C-4), guarded. A booking with a real fare in the
* thousands is settled by a forged payment.succeeded event carrying amountMinor = 1. The server must
* compare the settled amount against what the passenger was quoted (the booking's display total) and
* REFUSE to confirm a short payment — the booking stays unconfirmed and no ticket is issued.
*/
test("UA-15: a short-paid gateway settlement does NOT confirm the booking (C-4)", async ({ page }) => {
const r = await bookTrip(page, {
paymentMethod: "TELEBIRR",
forgeSettlement: { amountMinor: 1 }, // settle for 1 minor against a multi-thousand fare
});
expect(r.cardBaseFareMinor).toBeGreaterThan(1000);
expect(r.confirmed).toBe(false); // short-pay must NOT confirm the booking
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.status).not.toBe("CONFIRMED");
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-16 — one-way, 2 adults + 3 children under 5, ETB, WALLET (max passenger spread). One free child
* per adult → 2 free children, 1 paid. Total = 3 fares (2 adults + 1 paid child); 3 seats booked.
* Stresses the free-child reduce + multi-passenger seat assignment through the real browser.
*/
test("UA-16: 2 adults + 3 children — two children free, one paid", async ({ page }) => {
const r = await bookTrip(page, { adults: 2, children: 3, paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
const fare = r.cardBaseFareMinor;
expect(r.reviewedTotalMinor).toBe(fare * 3);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(fare * 3);
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
expect(seats.length).toBe(3);
});

View File

@@ -0,0 +1,28 @@
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.
*/
test("UA-1b: USD card display fare diverges 100x from the internal baseFareMinor", async ({ page }) => {
const searchDone = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl({ nationality: "Other" }));
const out = (await (await searchDone).json())?.data?.outbound?.[0];
const cls = out?.faresByClass?.[0];
expect(out.displayCurrency).toBe("USD");
// The display fare is the converted USD amount; baseFareMinor is the untouched ETB minor.
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".
const usdMajor = (cls.displayAmountMinor / 100).toFixed(2);
await expect(page.getByText(new RegExp(`USD\\s*${usdMajor.replace(".", "\\.")}`)).first()).toBeVisible({
timeout: 20_000,
});
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* 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.
*/
test("UA-2: USD booking — reviewed total (ETB) and stored total (USD) diverge 100x", async ({ page }) => {
const r = await bookTrip(page, { nationality: "Other", paymentMethod: "WALLET" });
expect(r.displayCurrency).toBe("USD");
expect(r.confirmed).toBe(true);
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);
expect(booking.displayCurrency).toBe("USD");
expect(booking.status).toBe("CONFIRMED");
});

View File

@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* 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.
*/
test("UA-3w: DJF WALLET booking is stamped DJF; reviewed (DJF) vs stored (ETB) diverge", 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 } });
expect(booking.displayCurrency).toBe("DJF");
// Mirror image of UA-2: here the browser sent the DJF display value as the reviewed total…
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
// …while the booking stored the raw ETB base. The chain uses different currencies at each hop.
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
expect(booking.status).toBe("CONFIRMED");
});
/**
* UA-3 — Djiboutian/DJF paid via a forged gateway settlement. The real telebirr gateway is
* unreachable in the test env, so (per the matrix's settlement-injection plan) the booking is created
* through the real browser flow and settled by forging the payment.succeeded event. Proves the DJF
* booking reaches a CONFIRMED, ticketed state through the gateway (non-WALLET) path.
*/
test("UA-3: DJF booking settles through a forged gateway payment", async ({ page }) => {
const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "TELEBIRR" });
expect(r.displayCurrency).toBe("DJF");
expect(r.confirmed).toBe(true);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.displayCurrency).toBe("DJF");
expect(booking.status).toBe("CONFIRMED");
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-4 — one-way, 1 adult + 1 child under 5, ETB, WALLET. The "first child per adult" policy makes
* the child free: the booked total is exactly one adult fare and the free child is not seated.
*/
test("UA-4: first child under 5 travels free, total = one adult fare", async ({ page }) => {
const r = await bookTrip(page, { adults: 1, children: 1, paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
// The child is free → the browser sent one adult fare as the reviewed total.
expect(r.reviewedTotalMinor).toBe(r.cardBaseFareMinor);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
// The free first-child is filtered out of the booked passengers → only the adult is seated.
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
expect(seats.length).toBe(1);
});

View File

@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookTrip } from "../../fixtures/booking-flow";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-5 — one-way, 1 adult + 2 children under 5, ETB, WALLET. One free child per adult: the first
* child is free, the second is charged a full adult fare. Total = 2 fares; 2 passengers are seated.
*/
test("UA-5: with 1 adult + 2 children, the second child pays full fare", async ({ page }) => {
const r = await bookTrip(page, { adults: 1, children: 2, paymentMethod: "WALLET" });
expect(r.confirmed).toBe(true);
const fare = r.cardBaseFareMinor;
// adult (paid) + first child (free) + second child (paid) = 2 fares.
expect(r.reviewedTotalMinor).toBe(fare * 2);
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBe(fare * 2);
// Only the free first-child is dropped → adult + paid second child are seated.
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
expect(seats.length).toBe(2);
});

View File

@@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
import { resultsUrl } from "../../fixtures/data";
/**
* 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.
*/
test("UA-6: round-trip inbound leg has availability but no priced coach (booking blocked)", 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;
expect(data.journeyType).toBe("ROUND_TRIP");
const inbound = data.inbound?.[0];
expect(inbound).toBeTruthy();
// 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);
});

View File

@@ -0,0 +1,17 @@
import { test } from "@playwright/test";
/**
* UA-7 — round trip, berth/bed class, INTERNATIONAL/USD. DEFERRED (documented, not silently omitted).
*
* A berth booking needs a bed coach type whose seat classes carry a bedPosition the fare engine can
* price. The current seed has only regular (bedPosition=null) classes, and UA-6 already shows the
* reverse-leg (round-trip) pricing returns empty coach types on this route. Enabling UA-7 requires
* two backend/seed prerequisites that are out of scope here:
* 1. A bed CoachType + LOCAL/INTL SeatClasses with bedPosition IN (UPPER,MIDDLE,LOWER) + a bed
* Coach with lowercase-bedPosition Seats (matrix §5.4), priced by the fare engine.
* 2. Reverse-direction (return-leg) fare resolution, currently unsupported (see UA-6).
*
* Once both exist, drive: bookTrip with a bed seat class + tripType ROUND_TRIP, asserting the berth
* surcharge is applied consistently on both legs.
*/
test.skip("UA-7: round-trip berth booking (needs bed coach-type seed + reverse-leg pricing)", () => {});

View File

@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
import { bookOneAdult } from "../../fixtures/booking-flow";
import { PROMO_VALID } from "../../fixtures/data";
const prisma = new PrismaClient();
test.afterAll(async () => {
await prisma.$disconnect();
});
/**
* UA-8 ✅ — a VALID promo is applied server-side even though the browser drops it (H-13). The portal
* still sums UNDISCOUNTED per-passenger fares into reviewedTotalMinor, but the server recomputes the
* authoritative fare (promo included, via the promoCode it forwards) and books the DISCOUNTED total —
* so the customer is charged the promo price, not full price.
*/
test("UA-8: valid promo is applied server-side to the booked total (H-13)", async ({ page }) => {
const r = await bookOneAdult(page, {
nationality: "Ethiopian",
paymentMethod: "WALLET",
promoCode: PROMO_VALID,
});
const fb = r.fareBreakdown;
expect(fb).toBeTruthy();
// The breakdown recognized the promo and computed a discount…
expect(fb.discountMinor).toBeGreaterThan(0);
expect(fb.totalMinor).toBeLessThan(fb.subtotalMinor);
// The browser still sends the UNDISCOUNTED subtotal (the frontend drops the promo)…
expect(r.reviewedTotalMinor).toBe(fb.subtotalMinor);
// …but the SERVER now applies the promo: the booking is stored at the discounted total.
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
expect(booking.totalMinor).toBeLessThan(fb.subtotalMinor); // ✅ discount honored
expect(booking.totalMinor).toBe(fb.subtotalMinor - fb.discountMinor);
});

View File

@@ -0,0 +1,200 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
import { API_URL, SEAT_CLASS_LOCAL, STATIONS, resultsUrl, sampleDepartDate, staffToken, passengerToken } from "../../fixtures/data";
/**
* Track B — backoffice config → portal propagation. A staff user changes config via the same API the
* backoffice calls; the passenger portal is then observed. Each test restores what it changed so the
* shared seeded DB stays consistent for other specs.
*/
/** The "starting from" fare the portal shows for the seeded ETB trip (captured from POST /search). */
async function portalCardFareMinor(page: Page): Promise<number> {
const done = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl(), { waitUntil: "domcontentloaded" });
const body = await (await done).json();
return body?.data?.outbound?.[0]?.faresByClass?.[0]?.baseFareMinor;
}
/** The USD display fare the portal shows for a non-Ethiopian (Other) search. */
async function portalUsdFare(page: Page): Promise<number> {
const done = page.waitForResponse(
(r) => r.url().includes("/search") && r.request().method() === "POST",
);
await page.goto(resultsUrl({ nationality: "Other" }), { waitUntil: "domcontentloaded" });
const body = await (await done).json();
return body?.data?.outbound?.[0]?.faresByClass?.[0]?.displayAmountMinor;
}
function authHeader() {
return { Authorization: `Bearer ${staffToken()}` };
}
/** Find a CurrencyExchangeRate row id by its currency pair. */
async function rateId(request: APIRequestContext, from: string, to: string): Promise<string> {
const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? [];
const row = rows.find((r: any) => r.fromCurrency === from && r.toCurrency === to);
if (!row) throw new Error(`no ${from}->${to} currency rate`);
return row.id;
}
test("PB-2: a backoffice seat-class base-price change propagates LIVE to portal search", async ({
page,
request,
}) => {
const before = await portalCardFareMinor(page);
expect(before).toBeGreaterThan(0);
// Staff doubles the base price via the API the backoffice tariff-rates form uses.
const patched = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 600 }, // seed-core seeds 300
});
expect(patched.ok()).toBeTruthy();
try {
const after = await portalCardFareMinor(page);
// No server-side config cache → the new price shows on the very next search.
expect(after).toBe(before * 2);
} finally {
await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 300 },
});
}
});
test("BC-11 ✅ a non-admin PASSENGER is forbidden from rewriting exchange rates (C-8)", async ({
request,
}) => {
// A global JwtGuard (SharedAuthModule) means anonymous requests get 401 — so this is NOT an
// unauthenticated hole. The PUT/PATCH handlers must ALSO carry @PassengerAdmin (as DELETE does) so
// a regular authenticated passenger cannot rewrite FX rates.
const anon = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999 },
});
expect(anon.status()).toBe(401); // authentication IS required
const asPassenger = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
headers: { Authorization: `Bearer ${passengerToken()}` },
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999, source: "E2E" },
});
expect(asPassenger.status()).toBe(403); // ✅ a regular passenger is forbidden (admin-only)
// The PATCH-by-id handler must be equally protected.
const patchAsPassenger = await request.patch(`${API_URL}/fare-engine/exchange-rates/${crypto.randomUUID()}`, {
headers: { Authorization: `Bearer ${passengerToken()}` },
data: { rate: 999 },
});
expect(patchAsPassenger.status()).toBe(403);
// A staff admin can still write (proves the endpoint isn't simply broken).
const asStaff = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
headers: { Authorization: `Bearer ${staffToken()}` },
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100, source: "E2E" },
});
expect(asStaff.ok()).toBeTruthy();
});
test("BC-7 ✅ negative seat-class base price is rejected by the live API (M-1)", async ({
request,
}) => {
const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: -500 }, // the API must now reject this (DTO @Min(0)), like the backoffice form
});
expect(res.status()).toBe(400); // ✅ negative fare rejected at the DTO layer
// The stored fare is unchanged — a valid write still succeeds and returns the seeded 300.
const restore = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 300 },
});
expect(restore.ok()).toBeTruthy();
expect(((await restore.json())?.data ?? {}).baseFareMinor).toBe(300);
});
test("PB-2b: base-price field-name — /seat-classes accepts `basePrice` and it drives the fare", async ({
request,
}) => {
// Documents which field the live seat-class endpoint reads (basePrice → baseFareMinor). If a future
// change renames it, this fails loudly (the tariff-rates vs /fleet/classes split, matrix §7 Q9).
const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
headers: authHeader(),
data: { basePrice: 300 }, // no-op value, just asserts the field is accepted
});
expect(res.ok()).toBeTruthy();
const json = await res.json();
const updated = json?.data ?? json;
expect(updated.baseFareMinor ?? updated.basePrice).toBe(300);
});
test("PB-1: a backoffice FX-rate change propagates LIVE to portal USD pricing", async ({ page, request }) => {
const id = await rateId(request, "USD", "ETB");
const before = await portalUsdFare(page);
expect(before).toBeGreaterThan(0);
try {
// Doubling the USD→ETB rate doubles the fare-engine's ETB fare and therefore the USD display fare.
const patched = await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 200 } });
expect(patched.ok()).toBeTruthy();
const after = await portalUsdFare(page);
expect(after).toBe(before * 2); // search has no cache → the new rate shows immediately
} finally {
await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 100 } });
}
});
test("PB-4: a station added in the backoffice appears in the portal station list", async ({ request }) => {
const code = `E2E${Date.now() % 100000}`;
const created = await request.post(`${API_URL}/stations`, {
headers: authHeader(),
data: { code, name: `E2E Station ${code}`, city: "Testville", countryCode: "ET", sequence: 99, isOperational: true },
});
expect(created.ok()).toBeTruthy();
const id = ((await created.json())?.data ?? {}).id;
try {
const rows = (await (await request.get(`${API_URL}/stations`)).json())?.data ?? [];
expect(rows.some((s: any) => s.code === code)).toBe(true); // portal SearchWidget reads this list
} finally {
await request.delete(`${API_URL}/stations/${id}?cascade=true`, { headers: authHeader() }).catch(() => {});
}
});
test("PB-10 ✅ deleting an FX rate makes pricing FAIL CLOSED, not a silent 1.0 fallback (M-5/H-2)", async ({ request }) => {
const searchBody = {
originStationId: STATIONS.A,
destinationStationId: STATIONS.C,
date: sampleDepartDate(),
adultCount: 1,
nationality: "OTHER", // USD — the fare engine needs the USD↔ETB rate to price
};
const usdFaresByClass = async (): Promise<any[]> => {
const res = await request.post(`${API_URL}/search`, { headers: authHeader(), data: searchBody });
expect(res.ok()).toBeTruthy();
return (await res.json())?.data?.outbound?.[0]?.faresByClass ?? [];
};
// Control: with the USD→ETB rate present, the USD search returns a real priced fare.
const before = await usdFaresByClass();
expect(before.length).toBeGreaterThan(0);
expect(before[0].displayAmountMinor).toBeGreaterThan(0);
try {
// Remove EVERY USD→ETB rate row (an earlier spec may have left a duplicate) so the pair is truly gone.
const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? [];
for (const r of rows.filter((x: any) => x.fromCurrency === "USD" && x.toCurrency === "ETB")) {
await request.delete(`${API_URL}/currencies/${r.id}`, { headers: authHeader() });
}
// With the USD→ETB pair gone, the fare engine must NOT silently substitute rate 1.0 (~100×
// underpricing). It fails closed — no priced class is returned for the USD trip, instead of a
// bogus parity-priced fare. (A booking attempt would likewise be rejected, not swallowed.)
const after = await usdFaresByClass();
expect(after.length).toBe(0); // ✅ no silent underpricing — no bogus fare offered
} finally {
// Restore the pair so later specs price correctly.
await request.post(`${API_URL}/currencies`, {
headers: authHeader(),
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100 },
});
}
});