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:
4
e2e-ui/.gitignore
vendored
Normal file
4
e2e-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
fixtures/storage/
|
||||
test-results/
|
||||
../e2e-ui-report/
|
||||
.last-run.json
|
||||
154
e2e-ui/README.md
Normal file
154
e2e-ui/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# EDR Passenger — Playwright UI E2E
|
||||
|
||||
Browser E2E for the passenger platform. **Track A** = portal booking combinations; **Track B** =
|
||||
backoffice config → portal propagation. Scenario matrix: `docs/ui-e2e-test-matrix.md`.
|
||||
|
||||
## Status
|
||||
|
||||
**Track A + Track B implemented and green** — 27 passing specs, 1 documented skip (UA-7). Full suite
|
||||
runs deterministically in ~1.8 min (`workers:1`, one seeded DB shared serially). The whole booking
|
||||
flow is factored into `fixtures/booking-flow.ts` — `bookTrip(page, opts)` drives an arbitrary
|
||||
passenger mix, nationality, trip type, promo, and payment method end to end (search → select →
|
||||
passengers → seats → review → pay → confirmation), capturing the price at each hop; `bookOneAdult` is
|
||||
a thin back-compat wrapper.
|
||||
|
||||
### Coverage vs `docs/ui-e2e-test-matrix.md`
|
||||
|
||||
**Track A — booking combinations** (`specs/portal`, `specs/guest`):
|
||||
|
||||
| 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-3 | `ua3-djf` | DJF booking settles via forged gateway payment |
|
||||
| UA-3w | `ua3-djf` | 🔴 DJF WALLET — reviewed (DJF) vs stored (ETB) diverge |
|
||||
| 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-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) |
|
||||
| UA-14 | `ua14-forged-seat-fare` | ✅ guest forged `seatFareMinor=0` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) |
|
||||
| UA-15 | `ua15-telebirr-shortpay` | ✅ short-paid gateway settlement now REFUSED — booking stays unconfirmed (C-4 fixed & guarded) |
|
||||
| UA-16 | `ua16-family-mix` | 2A+3C → two children free, one paid (3 seats) |
|
||||
|
||||
**Track B — config → portal propagation & validation gaps** (`specs/backoffice`, `specs/propagation`):
|
||||
|
||||
| ID | Spec | What it proves |
|
||||
|----|------|----------------|
|
||||
| PB-1 | `pb-config-propagation` | FX-rate change propagates live to portal USD pricing |
|
||||
| PB-2 / PB-2b | `pb-config-propagation` | seat-class base-price change propagates live; `basePrice` field drives the fare |
|
||||
| PB-4 | `pb-config-propagation` | station added in backoffice appears in the portal station list |
|
||||
| PB-7 | `config-validation` | 🔴 promo created with backoffice UI field names is inert (field-name mismatch) |
|
||||
| PB-10 | `pb-config-propagation` | ✅ deleting an FX rate now FAILS CLOSED (no priced fare) instead of a silent 1.0 collapse (M-5/H-2 fixed & guarded) |
|
||||
| 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-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-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).
|
||||
- **PB-3/5/6/8/9, BC-1…BC-6** — additional config surfaces and delete-referenced/mid-flight/staleness
|
||||
variations of the finding classes already covered above; the matrix marks several as deferrable.
|
||||
|
||||
**Gateway settlement:** the real telebirr gateway is unreachable in the test env (`/payments/initiate`
|
||||
502s), so gateway rows (UA-3, UA-15) create the booking through the real browser flow and then inject
|
||||
settlement via `POST /internal/payments/mark-paid` — exactly the matrix's settlement-injection plan.
|
||||
|
||||
Portal testids used: `result-select-btn`, `coach-option`, `continue-passenger-details`,
|
||||
`pay-method-{TYPE}`. Everything else (passengers form + DOB modal, seats auto-assign, review, payment)
|
||||
is driven via name/placeholder/role selectors — no further source edits were needed.
|
||||
|
||||
**Seed note:** `Passenger.id` is set EQUAL to the IAM user id — see the comment in `seed-ui.ts`
|
||||
(`UI_IDS.passenger`) and the SUSPECTED FINDING below. Each coach seeds 48 seats so a full serial run
|
||||
never exhausts availability across specs.
|
||||
|
||||
## Suspected finding (surfaced while building UA-1)
|
||||
|
||||
`POST /bookings` (authenticated) overrides `passengerId` with the JWT user id
|
||||
(`bookings.controller.ts:528-532`, "never trust the request body"). The service only resolves an
|
||||
iamUserId → Passenger when it is **non-UUID** (`bookings.service.ts:773`). IAM user ids are UUIDs, so
|
||||
the resolver never fires and `booking.create` uses the iamUserId directly as `passengerId` → FK
|
||||
violation unless `Passenger.id == iamUserId`. This is why the seed aligns them. **Verify against a
|
||||
real IAM-authenticated booking** — if `req.user.id` is genuinely the iamUserId in production,
|
||||
authenticated portal bookings may be broken (guest path unaffected). Candidate for `docs/ISSUES.md`.
|
||||
|
||||
## Prerequisites — the running stack
|
||||
|
||||
The suite drives a live stack. `global-setup.ts` seeds + mints auth, but assumes the apps are
|
||||
already up. Bring them up once (leave running across test runs):
|
||||
|
||||
```bash
|
||||
# 1. Infra: test Postgres (5544) + RabbitMQ (5672, payment vhost)
|
||||
bash e2e/prepare.sh # postgres + migrations
|
||||
docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e
|
||||
|
||||
# 2. Build the shared types package (nest build needs the dist)
|
||||
pnpm --filter @edr/types build
|
||||
|
||||
# 3. passenger-api on :4000 against the 5544 DB, with org+staff seeding on
|
||||
# (apps/edr-passenger-api/.env sets DATABASE_URL=…5544, PORT=4000,
|
||||
# RABBITMQ_ENABLED=false, FAYDA_ENABLED=false, SEED_EDR_PASSENGER_ORG=true,
|
||||
# SEED_PASSENGER_STAFF=true, DEFAULT_PASSWORD=Test@1234)
|
||||
( cd apps/edr-passenger-api && pnpm dev ) # background
|
||||
|
||||
# 4. Web apps (each has .env.local → NEXT_PUBLIC_API_URL=http://localhost:4000)
|
||||
( cd apps/edr-passenger-web/portal && pnpm dev ) # :5174, background
|
||||
( cd apps/edr-passenger-web/backoffice && pnpm dev ) # :5184, background
|
||||
```
|
||||
|
||||
> `playwright.config.ts` now declares a `webServer` block that auto-boots api/portal/backoffice and
|
||||
> **reuses** them if already running, so steps 3–4 are optional in local dev. Gateway rows settle via
|
||||
> the internal `mark-paid` endpoint, so `apps/edr-payment-api` (:3003) is **not** required.
|
||||
|
||||
## Run
|
||||
|
||||
One command (infra → build → boot → seed+auth → run → open report):
|
||||
|
||||
```bash
|
||||
bash e2e-ui/run.sh # all projects; args pass through to playwright
|
||||
bash e2e-ui/run.sh --headed # watch it in a real browser
|
||||
SLOWMO=500 bash e2e-ui/run.sh --headed # slow every action by 500ms
|
||||
bash e2e-ui/run.sh --project=portal ua4 # one project / filter by title
|
||||
```
|
||||
|
||||
Or, against an already-running stack:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:ui # all projects
|
||||
pnpm test:e2e:ui -- --project=guest --project=backoffice # smoke only
|
||||
```
|
||||
|
||||
HTML report → `e2e-ui-report/index.html`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
e2e-ui/
|
||||
playwright.config.ts projects: portal (passenger auth), guest (none),
|
||||
backoffice (staff auth), propagation (cross-app)
|
||||
global-setup.ts seeds test DB (seed-ui.ts) + mints staff.json via real /login
|
||||
fixtures/
|
||||
data.ts station IDs, sample depart date, results deep-link helper
|
||||
storage/staff.json generated staff storageState (gitignored)
|
||||
specs/{guest,portal,backoffice,propagation}/*.spec.ts
|
||||
```
|
||||
|
||||
Seed lives with the API harness: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (extends
|
||||
`seed-core.ts` with a bookable Train/Schedule/Coach/Seats, enabled PaymentMethods WALLET+TELEBIRR,
|
||||
promos, funded wallet). Run standalone: `npx ts-node test/fixtures/seed-ui.ts`.
|
||||
|
||||
## Auth model (grounded in the app)
|
||||
|
||||
- **Portal (passenger)**: `localStorage.auth_token` only, no server gate. (passenger storageState is
|
||||
a Phase 3 item — smoke uses the `guest` project.)
|
||||
- **Backoffice (staff)**: middleware requires the `auth_token` **cookie**; API guards check the
|
||||
session's permissions. `global-setup` logs in as the seeded `passenger.admin@edr.local` through
|
||||
the real `/login` UI and snapshots both. No hand-crafted tokens.
|
||||
367
e2e-ui/fixtures/booking-flow.ts
Normal file
367
e2e-ui/fixtures/booking-flow.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { expect, type Locator, type Page, type Route } from "@playwright/test";
|
||||
import { API_URL, CURRENCY_BY_NATIONALITY, resultsUrl } from "./data";
|
||||
|
||||
export type Nationality = "Ethiopian" | "Djiboutian" | "Other";
|
||||
|
||||
export interface PaxSpec {
|
||||
category: "ADULT" | "CHILD";
|
||||
name: string;
|
||||
gender: "Male" | "Female";
|
||||
/** Date of birth. Adults: age 6–110. Children: age < 5 (to be free-eligible). */
|
||||
dob: { d: number; m: number; y: number };
|
||||
/** Adults only. */
|
||||
phone?: string;
|
||||
/** Non-Ethiopian adults only. */
|
||||
passport?: { number: string; country: string; issue: string; expiry: string };
|
||||
}
|
||||
|
||||
export interface TripOptions {
|
||||
nationality?: Nationality;
|
||||
tripType?: "ONE_WAY" | "ROUND_TRIP";
|
||||
/** If `passengers` is omitted, N adults + M children are generated. */
|
||||
adults?: number;
|
||||
children?: number;
|
||||
passengers?: PaxSpec[];
|
||||
/** Promo code injected via the results URL (`?promoCode=`) — the portal has no promo input. */
|
||||
promoCode?: string;
|
||||
/** Mutate the outgoing POST /bookings(/guest) body (e.g. forge reviewedTotalMinor). */
|
||||
mutateBookingBody?: (body: any) => any;
|
||||
/**
|
||||
* When the POST /bookings(/guest) is expected to be rejected (e.g. a forged total the server
|
||||
* must refuse): don't assert a bookingId and return early with `bookingStatus` set, instead of
|
||||
* driving on to payment. Lets a spec assert the server refused the booking.
|
||||
*/
|
||||
tolerateBookingError?: boolean;
|
||||
paymentMethod?: "WALLET" | "TELEBIRR";
|
||||
/**
|
||||
* For TELEBIRR: after initiate, abort the external gateway redirect and forge settlement via the
|
||||
* internal mark-paid endpoint. `amountMinor` lets a test short-pay (settle for the wrong amount).
|
||||
* Defaults to settling for the real booking total.
|
||||
*/
|
||||
forgeSettlement?: { amountMinor?: number };
|
||||
}
|
||||
|
||||
export interface BookingResult {
|
||||
/** displayAmountMinor on the results card (what the passenger sees — passenger currency). */
|
||||
cardDisplayMinor: number;
|
||||
/** baseFareMinor on the results card (internal ETB fare; diverges from display for USD/DJF). */
|
||||
cardBaseFareMinor: number;
|
||||
/** The search response's displayCurrency (ETB/USD/DJF). */
|
||||
displayCurrency: string;
|
||||
/** reviewedTotalMinor the browser actually sent to POST /bookings. */
|
||||
reviewedTotalMinor: number;
|
||||
/** HTTP status the POST /bookings(/guest) returned (2xx on success, 4xx when the server rejects). */
|
||||
bookingStatus: number;
|
||||
bookingId: string;
|
||||
/** Whether the flow used /bookings/guest. */
|
||||
guest: boolean;
|
||||
initiateStatus: number;
|
||||
/** merchantOrderId returned by POST /payments/initiate (gateway methods). */
|
||||
merchantOrderId?: string;
|
||||
/** true once /booking/confirmation is reached. */
|
||||
confirmed: boolean;
|
||||
/** The GET /search/fare-breakdown payload seen on the review page (per-pax fares + discount). */
|
||||
fareBreakdown: any;
|
||||
}
|
||||
|
||||
const PHONE_BY_NATIONALITY: Record<Nationality, string> = {
|
||||
Ethiopian: "912345678",
|
||||
Djiboutian: "77123456",
|
||||
Other: "14155552671",
|
||||
};
|
||||
|
||||
const PASSPORT_COUNTRY: Record<Nationality, string> = {
|
||||
Ethiopian: "",
|
||||
Djiboutian: "Djibouti",
|
||||
Other: "Canada",
|
||||
};
|
||||
|
||||
/** Build a default passenger list: adults first, then children (matches form index → category). */
|
||||
export function makePassengers(adults: number, children: number, nationality: Nationality): PaxSpec[] {
|
||||
const list: PaxSpec[] = [];
|
||||
for (let i = 0; i < adults; i++) {
|
||||
list.push({
|
||||
category: "ADULT",
|
||||
name: `Adult ${i + 1}`,
|
||||
gender: i % 2 === 0 ? "Male" : "Female",
|
||||
dob: { d: 15, m: 6, y: 1990 },
|
||||
phone: PHONE_BY_NATIONALITY[nationality],
|
||||
passport:
|
||||
nationality === "Ethiopian"
|
||||
? undefined
|
||||
: { number: "P1234567", country: PASSPORT_COUNTRY[nationality], issue: "2020-01-01", expiry: "2032-01-01" },
|
||||
});
|
||||
}
|
||||
for (let j = 0; j < children; j++) {
|
||||
// Age ~3 as of 2026 → strictly under 5, so isChild() and the free-child policy apply.
|
||||
list.push({ category: "CHILD", name: `Child ${j + 1}`, gender: "Female", dob: { d: 10, m: 3, y: 2023 } });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Passenger card locator (scoped by the "Passenger N" heading; N is 1-based). */
|
||||
function card(page: Page, i: number): Locator {
|
||||
return page.locator("div.card").filter({ hasText: new RegExp(`Passenger ${i + 1}\\b`) });
|
||||
}
|
||||
|
||||
/** Open the DOB modal for a passenger card, enter the date manually, and confirm. */
|
||||
async function fillDob(page: Page, c: Locator, dob: { d: number; m: number; y: number }) {
|
||||
await c.getByRole("button", { name: /select date of birth/i }).click();
|
||||
await page.getByRole("button", { name: /enter manually/i }).click();
|
||||
await page.getByPlaceholder("DD").fill(String(dob.d));
|
||||
await page.getByPlaceholder("MM").fill(String(dob.m));
|
||||
await page.getByPlaceholder("YYYY").fill(String(dob.y));
|
||||
await page.getByRole("button", { name: /^confirm/i }).click();
|
||||
}
|
||||
|
||||
/** Fill one passenger card (adult or child), revealing the manual form if it's gated. */
|
||||
async function fillPassenger(page: Page, i: number, spec: PaxSpec) {
|
||||
const c = card(page, i);
|
||||
const nameInput = page.locator(`input[name="passengers.${i}.name"]`);
|
||||
// Adults may sit behind a Fayda gate that must be toggled open. Wait for whichever appears first —
|
||||
// the name field (already expanded) or the reveal button — so we never toggle an open form closed.
|
||||
const reveal = c.getByRole("button", { name: /enter details manually|skip for now/i }).first();
|
||||
await Promise.race([
|
||||
nameInput.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}),
|
||||
reveal.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}),
|
||||
]);
|
||||
if (!(await nameInput.isVisible().catch(() => false)) && (await reveal.isVisible().catch(() => false))) {
|
||||
await reveal.click();
|
||||
}
|
||||
await nameInput.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
await nameInput.fill(spec.name);
|
||||
await page.locator(`select[name="passengers.${i}.gender"]`).selectOption(spec.gender);
|
||||
if (spec.category === "ADULT" && spec.phone) {
|
||||
await c.locator('input[type="tel"]').first().fill(spec.phone);
|
||||
}
|
||||
if (spec.passport) {
|
||||
await page.locator(`input[name="passengers.${i}.passportNumber"]`).fill(spec.passport.number);
|
||||
await page.locator(`select[name="passengers.${i}.passportCountry"]`).selectOption(spec.passport.country);
|
||||
await page.locator(`input[name="passengers.${i}.passportIssueDate"]`).fill(spec.passport.issue);
|
||||
await page.locator(`input[name="passengers.${i}.passportExpiryDate"]`).fill(spec.passport.expiry);
|
||||
}
|
||||
await fillDob(page, c, spec.dob);
|
||||
}
|
||||
|
||||
/** Select a coach + continue, once for a one-way leg or twice for a round trip. */
|
||||
async function selectResultsAndContinue(page: Page, roundTrip: boolean) {
|
||||
const pickCoach = async (scope: Locator | Page) => {
|
||||
await (scope as Page).getByTestId("result-select-btn").first().click();
|
||||
await page.getByTestId("coach-option").first().click();
|
||||
await page.getByTestId("continue-passenger-details").first().click();
|
||||
};
|
||||
await pickCoach(page); // outbound (advances to the inbound step for a round trip)
|
||||
if (roundTrip) {
|
||||
// The inbound step re-renders result cards; scope to the inbound section if present.
|
||||
const inbound = page.locator("#inbound-section");
|
||||
const scope = (await inbound.count()) > 0 ? inbound : page;
|
||||
await scope.getByTestId("result-select-btn").first().click();
|
||||
await page.getByTestId("coach-option").first().click();
|
||||
await page.getByTestId("continue-passenger-details").first().click();
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-assign seats (fills all passengers at once and auto-continues). Twice for a round trip. */
|
||||
async function assignSeatsAndContinue(page: Page, roundTrip: boolean) {
|
||||
const autoAssign = () => page.getByRole("button", { name: /auto assign seats/i }).first().click();
|
||||
await autoAssign(); // outbound
|
||||
if (roundTrip) {
|
||||
// After the outbound hold, the page switches to the return-seat map.
|
||||
await page.getByRole("heading", { name: /return seats/i }).waitFor({ timeout: 20_000 });
|
||||
await autoAssign(); // inbound
|
||||
}
|
||||
await page.waitForURL(/\/booking\/review/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the real portal booking flow end to end for an arbitrary passenger mix, nationality,
|
||||
* trip type, promo, and payment method. Captures the price at each hop for DB cross-checks.
|
||||
* Runs as a guest when the page context has no auth token (the `guest` Playwright project).
|
||||
*/
|
||||
export async function bookTrip(page: Page, opts: TripOptions = {}): Promise<BookingResult> {
|
||||
const nationality = opts.nationality ?? "Ethiopian";
|
||||
const tripType = opts.tripType ?? "ONE_WAY";
|
||||
const roundTrip = tripType === "ROUND_TRIP";
|
||||
const passengers =
|
||||
opts.passengers ?? makePassengers(opts.adults ?? 1, opts.children ?? 0, nationality);
|
||||
const adults = passengers.filter((p) => p.category === "ADULT").length;
|
||||
const children = passengers.filter((p) => p.category === "CHILD").length;
|
||||
|
||||
// Optional: forge the POST /bookings body before it leaves the browser.
|
||||
if (opts.mutateBookingBody) {
|
||||
await page.route(/\/bookings(\/guest)?(\?|$)/, async (route: Route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const body = route.request().postDataJSON();
|
||||
await route.continue({ postData: JSON.stringify(opts.mutateBookingBody!(body)) });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search / results ────────────────────────────────────────────────────────
|
||||
const searchDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
const base = resultsUrl({ nationality, adults, children, tripType });
|
||||
await page.goto(opts.promoCode ? `${base}&promoCode=${encodeURIComponent(opts.promoCode)}` : base);
|
||||
const search = await searchDone;
|
||||
const out = (await search.json())?.data?.outbound?.[0];
|
||||
const cardCls = out?.faresByClass?.[0];
|
||||
const cardBaseFareMinor = cardCls?.baseFareMinor;
|
||||
const cardDisplayMinor = cardCls?.displayAmountMinor ?? cardBaseFareMinor;
|
||||
const displayCurrency = out?.displayCurrency ?? CURRENCY_BY_NATIONALITY[nationality];
|
||||
expect(cardBaseFareMinor).toBeGreaterThan(0);
|
||||
|
||||
await selectResultsAndContinue(page, roundTrip);
|
||||
// Both authenticated users and guests may pass through the auth-check interstitial: authenticated
|
||||
// users auto-forward to passengers, guests must click "Continue as guest". Handle whichever wins.
|
||||
await page.waitForURL(/\/booking\/(passengers|auth-check)/, { timeout: 30_000 });
|
||||
if (/\/booking\/auth-check/.test(page.url())) {
|
||||
await Promise.race([
|
||||
page.waitForURL(/\/booking\/passengers/, { timeout: 15_000 }).catch(() => {}),
|
||||
page
|
||||
.getByRole("button", { name: /continue as guest/i })
|
||||
.click({ timeout: 15_000 })
|
||||
.catch(() => {}),
|
||||
]);
|
||||
await page.waitForURL(/\/booking\/passengers/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ── Passenger form ────────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < passengers.length; i++) await fillPassenger(page, i, passengers[i]);
|
||||
await page.getByRole("button", { name: /continue to seat selection/i }).click();
|
||||
await page.waitForURL(/\/booking\/seats/, { timeout: 30_000 });
|
||||
|
||||
// ── Seats: auto-assign → hold → review ────────────────────────────────────────
|
||||
const fbDone = page
|
||||
.waitForResponse((r) => r.url().includes("/search/fare-breakdown"), { timeout: 25_000 })
|
||||
.catch(() => null);
|
||||
await assignSeatsAndContinue(page, roundTrip);
|
||||
const fbRes = await fbDone;
|
||||
const fbJson = fbRes ? await fbRes.json() : null;
|
||||
const fareBreakdown = fbJson?.data ?? fbJson;
|
||||
|
||||
// ── Review: confirm → POST /bookings(/guest) ──────────────────────────────────
|
||||
const bookingDone = page.waitForResponse(
|
||||
(r) => /\/bookings(\/guest)?(\?|$)/.test(r.url()) && r.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: /^confirm/i }).first().click();
|
||||
const bookingRes = await bookingDone;
|
||||
const bookingStatus = bookingRes.status();
|
||||
const guest = bookingRes.url().includes("/bookings/guest");
|
||||
const reviewedTotalMinor = bookingRes.request().postDataJSON()?.reviewedTotalMinor;
|
||||
|
||||
// Expected-rejection path: the server refused the booking (e.g. a forged total). Return early
|
||||
// with the status so the caller can assert the refusal; there is no booking to drive to payment.
|
||||
if (opts.tolerateBookingError && !bookingRes.ok()) {
|
||||
return {
|
||||
cardDisplayMinor,
|
||||
cardBaseFareMinor,
|
||||
displayCurrency,
|
||||
reviewedTotalMinor,
|
||||
bookingStatus,
|
||||
bookingId: "",
|
||||
guest,
|
||||
initiateStatus: 0,
|
||||
confirmed: false,
|
||||
fareBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
const bookingData = (await bookingRes.json())?.data ?? {};
|
||||
const bookingId = bookingData.id ?? bookingData.bookingId;
|
||||
expect(bookingId).toBeTruthy();
|
||||
await page.waitForURL(/\/booking\/(payment|confirmation)/, { timeout: 30_000 });
|
||||
|
||||
const result: BookingResult = {
|
||||
cardDisplayMinor,
|
||||
cardBaseFareMinor,
|
||||
displayCurrency,
|
||||
reviewedTotalMinor,
|
||||
bookingStatus,
|
||||
bookingId,
|
||||
guest,
|
||||
initiateStatus: 0,
|
||||
confirmed: false,
|
||||
fareBreakdown,
|
||||
};
|
||||
|
||||
// A zero-total booking skips payment and lands straight on confirmation.
|
||||
if (/\/booking\/confirmation/.test(page.url())) {
|
||||
result.confirmed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Payment ─────────────────────────────────────────────────────────────────
|
||||
const method = opts.paymentMethod ?? "WALLET";
|
||||
|
||||
if (method === "WALLET") {
|
||||
// WALLET settles fully server-side, synchronously → straight to /booking/confirmation.
|
||||
const initiateDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/payments/initiate") && r.request().method() === "POST",
|
||||
);
|
||||
await page.getByTestId("pay-method-WALLET").first().click();
|
||||
await page.getByRole("button", { name: /^pay\b/i }).first().click();
|
||||
result.initiateStatus = (await initiateDone).status();
|
||||
result.confirmed = await page
|
||||
.waitForURL(/\/booking\/confirmation/, { timeout: 25_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Gateway (TELEBIRR): the real provider is unreachable in the test env (initiate 502s), so we do
|
||||
// what the matrix prescribes — inject settlement. The booking is already created through the real
|
||||
// browser flow and sits in PENDING_PAYMENT; we forge the payment.succeeded event to the internal
|
||||
// mark-paid endpoint (ungated when SERVICE_AUTH_TOKEN is unset), then let the confirmation page's
|
||||
// poll flip to CONFIRMED. `forgeSettlement.amountMinor` lets a test short-pay (settle wrong amount).
|
||||
const amountMinor = opts.forgeSettlement?.amountMinor ?? reviewedTotalMinor;
|
||||
// mark-paid sits behind the global JwtGuard (any valid token passes; ServiceAuthGuard is a no-op
|
||||
// when SERVICE_AUTH_TOKEN is unset). Reuse the logged-in passenger's token from localStorage.
|
||||
const authToken = await page.evaluate(() => localStorage.getItem("auth_token"));
|
||||
const markPaid = await page.request.post(`${API_URL}/internal/payments/mark-paid`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
data: {
|
||||
version: 1,
|
||||
eventId: crypto.randomUUID(), // @IsUUID
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: new Date().toISOString(),
|
||||
service: "PASSENGER",
|
||||
intentId: crypto.randomUUID(), // @IsUUID
|
||||
referenceType: "BOOKING",
|
||||
referenceId: bookingId,
|
||||
merchantOrderId: `e2e-${bookingId}`,
|
||||
provider: "TELEBIRR",
|
||||
amountMinor,
|
||||
currency: "ETB",
|
||||
providerTxnId: `e2e-txn-${bookingId}`,
|
||||
paidAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
result.initiateStatus = markPaid.status();
|
||||
// mark-paid finalizes synchronously; confirm authoritatively via the booking status API (the
|
||||
// confirmation page's DOM depends on the client store, which a direct navigation may not carry).
|
||||
await page.goto("/booking/confirmation");
|
||||
for (let attempt = 0; attempt < 10 && !result.confirmed; attempt++) {
|
||||
const res = await page.request.get(`${API_URL}/bookings/${bookingId}`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
const status = ((await res.json().catch(() => ({})))?.data ?? {})?.status;
|
||||
if (status === "CONFIRMED") result.confirmed = true;
|
||||
else await page.waitForTimeout(500);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Back-compat wrapper: one-way single adult (used by the original UA-1/8/13 specs). */
|
||||
export interface BookingOptions {
|
||||
nationality?: Nationality;
|
||||
promoCode?: string;
|
||||
mutateBookingBody?: (body: any) => any;
|
||||
tolerateBookingError?: boolean;
|
||||
paymentMethod?: "WALLET" | "TELEBIRR";
|
||||
}
|
||||
export async function bookOneAdult(page: Page, opts: BookingOptions = {}) {
|
||||
const r = await bookTrip(page, { ...opts, adults: 1, children: 0, tripType: "ONE_WAY" });
|
||||
// Preserve the original field name used by the existing specs.
|
||||
return { ...r, cardFareMinor: r.cardBaseFareMinor };
|
||||
}
|
||||
92
e2e-ui/fixtures/data.ts
Normal file
92
e2e-ui/fixtures/data.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/** Shared constants mirroring apps/edr-passenger-api/test/fixtures/{seed-core,seed-ui}.ts. */
|
||||
export const STATIONS = {
|
||||
A: "00000000-0000-4000-8000-000000000020", // Alpha / AAA
|
||||
B: "00000000-0000-4000-8000-000000000021", // Bravo / BBB
|
||||
C: "00000000-0000-4000-8000-000000000022", // Charlie / CCC
|
||||
} as const;
|
||||
|
||||
export const SCHEDULE_ID = "00000000-0000-4000-8000-000000000101";
|
||||
export const RETURN_SCHEDULE_ID = "00000000-0000-4000-8000-000000000201";
|
||||
export const SEAT_CLASS_LOCAL = "00000000-0000-4000-8000-000000000010";
|
||||
export const SEAT_CLASS_INTL = "00000000-0000-4000-8000-000000000011";
|
||||
export const COACH_TYPE_ID = "00000000-0000-4000-8000-000000000001";
|
||||
export const ROUTE_ID = "00000000-0000-4000-8000-000000000030";
|
||||
export const PROMO_VALID = "PROMO10";
|
||||
export const PROMO_EXPIRED = "EXPIRED50";
|
||||
|
||||
export const API_URL = process.env.API_URL ?? "http://localhost:4000";
|
||||
|
||||
/** Friendly nationality name → the enum the portal/search expects. */
|
||||
export const NATIONALITY_ENUM = {
|
||||
Ethiopian: "ETHIOPIAN",
|
||||
Djiboutian: "DJIBOUTIAN",
|
||||
Other: "OTHER",
|
||||
} as const;
|
||||
export type Nationality = keyof typeof NATIONALITY_ENUM;
|
||||
|
||||
/** Display currency the search returns per nationality (asserted by the currency specs). */
|
||||
export const CURRENCY_BY_NATIONALITY = {
|
||||
Ethiopian: "ETB",
|
||||
Djiboutian: "DJF",
|
||||
Other: "USD",
|
||||
} as const;
|
||||
|
||||
function tokenFrom(file: string): string {
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "storage", file), "utf8"));
|
||||
for (const origin of raw.origins ?? []) {
|
||||
for (const item of origin.localStorage ?? []) {
|
||||
if (item.name === "auth_token") return item.value as string;
|
||||
}
|
||||
}
|
||||
throw new Error(`auth_token not found in ${file} — did global-setup run?`);
|
||||
}
|
||||
|
||||
/** Staff/admin auth token minted by global-setup (backoffice storageState). */
|
||||
export function staffToken(): string {
|
||||
return tokenFrom("staff.json");
|
||||
}
|
||||
|
||||
/** Regular passenger (non-admin) auth token minted by global-setup (portal storageState). */
|
||||
export function passengerToken(): string {
|
||||
return tokenFrom("passenger.json");
|
||||
}
|
||||
|
||||
/** Must match seed-ui.sampleDepartAt(): now + 2 days at 06:00Z. */
|
||||
export function sampleDepartDate(): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + 2);
|
||||
d.setUTCHours(6, 0, 0, 0);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link to the results page (bypasses the search form, which cannot emit tripType/returnDate).
|
||||
* `nationality` accepts the friendly name ("Ethiopian"|"Djiboutian"|"Other") and is emitted as the
|
||||
* enum the results page expects. For a round trip, pass tripType "ROUND_TRIP" — returnDate defaults
|
||||
* to the same calendar day (the seeded return leg departs 8h after the outbound).
|
||||
*/
|
||||
export function resultsUrl(opts?: {
|
||||
adults?: number;
|
||||
children?: number;
|
||||
nationality?: string;
|
||||
tripType?: "ONE_WAY" | "ROUND_TRIP";
|
||||
returnDate?: string;
|
||||
}) {
|
||||
const nat = opts?.nationality ?? "Ethiopian";
|
||||
const enumNat = (NATIONALITY_ENUM as Record<string, string>)[nat] ?? nat.toUpperCase();
|
||||
const p = new URLSearchParams({
|
||||
origin: STATIONS.A,
|
||||
destination: STATIONS.C,
|
||||
date: sampleDepartDate(),
|
||||
tripType: opts?.tripType ?? "ONE_WAY",
|
||||
adults: String(opts?.adults ?? 1),
|
||||
children: String(opts?.children ?? 0),
|
||||
nationality: enumNat,
|
||||
});
|
||||
if ((opts?.tripType ?? "ONE_WAY") === "ROUND_TRIP") {
|
||||
p.set("returnDate", opts?.returnDate ?? sampleDepartDate());
|
||||
}
|
||||
return `/booking/results?${p.toString()}`;
|
||||
}
|
||||
74
e2e-ui/global-setup.ts
Normal file
74
e2e-ui/global-setup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { seedUi } from "../apps/edr-passenger-api/test/fixtures/seed-ui";
|
||||
import { seedPassengerSession } from "../apps/edr-passenger-api/test/fixtures/seed-passenger-session";
|
||||
|
||||
/**
|
||||
* Playwright global-setup for the UI E2E suite.
|
||||
* 1. Seeds the 5544 test DB with the bookable trip + payment methods + promos (seed-ui.ts).
|
||||
* 2. Mints a passenger IAM session + token → passenger.json storageState (localStorage).
|
||||
* 3. Logs in as the seeded backoffice admin via the REAL /login UI → staff.json storageState.
|
||||
*
|
||||
* Assumes the stack is already running (api :4000, portal :5174, backoffice :5184).
|
||||
*/
|
||||
const STORAGE_DIR = path.join(__dirname, "fixtures", "storage");
|
||||
const API = process.env.API_URL ?? "http://localhost:4000";
|
||||
const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174";
|
||||
const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184";
|
||||
const DB_URL =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger";
|
||||
const STAFF = { email: "passenger.admin@edr.local", password: process.env.DEFAULT_PASSWORD ?? "Test@1234" };
|
||||
|
||||
export default async function globalSetup(_config: FullConfig) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
process.env.DATABASE_URL = DB_URL;
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
console.log("[global-setup] seeding test DB…");
|
||||
await seedUi(prisma);
|
||||
|
||||
console.log("[global-setup] minting passenger session…");
|
||||
const { token } = await seedPassengerSession(prisma);
|
||||
const profileRes = await fetch(`${API}/auth/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!profileRes.ok) throw new Error(`/auth/profile failed: HTTP ${profileRes.status}`);
|
||||
const profile = (await profileRes.json())?.data ?? {};
|
||||
|
||||
const passengerState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: PORTAL,
|
||||
localStorage: [
|
||||
{ name: "auth_token", value: token },
|
||||
{ name: "auth_user", value: JSON.stringify(profile) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(path.join(STORAGE_DIR, "passenger.json"), JSON.stringify(passengerState));
|
||||
console.log("[global-setup] passenger.json written");
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
console.log("[global-setup] minting staff storageState via real login…");
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BACKOFFICE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator('input[type="email"]').fill(STAFF.email);
|
||||
await page.locator('input[type="password"]').fill(STAFF.password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }),
|
||||
page.locator('button[type="submit"]').click(),
|
||||
]);
|
||||
await ctx.storageState({ path: path.join(STORAGE_DIR, "staff.json") });
|
||||
console.log("[global-setup] staff.json written");
|
||||
await browser.close();
|
||||
}
|
||||
94
e2e-ui/playwright.config.ts
Normal file
94
e2e-ui/playwright.config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import * as path from "node:path";
|
||||
|
||||
// Defaults so `pnpm test:e2e:ui` runs standalone. Must match apps/edr-passenger-api/.env.
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger";
|
||||
process.env.JWT_ACCESS_TOKEN_SECRET ??= "test-access-secret-0000000000000000000000";
|
||||
process.env.DEFAULT_PASSWORD ??= "Test@1234";
|
||||
|
||||
/**
|
||||
* Playwright UI E2E for the EDR passenger platform.
|
||||
* Track A (portal booking combinations) + Track B (backoffice config → portal propagation).
|
||||
* See docs/ui-e2e-test-matrix.md. global-setup boots/awaits the stack, seeds the 5544 test DB,
|
||||
* and mints the passenger + staff storageStates.
|
||||
*/
|
||||
const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174";
|
||||
const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184";
|
||||
const STORAGE = path.join(__dirname, "fixtures", "storage");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.join(__dirname, "specs"),
|
||||
fullyParallel: false, // shared seeded DB — serialize to keep assertions deterministic
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
globalSetup: path.join(__dirname, "global-setup.ts"),
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: path.join(__dirname, "..", "e2e-ui-report"), open: "never" }],
|
||||
],
|
||||
// Boot the app tier automatically; reuse it if it's already running (dev). Infra (Postgres 5544,
|
||||
// RabbitMQ, migrations, @edr/types build) is handled by e2e-ui/run.sh BEFORE Playwright starts.
|
||||
webServer: [
|
||||
{
|
||||
command: "pnpm --filter @edr/passenger-api dev",
|
||||
url: "http://localhost:4000/stations",
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: true,
|
||||
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/passenger-portal dev",
|
||||
url: PORTAL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: true,
|
||||
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/passenger-backoffice dev",
|
||||
url: `${BACKOFFICE}/login`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: true,
|
||||
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
|
||||
},
|
||||
],
|
||||
use: {
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: 15_000,
|
||||
// SLOWMO=500 bash e2e-ui/run.sh --headed → pause 500ms between each browser action
|
||||
launchOptions: { slowMo: Number(process.env.SLOWMO ?? 0) },
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "portal", // Track A — logged-in passenger
|
||||
testMatch: /specs\/portal\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: PORTAL,
|
||||
storageState: path.join(STORAGE, "passenger.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "guest", // Track A — guest bookings (no auth)
|
||||
testMatch: /specs\/guest\/.*\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: PORTAL },
|
||||
},
|
||||
{
|
||||
name: "backoffice", // Track B — staff/admin
|
||||
testMatch: /specs\/backoffice\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: BACKOFFICE,
|
||||
storageState: path.join(STORAGE, "staff.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propagation", // cross-app: staff writes config via API → passenger portal reads
|
||||
testMatch: /specs\/propagation\/.*\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: PORTAL },
|
||||
},
|
||||
],
|
||||
});
|
||||
40
e2e-ui/run.sh
Executable file
40
e2e-ui/run.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-command UI E2E: infra → build → boot app tier (via Playwright webServer) → seed+auth → run →
|
||||
# open the HTML report. Idempotent; reuses an already-running stack. Any args pass through to
|
||||
# playwright (e.g. `bash e2e-ui/run.sh --project=portal ua1`).
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$HERE/.."
|
||||
API="$ROOT/apps/edr-passenger-api"
|
||||
export GITHUB_PACKAGE_TOKEN="${GITHUB_PACKAGE_TOKEN:-dummy}"
|
||||
|
||||
echo "==> 1/4 Infra: Postgres (5544) + RabbitMQ (5672) + migrations"
|
||||
bash "$ROOT/e2e/prepare.sh"
|
||||
echo " waiting for RabbitMQ healthy"
|
||||
for _ in $(seq 1 30); do
|
||||
s="$(docker inspect --format '{{.State.Health.Status}}' edr-passenger-e2e-rmq 2>/dev/null || echo none)"
|
||||
[ "$s" = "healthy" ] && break; sleep 2
|
||||
done
|
||||
|
||||
echo "==> 2/4 Build shared types (@edr/types dist — nest build needs it)"
|
||||
pnpm --filter @edr/types build >/dev/null
|
||||
|
||||
echo "==> 3/4 Ensure passenger-api dev env (test DB 5544, port 4000, brokers/Fayda off, seeding on)"
|
||||
if [ ! -f "$API/.env" ]; then
|
||||
sed -e 's/^PORT=.*/PORT=4000/' \
|
||||
-e 's/^SEED_EDR_PASSENGER_ORG=.*/SEED_EDR_PASSENGER_ORG=true/' \
|
||||
-e 's/^SEED_PASSENGER_STAFF=.*/SEED_PASSENGER_STAFF=true/' \
|
||||
"$API/.env.test" > "$API/.env"
|
||||
echo " created $API/.env"
|
||||
fi
|
||||
|
||||
echo "==> 4/4 Playwright (boots api/portal/backoffice if not already up, seeds + mints auth, runs)"
|
||||
npx playwright test -c "$HERE/playwright.config.ts" "$@" || TEST_EXIT=$?
|
||||
|
||||
REPORT="$ROOT/e2e-ui-report/index.html"
|
||||
if [ -f "$REPORT" ]; then
|
||||
echo "==> Report: $REPORT"
|
||||
open "$REPORT" 2>/dev/null || true
|
||||
fi
|
||||
exit "${TEST_EXIT:-0}"
|
||||
79
e2e-ui/specs/backoffice/config-validation.spec.ts
Normal file
79
e2e-ui/specs/backoffice/config-validation.spec.ts
Normal 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(() => {});
|
||||
});
|
||||
19
e2e-ui/specs/backoffice/currencies.smoke.spec.ts
Normal file
19
e2e-ui/specs/backoffice/currencies.smoke.spec.ts
Normal 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();
|
||||
});
|
||||
24
e2e-ui/specs/guest/search.smoke.spec.ts
Normal file
24
e2e-ui/specs/guest/search.smoke.spec.ts
Normal 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();
|
||||
});
|
||||
37
e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts
Normal file
37
e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts
Normal 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();
|
||||
});
|
||||
31
e2e-ui/specs/portal/ua1.spec.ts
Normal file
31
e2e-ui/specs/portal/ua1.spec.ts
Normal 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");
|
||||
});
|
||||
25
e2e-ui/specs/portal/ua11-expired-promo.spec.ts
Normal file
25
e2e-ui/specs/portal/ua11-expired-promo.spec.ts
Normal 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
|
||||
});
|
||||
40
e2e-ui/specs/portal/ua13-forged-total.spec.ts
Normal file
40
e2e-ui/specs/portal/ua13-forged-total.spec.ts
Normal 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();
|
||||
});
|
||||
27
e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts
Normal file
27
e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts
Normal 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");
|
||||
});
|
||||
27
e2e-ui/specs/portal/ua16-family-mix.spec.ts
Normal file
27
e2e-ui/specs/portal/ua16-family-mix.spec.ts
Normal 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);
|
||||
});
|
||||
28
e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts
Normal file
28
e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
33
e2e-ui/specs/portal/ua2-usd-booking.spec.ts
Normal file
33
e2e-ui/specs/portal/ua2-usd-booking.spec.ts
Normal 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");
|
||||
});
|
||||
43
e2e-ui/specs/portal/ua3-djf.spec.ts
Normal file
43
e2e-ui/specs/portal/ua3-djf.spec.ts
Normal 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");
|
||||
});
|
||||
27
e2e-ui/specs/portal/ua4-child-free.spec.ts
Normal file
27
e2e-ui/specs/portal/ua4-child-free.spec.ts
Normal 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);
|
||||
});
|
||||
28
e2e-ui/specs/portal/ua5-second-child-paid.spec.ts
Normal file
28
e2e-ui/specs/portal/ua5-second-child-paid.spec.ts
Normal 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);
|
||||
});
|
||||
35
e2e-ui/specs/portal/ua6-round-trip.spec.ts
Normal file
35
e2e-ui/specs/portal/ua6-round-trip.spec.ts
Normal 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);
|
||||
});
|
||||
17
e2e-ui/specs/portal/ua7-berth.spec.ts
Normal file
17
e2e-ui/specs/portal/ua7-berth.spec.ts
Normal 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)", () => {});
|
||||
37
e2e-ui/specs/portal/ua8-promo-drop.spec.ts
Normal file
37
e2e-ui/specs/portal/ua8-promo-drop.spec.ts
Normal 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);
|
||||
});
|
||||
200
e2e-ui/specs/propagation/pb-config-propagation.spec.ts
Normal file
200
e2e-ui/specs/propagation/pb-config-propagation.spec.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user