Files
edr-platform/e2e-ui/specs/propagation/pb-config-propagation.spec.ts

201 lines
9.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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