mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
test: add EDR passenger pricing/config E2E bug-hunt harness
Hermetic E2E harness targeting pricing integrity and backoffice config: - e2e/ docker Postgres (5544) + prepare.sh/run.sh one-command runner + HTML report - 6 suites / 23 tests reproducing pricing, FX, wallet, refund, config and auth defects (see docs/ISSUES.md); docs/e2e-test-matrix.md documents the matrix - two-tier harness (slim module boot + direct service instantiation) to work around the IAM/RabbitMQ/file-type boot wall - .env.test.example tracked; loader falls back to it for fresh checkouts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
29
apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts
Normal file
29
apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Auth/authorization gaps (matrix Suite J), proven via route guard metadata — no boot needed.
|
||||
* J1 🔴 The exchange-rate controller's write routes (PUT upsert, PATCH update) carry NO guard,
|
||||
* so USD/ETB/DJF rates — which every international fare multiplies by — can be rewritten by
|
||||
* an unauthenticated caller. Only DELETE is guarded (@PassengerAdmin). fare-engine/currency.controller.ts:25,32,42
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
||||
|
||||
// Nest stores @UseGuards under the "__guards__" metadata key on the route handler.
|
||||
const GUARDS_METADATA = "__guards__";
|
||||
|
||||
function guardsOn(handler: unknown): unknown[] {
|
||||
return (Reflect.getMetadata(GUARDS_METADATA, handler as object) as unknown[]) ?? [];
|
||||
}
|
||||
|
||||
describe("Auth gaps (Suite J)", () => {
|
||||
it("J1 🔴 PUT upsert exchange-rate has NO guard (unauthenticated FX write)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("J1 🔴 PATCH update exchange-rate has NO guard (unauthenticated FX write)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("J1 control: DELETE exchange-rate IS guarded — proving the omission on writes is not global", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
74
apps/edr-passenger-api/test/config-validation.e2e-spec.ts
Normal file
74
apps/edr-passenger-api/test/config-validation.e2e-spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Backoffice config validation suite (matrix Suite H). The global ValidationPipe in src/main.ts:56
|
||||
* enforces exactly these class-validator DTOs, so validating the DTOs directly reproduces what a
|
||||
* raw API call (bypassing the HTML-only frontend checks) would be allowed to submit.
|
||||
* H1 🔴 CreateFareRuleDto.baseFareMinor accepts NEGATIVE (no @Min) — while the sibling
|
||||
* CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent).
|
||||
* H2 🔴 CreateSeatClassDto.basePrice accepts negative/zero (no @Min) — drives every distance fare.
|
||||
* H4 🔴 CreatePromotionDto.percentOff accepts 200 (no @Max(100)) → discount > subtotal.
|
||||
* H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validate } from "class-validator";
|
||||
|
||||
import { CreateFareRuleDto } from "../src/modules/schedules/schedules.dto";
|
||||
import { CreateSegmentFareDto } from "../src/modules/segments/segment-fare.dto";
|
||||
import { CreateSeatClassDto } from "../src/modules/seat-classes/seat-classes.dto";
|
||||
import { CreatePromotionDto } from "../src/modules/promos/promos.dto";
|
||||
|
||||
/** Property names that produced a validation error. */
|
||||
async function erroredProps(dto: object): Promise<string[]> {
|
||||
const errors = await validate(dto);
|
||||
return errors.map((e) => e.property);
|
||||
}
|
||||
|
||||
describe("Backoffice config validation (Suite H)", () => {
|
||||
it("H1 🔴 CreateFareRuleDto accepts a NEGATIVE baseFareMinor (no @Min)", async () => {
|
||||
const dto = plainToInstance(CreateFareRuleDto, {
|
||||
seatClassId: "sc-1",
|
||||
baseFareMinor: -100,
|
||||
validFrom: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("baseFareMinor");
|
||||
});
|
||||
|
||||
it("H1 contrast: sibling CreateSegmentFareDto REJECTS negative baseFareMinor (@Min(0))", async () => {
|
||||
const dto = plainToInstance(CreateSegmentFareDto, {
|
||||
routeId: "rt-1",
|
||||
originStopSequence: 1,
|
||||
destinationStopSequence: 5,
|
||||
seatClassId: "sc-1",
|
||||
baseFareMinor: -100,
|
||||
});
|
||||
expect(await erroredProps(dto)).toContain("baseFareMinor");
|
||||
});
|
||||
|
||||
it("H2 🔴 CreateSeatClassDto accepts a negative basePrice (no @Min)", async () => {
|
||||
const dto = plainToInstance(CreateSeatClassDto, {
|
||||
coachTypeId: "ct-1",
|
||||
name: "Economy",
|
||||
basePrice: -5000,
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("basePrice");
|
||||
});
|
||||
|
||||
it("H4 🔴 CreatePromotionDto accepts percentOff = 200 (no @Max(100))", async () => {
|
||||
const dto = plainToInstance(CreatePromotionDto, {
|
||||
code: "OVER",
|
||||
title: "Overshoot",
|
||||
percentOff: 200,
|
||||
validUntil: "2026-12-31T23:59:59Z",
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("percentOff");
|
||||
});
|
||||
|
||||
it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => {
|
||||
const dto = plainToInstance(CreatePromotionDto, {
|
||||
code: "BADDATE",
|
||||
title: "Bad date",
|
||||
validUntil: "not-a-real-date",
|
||||
});
|
||||
expect(await erroredProps(dto)).not.toContain("validUntil");
|
||||
});
|
||||
});
|
||||
275
apps/edr-passenger-api/test/critical-repro.e2e-spec.ts
Normal file
275
apps/edr-passenger-api/test/critical-repro.e2e-spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Executable reproducers for the highest-severity findings that were previously inspection-only.
|
||||
* All Tier-2 (direct instantiation, real Prisma + stubbed collaborators).
|
||||
*
|
||||
* C-1 🔴 BookingsService trusts client `reviewedTotalMinor`: a booking is stored with totalMinor=1
|
||||
* while the server fare engine computed ~30000.
|
||||
* C-4 🔴 finalizePaymentSuccess confirms a booking without comparing the paid amount: an intent for
|
||||
* 1 minor confirms a 30000 booking.
|
||||
* C-6 🔴 Concurrent WALLET payments double-spend one balance (no row lock): a wallet funded for one
|
||||
* ticket pays for two.
|
||||
*/
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { PaymentsService } from "../src/modules/payments/payments.service";
|
||||
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
function asyncStub(): any {
|
||||
return new Proxy({}, { get: () => async () => undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a PrismaClient so that inside `$transaction(cb)`, every `walletAccount.update` waits until
|
||||
* BOTH concurrent transactions have finished their `walletAccount.findUnique` (balance read). This
|
||||
* deterministically forces the exact interleaving a real multi-request system permits, exposing the
|
||||
* service's unlocked check-then-act (no SELECT … FOR UPDATE). Only scheduling is controlled — the
|
||||
* service's own logic runs unmodified.
|
||||
*/
|
||||
function makeRaceWrappedPrisma(real: any, parties: number) {
|
||||
let arrived = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
const signalRead = () => {
|
||||
if (++arrived >= parties) release();
|
||||
};
|
||||
|
||||
return new Proxy(real, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "$transaction") {
|
||||
return (cb: (tx: any) => unknown, opts?: unknown) =>
|
||||
target.$transaction((tx: any) => {
|
||||
const wrappedTx = new Proxy(tx, {
|
||||
get(t, p) {
|
||||
if (p === "walletAccount") {
|
||||
return {
|
||||
findUnique: async (args: unknown) => {
|
||||
const res = await t.walletAccount.findUnique(args);
|
||||
signalRead();
|
||||
return res;
|
||||
},
|
||||
update: async (args: unknown) => {
|
||||
await gate; // hold the write until both reads are done
|
||||
return t.walletAccount.update(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
return t[p];
|
||||
},
|
||||
});
|
||||
return cb(wrappedTx);
|
||||
}, opts);
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function makeSchedule(prisma: any) {
|
||||
const train = await prisma.train.create({ data: { number: `CR-${++seq}`, name: "T" } });
|
||||
return prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Critical reproducers (Tier-2)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
// ── C-1 ──────────────────────────────────────────────────────────────────
|
||||
it("C-1 🔴 booking stores client reviewedTotalMinor=1 while the fare engine computed ~30000", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma);
|
||||
// Stop times so origin/dest resolve on the schedule.
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
|
||||
],
|
||||
});
|
||||
// Coach + seat for the passenger to occupy.
|
||||
const coach = await prisma.coach.create({
|
||||
data: { coachTypeId: IDS.coachType, number: `C-${seq}` },
|
||||
});
|
||||
const seat = await prisma.seat.create({
|
||||
data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" },
|
||||
});
|
||||
// A real server fare source (tripId match → highest priority): 30000 minor.
|
||||
await prisma.fareRule.create({
|
||||
data: {
|
||||
tripId: schedule.id,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
baseFareMinor: 30_000,
|
||||
currency: "ETB",
|
||||
validFrom: new Date("2020-01-01"),
|
||||
},
|
||||
});
|
||||
const hold = await prisma.seatHold.create({
|
||||
data: {
|
||||
scheduleId: schedule.id,
|
||||
seatIds: [seat.id],
|
||||
passengerId: passenger.id,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = new BookingsService(
|
||||
prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
asyncStub(), // seatsService (confirmSeats no-op)
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService (PASSPORT path skips it anyway)
|
||||
asyncStub(), // currencyService (ETB path skips it)
|
||||
asyncStub(), // fareEngine (FareRule short-circuits before this)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
const dto = {
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
bookingType: "ONE_WAY",
|
||||
reviewedTotalMinor: 1, // the forged client total
|
||||
passengers: [
|
||||
{
|
||||
seatId: seat.id,
|
||||
passengerName: "Mallory Adult",
|
||||
dateOfBirth: new Date("1990-01-01"),
|
||||
idDocumentType: "PASSPORT",
|
||||
passportNumber: "P123",
|
||||
passportCountry: "ET",
|
||||
nationality: "Ethiopian",
|
||||
// NOTE: no seatFareMinor → not "allFaresProvided" → reviewedTotalMinor is trusted
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result: any = await (bookings as any).createOneWayBooking(dto);
|
||||
|
||||
// The server engine computed the real fare…
|
||||
expect(result.fareBreakdown.totalMinor).toBeGreaterThanOrEqual(30_000);
|
||||
// …but the booking was stored at the client's forged 1 minor.
|
||||
expect(result.totalMinor).toBe(1);
|
||||
const stored = await prisma.booking.findUnique({ where: { id: result.id } });
|
||||
expect(stored?.totalMinor).toBe(1);
|
||||
});
|
||||
|
||||
// ── C-4 ──────────────────────────────────────────────────────────────────
|
||||
it("C-4 🔴 finalizePaymentSuccess confirms a 30000 booking from an intent of 1 (no amount check)", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma);
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "PAY-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "PENDING_PAYMENT",
|
||||
},
|
||||
});
|
||||
const intent = await prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: 1, // wildly short payment
|
||||
method: "WALLET",
|
||||
status: "PROCESSING",
|
||||
},
|
||||
});
|
||||
|
||||
const payments = new PaymentsService(
|
||||
prisma as any,
|
||||
{ confirmSeats: async () => undefined } as any,
|
||||
{ generate: async () => undefined } as any, // must not throw (re-thrown otherwise)
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // paymentClient
|
||||
asyncStub(), // currencyService (not used on this path)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
await payments.finalizePaymentSuccess({ intentId: intent.id });
|
||||
|
||||
const after = await prisma.booking.findUnique({ where: { id: booking.id } });
|
||||
// Confirmed despite intent.amountMinor (1) ≠ booking.totalMinor (30000).
|
||||
expect(after?.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
// ── C-6 ──────────────────────────────────────────────────────────────────
|
||||
it("C-6 🔴 two concurrent WALLET payments double-spend a single-ticket balance", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma);
|
||||
// Wallet funded for exactly ONE ticket.
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: passenger.id, balanceMinor: 30_000 },
|
||||
});
|
||||
const mkBooking = (ref: string) =>
|
||||
prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: ref,
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "PENDING_PAYMENT",
|
||||
},
|
||||
});
|
||||
const b1 = await mkBooking("W-0001");
|
||||
const b2 = await mkBooking("W-0002");
|
||||
|
||||
// Race-wrapped prisma forces both balance reads to complete before either debit writes.
|
||||
const racePrisma = makeRaceWrappedPrisma(prisma, 2);
|
||||
const payments = new PaymentsService(
|
||||
racePrisma as any,
|
||||
{ confirmSeats: async () => undefined } as any,
|
||||
{ generate: async () => undefined } as any,
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
);
|
||||
|
||||
const [bk1, bk2] = await Promise.all([
|
||||
prisma.booking.findUnique({ where: { id: b1.id }, include: { seats: true } }),
|
||||
prisma.booking.findUnique({ where: { id: b2.id }, include: { seats: true } }),
|
||||
]);
|
||||
|
||||
const [r1, r2] = await Promise.allSettled([
|
||||
(payments as any).initiateWalletPayment(bk1),
|
||||
(payments as any).initiateWalletPayment(bk2),
|
||||
]);
|
||||
|
||||
const succeeded = await prisma.paymentIntent.count({
|
||||
where: { bookingId: { in: [b1.id, b2.id] }, status: { in: ["SUCCEEDED", "PROCESSING"] } },
|
||||
});
|
||||
const debits = await prisma.walletLedgerEntry.count({ where: { type: "DEBIT" } });
|
||||
const wallet = await prisma.walletAccount.findUnique({
|
||||
where: { passengerId: passenger.id },
|
||||
});
|
||||
|
||||
// Double-spend signature: two successful debits from a one-ticket balance, or a negative
|
||||
// balance. A correctly-locked wallet allows exactly one.
|
||||
const totalDebited = debits * 30_000;
|
||||
const doubleSpent =
|
||||
(succeeded === 2 && totalDebited > 30_000) || (wallet?.balanceMinor ?? 0) < 0;
|
||||
expect(doubleSpent).toBe(true);
|
||||
expect([r1.status, r2.status]).toEqual(["fulfilled", "fulfilled"]);
|
||||
});
|
||||
});
|
||||
128
apps/edr-passenger-api/test/fixtures/seed-core.ts
vendored
Normal file
128
apps/edr-passenger-api/test/fixtures/seed-core.ts
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Deterministic core fixtures for the pricing E2E suites.
|
||||
*
|
||||
* The repo's `prisma/seed.ts` is entirely commented out (every step disabled), so the harness
|
||||
* builds its own minimal, fully-controlled graph: coach type → seat classes → stations → route
|
||||
* with distance-bearing stops → FX rates. Fixed UUIDs let specs reference entities directly.
|
||||
*
|
||||
* Uses a bare PrismaClient (not the Nest PrismaService) so it can run in jest globalSetup or
|
||||
* inside a spec without booting the app. Reads DATABASE_URL from process.env (load-env sets it).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const IDS = {
|
||||
coachType: "00000000-0000-4000-8000-000000000001",
|
||||
seatClassLocal: "00000000-0000-4000-8000-000000000010",
|
||||
seatClassIntl: "00000000-0000-4000-8000-000000000011",
|
||||
stationA: "00000000-0000-4000-8000-000000000020",
|
||||
stationB: "00000000-0000-4000-8000-000000000021",
|
||||
stationC: "00000000-0000-4000-8000-000000000022",
|
||||
route: "00000000-0000-4000-8000-000000000030",
|
||||
} as const;
|
||||
|
||||
/** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */
|
||||
export const DISTANCE = { A: 0, B: 100, C: 250 } as const;
|
||||
|
||||
/**
|
||||
* FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the
|
||||
* USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the
|
||||
* major→minor scaling line up; a realistic rate (e.g. 132) would visibly distort domestic fares,
|
||||
* which is itself a finding the suites probe.
|
||||
*/
|
||||
export const USD_TO_ETB = 100;
|
||||
export const ETB_TO_DJF = 1.8;
|
||||
|
||||
/** TRUNCATE every table in the `passenger` schema (except Prisma's migration bookkeeping). */
|
||||
export async function truncateAllPassenger(prisma: PrismaClient): Promise<void> {
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ tablename: string }>>(
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname = 'passenger' AND tablename <> '_prisma_migrations'`,
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const list = rows.map((r) => `passenger."${r.tablename}"`).join(", ");
|
||||
await prisma.$executeRawUnsafe(
|
||||
`TRUNCATE ${list} RESTART IDENTITY CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert the deterministic core graph. Call after truncateAllPassenger. */
|
||||
export async function seedCore(prisma: PrismaClient): Promise<void> {
|
||||
const past = new Date("2020-01-01T00:00:00.000Z");
|
||||
|
||||
await prisma.coachType.create({
|
||||
data: {
|
||||
id: IDS.coachType,
|
||||
code: "STD",
|
||||
name: "Standard Coach",
|
||||
type: "passenger",
|
||||
},
|
||||
});
|
||||
|
||||
// LOCAL and INTERNATIONAL seat classes share the coach type + bedPosition (null = regular seat),
|
||||
// which is exactly how fare-engine picks the nationality-matched class (findFirst on those keys).
|
||||
await prisma.seatClass.createMany({
|
||||
data: [
|
||||
{
|
||||
id: IDS.seatClassLocal,
|
||||
coachTypeId: IDS.coachType,
|
||||
name: "Local Standard",
|
||||
nationalityType: "LOCAL",
|
||||
bedPosition: null,
|
||||
baseFareMinor: 300, // 3.00 ETB/km
|
||||
premiumMinor: 0,
|
||||
insuranceFeeMinor: 0,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: IDS.seatClassIntl,
|
||||
coachTypeId: IDS.coachType,
|
||||
name: "Intl Standard",
|
||||
nationalityType: "INTERNATIONAL",
|
||||
bedPosition: null,
|
||||
baseFareMinor: 500, // 5.00 ETB/km
|
||||
premiumMinor: 0,
|
||||
insuranceFeeMinor: 0,
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.station.createMany({
|
||||
data: [
|
||||
{ id: IDS.stationA, code: "AAA", name: "Alpha", city: "Alpha City", sequence: 1 },
|
||||
{ id: IDS.stationB, code: "BBB", name: "Bravo", city: "Bravo City", sequence: 2 },
|
||||
{ id: IDS.stationC, code: "CCC", name: "Charlie", city: "Charlie City", sequence: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.route.create({
|
||||
data: {
|
||||
id: IDS.route,
|
||||
code: "RT-MAIN",
|
||||
name: "Main Line",
|
||||
effectiveFrom: past,
|
||||
active: true,
|
||||
stops: {
|
||||
create: [
|
||||
{ stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A },
|
||||
{ stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B },
|
||||
{ stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.currencyExchangeRate.createMany({
|
||||
data: [
|
||||
{ fromCurrency: "USD", toCurrency: "ETB", rate: USD_TO_ETB, effectiveDate: new Date() },
|
||||
{ fromCurrency: "ETB", toCurrency: "USD", rate: 1 / USD_TO_ETB, effectiveDate: new Date() },
|
||||
{ fromCurrency: "ETB", toCurrency: "DJF", rate: ETB_TO_DJF, effectiveDate: new Date() },
|
||||
{ fromCurrency: "DJF", toCurrency: "ETB", rate: 1 / ETB_TO_DJF, effectiveDate: new Date() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience: reset + seed in one call. */
|
||||
export async function resetAndSeedCore(prisma: PrismaClient): Promise<void> {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
}
|
||||
@@ -2,6 +2,34 @@
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"testEnvironment": "node"
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"test/app.e2e-spec.ts"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": ["ts-jest", { "isolatedModules": true }]
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": ["<rootDir>/setup/load-env.ts"],
|
||||
"moduleNameMapper": {
|
||||
"^file-type$": "<rootDir>/setup/stubs/file-type.ts",
|
||||
"^@edr/types$": "<rootDir>/../../../packages/types/src/index.ts",
|
||||
"^@edr/types/(.*)$": "<rootDir>/../../../packages/types/src/$1",
|
||||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||
},
|
||||
"testTimeout": 60000,
|
||||
"maxWorkers": 1,
|
||||
"reporters": [
|
||||
"default",
|
||||
[
|
||||
"jest-html-reporters",
|
||||
{
|
||||
"publicPath": "<rootDir>/../e2e-report",
|
||||
"filename": "index.html",
|
||||
"pageTitle": "EDR Passenger — Pricing/Config E2E Results",
|
||||
"expand": true,
|
||||
"hideIcon": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
175
apps/edr-passenger-api/test/money-integrity.e2e-spec.ts
Normal file
175
apps/edr-passenger-api/test/money-integrity.e2e-spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tier-2 money-integrity suite — services behind the IAM/RabbitMQ wall, instantiated directly with
|
||||
* a real Prisma (test DB) + stubbed collaborators. Confirms critical findings:
|
||||
* F1/F2 🔴 WalletService.topUp credits any passenger's wallet with no ownership check and no
|
||||
* payment backing (free money).
|
||||
* G4/G5 🔴 BookingsService.cancel computes an 80% refund but NEVER disburses it — no PaymentRefund,
|
||||
* no wallet credit; the cancellation sits at refundStatus PENDING forever.
|
||||
* E1/E2 🔴 ExcessBaggageService.logCharge picks the OLDEST BaggageAllowance globally, ignoring the
|
||||
* booking's seat class, and computes fee = feePerKgMinor × excessWeightKg.
|
||||
*/
|
||||
import { WalletService } from "../src/modules/wallet/wallet.service";
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { ExcessBaggageService } from "../src/modules/excess-baggage/excess-baggage.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
|
||||
function asyncStub(): any {
|
||||
return new Proxy(
|
||||
{},
|
||||
{ get: () => async () => undefined },
|
||||
);
|
||||
}
|
||||
|
||||
describe("Money integrity (Tier-2 direct instantiation)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
// ── F1 / F2 ────────────────────────────────────────────────────────────────
|
||||
it("F1/F2 🔴 topUp credits another passenger's wallet — no ownership check, no payment backing", async () => {
|
||||
const victim = await prisma.passenger.create({ data: {} });
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: victim.id, balanceMinor: 0 },
|
||||
});
|
||||
|
||||
const wallet = new WalletService(prisma as any);
|
||||
|
||||
// An attacker-controlled call: just pass the victim's id. Nothing checks caller identity,
|
||||
// and no PaymentIntent/settlement backs the credit.
|
||||
await wallet.topUp(victim.id, 1_000_000, "free money");
|
||||
|
||||
const after = await prisma.walletAccount.findUnique({
|
||||
where: { passengerId: victim.id },
|
||||
});
|
||||
expect(after?.balanceMinor).toBe(1_000_000);
|
||||
|
||||
// The only ledger entry is a bare CREDIT — no linked payment.
|
||||
const ledger = await prisma.walletLedgerEntry.findMany({
|
||||
where: { walletId: after!.id },
|
||||
});
|
||||
expect(ledger).toHaveLength(1);
|
||||
expect(ledger[0].type).toBe("CREDIT");
|
||||
expect(ledger[0].relatedBookingId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
// ── G4 / G5 ────────────────────────────────────────────────────────────────
|
||||
it("G4/G5 🔴 cancel() computes floor(total*0.8) refund but never disburses it (stuck PENDING)", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
// Give the passenger a wallet so we can prove NO refund lands in it.
|
||||
const w = await prisma.walletAccount.create({
|
||||
data: { passengerId: passenger.id, balanceMinor: 0 },
|
||||
});
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "CXL-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
displayCurrency: "ETB",
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = new BookingsService(
|
||||
prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
asyncStub(), // seatsService
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
asyncStub(), // currencyService
|
||||
asyncStub(), // fareEngine
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
const result: any = await bookings.cancel(booking.bookingRef, "test");
|
||||
|
||||
// Refund is COMPUTED as 80%:
|
||||
expect(result.refundAmount).toBe(Math.floor(30_000 * 0.8) / 100); // 240.00
|
||||
|
||||
// …but recorded only as PENDING, and never actually paid out:
|
||||
const cancellation = await prisma.bookingCancellation.findFirst({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
expect(cancellation?.refundStatus).toBe("PENDING");
|
||||
|
||||
// No PaymentRefund row was created anywhere (isolated DB) and the wallet was NOT credited.
|
||||
const refundCount = await prisma.paymentRefund.count();
|
||||
expect(refundCount).toBe(0);
|
||||
const walletAfter = await prisma.walletAccount.findUnique({ where: { id: w.id } });
|
||||
expect(walletAfter?.balanceMinor).toBe(0);
|
||||
});
|
||||
|
||||
// ── E1 / E2 ────────────────────────────────────────────────────────────────
|
||||
it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "BAG-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
// Oldest allowance is for the LOCAL class (rate 50). A later one for INTL (rate 200) should win
|
||||
// for an intl booking — but logCharge ignores seat class and takes the oldest row.
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
|
||||
});
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassIntl, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 200 },
|
||||
});
|
||||
|
||||
const service = new ExcessBaggageService(
|
||||
prisma as any,
|
||||
asyncStub(), // auditService
|
||||
asyncStub(), // paymentClient
|
||||
asyncStub(), // notifications
|
||||
asyncStub(), // smsClient
|
||||
asyncStub(), // emailClient
|
||||
);
|
||||
|
||||
const charge: any = await service.logCharge({
|
||||
bookingId: booking.id,
|
||||
excessWeightKg: 10,
|
||||
collectCash: true,
|
||||
} as any);
|
||||
|
||||
// Used the oldest (LOCAL, 50) not any seat-class-matched rate; fee = 50 × 10.
|
||||
expect(charge.feePerKgMinor).toBe(50);
|
||||
expect(charge.totalMinor).toBe(50 * 10);
|
||||
});
|
||||
});
|
||||
|
||||
let trainSeq = 0;
|
||||
|
||||
/** Minimal TrainSchedule (+train) so booking/cancel fixtures satisfy FKs. */
|
||||
async function makeSchedule(prisma: any, _passengerId: string) {
|
||||
const train = await prisma.train.create({
|
||||
data: { number: `T-${++trainSeq}`, name: "Test Train" },
|
||||
});
|
||||
return prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
}
|
||||
81
apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts
Normal file
81
apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Currency / FX suite (matrix Suite C). Exercises CurrencyService directly.
|
||||
* C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws —
|
||||
* the display path degrades but the charge path errors on the SAME condition (divergence).
|
||||
* C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter).
|
||||
* C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor
|
||||
* return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both
|
||||
* written into fields named `amountMinor` at their call sites.
|
||||
*/
|
||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||
import { resetAndSeedCore, USD_TO_ETB } from "./fixtures/seed-core";
|
||||
|
||||
describe("Pricing — CurrencyService (Suite C)", () => {
|
||||
let harness: ServiceHarness;
|
||||
let currency: CurrencyService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
currency = harness.moduleRef.get(CurrencyService);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
});
|
||||
|
||||
it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => {
|
||||
// Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays.
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||
});
|
||||
|
||||
// Display/fare path (getExchangeRate) has NO inverse fallback → silently returns 1.0 (wrong).
|
||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
||||
|
||||
// Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct).
|
||||
await expect(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).resolves.toBe(USD_TO_ETB);
|
||||
// → the display fare and the charged amount for the same trip differ by 100x.
|
||||
});
|
||||
|
||||
it("C2b 🔴 truly-missing pair: getExchangeRate → 1.0 (silent), getRateOrThrow → throws", async () => {
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromCurrency: "USD", toCurrency: "ETB" },
|
||||
{ fromCurrency: "ETB", toCurrency: "USD" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
||||
await expect(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).rejects.toThrow(/No exchange rate/i);
|
||||
});
|
||||
|
||||
it("C3 🔴 a future-dated rate is used right now (no effective-date gate)", async () => {
|
||||
const future = new Date(Date.now() + 365 * 24 * 3600 * 1000);
|
||||
await harness.prisma.currencyExchangeRate.create({
|
||||
data: { fromCurrency: "ETB", toCurrency: "USD", rate: 999, effectiveDate: future },
|
||||
});
|
||||
|
||||
// Correct behavior: ignore not-yet-effective rates. Actual: latest-by-date wins immediately.
|
||||
const rate = await currency.getExchangeRate("ETB" as any, "USD" as any);
|
||||
expect(rate).toBe(999);
|
||||
});
|
||||
|
||||
it("C5 🔴 conversion routines return different UNITS for the same money (100x apart)", async () => {
|
||||
// 100000 ETB minor = 1000.00 ETB. With ETB→USD = 1/100:
|
||||
const asMajor = await currency.convertMinorToChargeMajor(100000, "ETB", "USD"); // → 10.00 (major)
|
||||
const asMinor = await currency.convertEtbMinorToChargeMinor(100000, "USD"); // → 1000 (minor)
|
||||
|
||||
expect(asMajor).toBeCloseTo(1000 / USD_TO_ETB, 2); // 10.00
|
||||
expect(asMinor).toBe(Math.round((100000 * 1) / USD_TO_ETB)); // 1000
|
||||
// Same amount, but the two results differ by 100x — and both feed fields named `amountMinor`.
|
||||
expect(asMinor).toBe(asMajor * 100);
|
||||
});
|
||||
});
|
||||
131
apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts
Normal file
131
apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly.
|
||||
* Also confirms two matrix findings against the running engine:
|
||||
* D1 — a promo with percentOff > 100 drives the total NEGATIVE (no clamp at 0).
|
||||
* C1 — a missing USD→ETB FX rate is silently substituted with 1.0 (fares collapse ~100x).
|
||||
*/
|
||||
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||
import {
|
||||
createServiceHarness,
|
||||
ServiceHarness,
|
||||
} from "./setup/slim-app";
|
||||
import {
|
||||
IDS,
|
||||
resetAndSeedCore,
|
||||
DISTANCE,
|
||||
USD_TO_ETB,
|
||||
} from "./fixtures/seed-core";
|
||||
|
||||
describe("Pricing — FareEngineService (slim harness)", () => {
|
||||
let harness: ServiceHarness;
|
||||
let fareEngine: FareEngineService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
fareEngine = harness.moduleRef.get(FareEngineService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
});
|
||||
|
||||
// nationality 'Ethiopian' → LOCAL seat class (3.00 ETB/km) and ETB billing (rate 1).
|
||||
const baseDto = () => ({
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
nationality: "Ethiopian",
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
});
|
||||
|
||||
it("boots and computes a positive baseline fare (A→B, local, 1 adult)", async () => {
|
||||
const result = await fareEngine.calculate(baseDto() as any);
|
||||
// 100km × 3.00 ETB/km × 1 × USD_TO_ETB(100) = 30000 minor (see seat-class formula).
|
||||
expect(result.totalMinor).toBeGreaterThan(0);
|
||||
expect(result.totalMinor).toBe(DISTANCE.B * 3 * USD_TO_ETB);
|
||||
});
|
||||
|
||||
it("D1 🔴 promo percentOff=150 produces a NEGATIVE total (no floor at 0)", async () => {
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Overshoot",
|
||||
code: "OVER150",
|
||||
percentOff: 150,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "OVER150",
|
||||
} as any);
|
||||
|
||||
// Expected (correct) behavior: total clamped at >= 0. Actual: negative.
|
||||
expect(result.totalMinor).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("D2 🔴 fixed amountOffMinor larger than subtotal drives total NEGATIVE", async () => {
|
||||
const base = await fareEngine.calculate(baseDto() as any); // 30000 minor
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Huge fixed",
|
||||
code: "FIXEDBIG",
|
||||
amountOffMinor: base.totalMinor + 10_000,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "FIXEDBIG",
|
||||
} as any);
|
||||
expect(result.totalMinor).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("D4 🔴 promo with percentOff=0 is treated as FIXED (0 is falsy) and applies amountOffMinor", async () => {
|
||||
// A promo intended as '0% off' but also carrying a stray fixed amount: the falsy check
|
||||
// `promo.percentOff ? percent : amountOffMinor` wrongly applies the fixed discount.
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Zero percent",
|
||||
code: "ZERO0",
|
||||
percentOff: 0,
|
||||
amountOffMinor: 5000,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const base = await fareEngine.calculate(baseDto() as any);
|
||||
const withPromo = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "ZERO0",
|
||||
} as any);
|
||||
|
||||
// A true 0% promo should not change the price; here it deducts the fixed 5000.
|
||||
expect(withPromo.totalMinor).toBe(base.totalMinor - 5000);
|
||||
});
|
||||
|
||||
it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => {
|
||||
const withRate = await fareEngine.calculate(baseDto() as any);
|
||||
|
||||
// Remove the USD→ETB rate the seat-class formula multiplies by.
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||
});
|
||||
|
||||
const withoutRate = await fareEngine.calculate(baseDto() as any);
|
||||
|
||||
// Correct behavior would be to reject/flag; instead the fare silently drops by the rate factor.
|
||||
expect(withoutRate.totalMinor).toBe(withRate.totalMinor / USD_TO_ETB);
|
||||
expect(withoutRate.totalMinor).toBeLessThan(withRate.totalMinor);
|
||||
});
|
||||
});
|
||||
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Loads apps/edr-passenger-api/.env.test into process.env BEFORE the Nest AppModule boots.
|
||||
* Registered as a jest `setupFile` (runs per test file, before the framework and before any
|
||||
* `Test.createTestingModule`). Zero-dependency KEY=VALUE parser — dotenv is not a direct dep here.
|
||||
* Existing process.env values win (so CI can override the DB URL without editing the file).
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Prefer a local (gitignored) .env.test; fall back to the tracked .env.test.example so a fresh
|
||||
// checkout of the branch runs the suites without a manual copy step.
|
||||
const localPath = join(__dirname, "..", "..", ".env.test");
|
||||
const examplePath = join(__dirname, "..", "..", ".env.test.example");
|
||||
const envPath = existsSync(localPath) ? localPath : examplePath;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(envPath, "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
// strip surrounding quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface loudly — a missing .env.test means every suite would boot against the wrong DB.
|
||||
throw new Error(
|
||||
`[load-env] could not read ${envPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Singleton PrismaClient against the hermetic test DB (DATABASE_URL from .env.test, loaded by
|
||||
* setup/load-env.ts). Used by:
|
||||
* - the fixture seeder (fixtures/seed-core.ts), and
|
||||
* - "direct-instantiation" specs for services behind the IAM/RabbitMQ wall (BookingsService,
|
||||
* PaymentsService, WalletService, …) which cannot be booted through their Nest modules because
|
||||
* those transitively import the @tria-plc IAM stack (ESM-only `file-type`) / golevelup RabbitMQ.
|
||||
* Those specs `new TheService(prisma, ...mockedCollaborators)` and assert the money logic.
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
let client: PrismaClient | undefined;
|
||||
|
||||
export function getTestPrisma(): PrismaClient {
|
||||
if (!client) {
|
||||
client = new PrismaClient();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function disconnectTestPrisma(): Promise<void> {
|
||||
if (client) {
|
||||
await client.$disconnect();
|
||||
client = undefined;
|
||||
}
|
||||
}
|
||||
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Slim Nest test harness — boots ONLY the passenger domain modules needed for pricing/booking
|
||||
* tests, deliberately excluding the IAM (TriaIamModule), SharedAuth, and MinIO stack from
|
||||
* app.module.ts. Those drag in `@tria-plc/api-common`'s file-crud/minio chain which requires the
|
||||
* ESM-only `file-type` package that jest's CommonJS resolver cannot load.
|
||||
*
|
||||
* Two entry points:
|
||||
* - createServiceHarness(): resolve services directly (FareEngineService, etc.) for unit/DB-level
|
||||
* assertions on the money math.
|
||||
* - createHttpHarness(): a full Nest HTTP app with the SAME global ValidationPipe as main.ts, so
|
||||
* controller/DTO/pipe behavior (client-trust, DTO validation) is exercised end-to-end over HTTP.
|
||||
*
|
||||
* The IAM JwtGuard is overridden with an always-allow stub so protected routes are reachable; auth
|
||||
* *enforcement* findings (which guards are missing) are asserted separately via route metadata, not
|
||||
* by booting the real guard.
|
||||
*/
|
||||
import { Global, INestApplication, Module, ValidationPipe } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { getDataSourceToken } from "@nestjs/typeorm";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { PrismaModule } from "../../src/common/prisma.module";
|
||||
import { PrismaService } from "../../src/common/prisma.service";
|
||||
import { SessionActivityInterceptor } from "../../src/common/interceptors/session-activity.interceptor";
|
||||
import { FareEngineModule } from "../../src/modules/fare-engine/fare-engine.module";
|
||||
import { CurrencyModule } from "../../src/modules/currency/currency.module";
|
||||
import { CurrenciesModule } from "../../src/modules/currencies/currencies.module";
|
||||
import { PromosModule } from "../../src/modules/promos/promos.module";
|
||||
import { SeatClassesModule } from "../../src/modules/seat-classes/seat-classes.module";
|
||||
import { StationsModule } from "../../src/modules/stations/stations.module";
|
||||
import { SchedulesModule } from "../../src/modules/schedules/schedules.module";
|
||||
import { SegmentsModule } from "../../src/modules/segments/segments.module";
|
||||
import { SystemConfigModule } from "../../src/modules/system-config/system-config.module";
|
||||
|
||||
/**
|
||||
* A stub TypeORM DataSource, provided globally so IAM-derived providers that reach the slim
|
||||
* harness transitively (e.g. NotificationsService via ExcessBaggageModule) can instantiate.
|
||||
* Pricing tests never trigger the code paths that actually use it.
|
||||
*/
|
||||
const fakeDataSource = {
|
||||
query: async () => [],
|
||||
transaction: async (cb: (m: unknown) => unknown) => cb({}),
|
||||
getRepository: () => ({}),
|
||||
createQueryRunner: () => ({
|
||||
connect: async () => undefined,
|
||||
startTransaction: async () => undefined,
|
||||
commitTransaction: async () => undefined,
|
||||
rollbackTransaction: async () => undefined,
|
||||
release: async () => undefined,
|
||||
manager: {},
|
||||
}),
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: getDataSourceToken(), useValue: fakeDataSource }],
|
||||
exports: [getDataSourceToken()],
|
||||
})
|
||||
class TestGlobalsModule {}
|
||||
|
||||
/** Modules that are safe to import in isolation (verified free of the IAM/MinIO chain). */
|
||||
const DOMAIN_MODULES = [
|
||||
FareEngineModule,
|
||||
CurrencyModule,
|
||||
CurrenciesModule,
|
||||
PromosModule,
|
||||
SeatClassesModule,
|
||||
StationsModule,
|
||||
SchedulesModule,
|
||||
SegmentsModule,
|
||||
SystemConfigModule,
|
||||
];
|
||||
// NOTE: ExcessBaggageModule/PaymentsModule/BookingsModule are intentionally excluded — they pull in
|
||||
// NotificationsModule → @golevelup RabbitMQ which connects at boot. Their suites instantiate the
|
||||
// service directly with mocked collaborators (see excess-baggage / booking-trust specs).
|
||||
|
||||
async function buildModule(): Promise<TestingModule> {
|
||||
return Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
EventEmitterModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
TestGlobalsModule,
|
||||
PrismaModule,
|
||||
...DOMAIN_MODULES,
|
||||
],
|
||||
})
|
||||
// SessionActivityInterceptor needs the IAM TypeORM DataSource, which the slim harness
|
||||
// deliberately omits. Replace it with a pass-through — it does not affect pricing logic.
|
||||
.overrideProvider(SessionActivityInterceptor)
|
||||
.useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() })
|
||||
.compile();
|
||||
}
|
||||
|
||||
export interface ServiceHarness {
|
||||
moduleRef: TestingModule;
|
||||
prisma: PrismaClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Resolve services for direct method-level assertions. */
|
||||
export async function createServiceHarness(): Promise<ServiceHarness> {
|
||||
const moduleRef = await buildModule();
|
||||
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
|
||||
return {
|
||||
moduleRef,
|
||||
prisma,
|
||||
close: async () => {
|
||||
await moduleRef.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HttpHarness {
|
||||
app: INestApplication;
|
||||
moduleRef: TestingModule;
|
||||
prisma: PrismaClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Boot a full HTTP app with the production ValidationPipe config from src/main.ts:56. */
|
||||
export async function createHttpHarness(): Promise<HttpHarness> {
|
||||
const moduleRef = await buildModule();
|
||||
const app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidUnknownValues: false,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
|
||||
return {
|
||||
app,
|
||||
moduleRef,
|
||||
prisma,
|
||||
close: async () => {
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* CommonJS stub for the ESM-only `file-type` package (v21). jest's CommonJS resolver cannot load
|
||||
* the real one, and `@tria-plc/api-common`'s minio.service `require("file-type")` at import time,
|
||||
* dragging the whole IAM stack down with it. minio.service only calls fileTypeFromBuffer when
|
||||
* actually processing an upload — never during pricing/booking tests — so a stub is sufficient to
|
||||
* let the full AppModule boot. Mapped via jest `moduleNameMapper` (^file-type$).
|
||||
*/
|
||||
export async function fileTypeFromBuffer(): Promise<undefined> {
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user