Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

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