mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Adding all the tests and fixes to the passengers app
This commit is contained in:
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