mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
201 lines
9.2 KiB
TypeScript
201 lines
9.2 KiB
TypeScript
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 },
|
||
});
|
||
}
|
||
});
|