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