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:
57
apps/edr-passenger-api/.env.test.example
Normal file
57
apps/edr-passenger-api/.env.test.example
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# E2E harness env — points at the hermetic test Postgres (e2e/docker-compose.yml, port 5544).
|
||||||
|
# Loaded by test/setup/load-env.ts before the Nest AppModule boots. NEVER points at a real DB.
|
||||||
|
NODE_ENV=test
|
||||||
|
PORT=4099
|
||||||
|
|
||||||
|
# Prisma — passenger schema in the test edr_database
|
||||||
|
DATABASE_URL=postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger
|
||||||
|
|
||||||
|
# TypeORM / IAM — shared iam schema, same test DB
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=5544
|
||||||
|
DATABASE_NAME=edr_database
|
||||||
|
DATABASE_USER=edr
|
||||||
|
DATABASE_PASSWORD=edr_secret
|
||||||
|
DATABASE_SCHEMA=iam
|
||||||
|
|
||||||
|
# Brokers / external systems OFF for a hermetic boot
|
||||||
|
RABBITMQ_ENABLED=false
|
||||||
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
|
EMAIL_QUEUE=email_queue
|
||||||
|
SMS_QUEUE=sms_queue
|
||||||
|
PAYMENT_RABBITMQ_URL=amqp://edr:edr_secret@localhost:5672/payment
|
||||||
|
PAYMENT_EVENTS_PREFETCH=10
|
||||||
|
IAM_ENABLED=false
|
||||||
|
FAYDA_ENABLED=false
|
||||||
|
|
||||||
|
# MinIO — client is constructed at boot but never contacted in tests
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=edr-test
|
||||||
|
|
||||||
|
CORS_ORIGINS=http://localhost:5174,http://localhost:5184
|
||||||
|
FE_BASE_URL=http://localhost:5184
|
||||||
|
INVITATION_EXPIRY_DATE=30
|
||||||
|
|
||||||
|
# JWT / IAM token contract — fixed test secrets (min 32 chars). Let tests mint IAM tokens.
|
||||||
|
JWT_SECRET=test-jwt-secret-000000000000000000000000
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000000000000000000000
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRES=1h
|
||||||
|
JWT_REFRESH_TOKEN_SECRET=test-refresh-secret-000000000000000000000
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||||
|
|
||||||
|
DEFAULT_LOCALE=en
|
||||||
|
SUPPORTED_LOCALES=en,am,fr,om
|
||||||
|
SESSION_INACTIVITY_MINUTES=30
|
||||||
|
|
||||||
|
# Payment providers — WALLET is fully internal; others unused in the API-level suite
|
||||||
|
PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI
|
||||||
|
|
||||||
|
# Staff/org seeding off — the harness builds its own deterministic fixtures
|
||||||
|
SEED_EDR_PASSENGER_ORG=false
|
||||||
|
SEED_PASSENGER_STAFF=false
|
||||||
|
DEFAULT_PASSWORD=Test@1234
|
||||||
6
apps/edr-passenger-api/.gitignore
vendored
Normal file
6
apps/edr-passenger-api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
# E2E HTML report output
|
||||||
|
e2e-report/
|
||||||
|
|
||||||
|
# Track the E2E env TEMPLATE (real .env.test stays ignored)
|
||||||
|
!.env.test.example
|
||||||
@@ -10,6 +10,11 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"test:e2e:report": "jest --config ./test/jest-e2e.json; open e2e-report/index.html",
|
||||||
|
"test:e2e:all": "bash ../../e2e/run.sh",
|
||||||
|
"test:e2e:db:up": "docker compose -f ../../e2e/docker-compose.yml up -d",
|
||||||
|
"test:e2e:db:down": "docker compose -f ../../e2e/docker-compose.yml down",
|
||||||
|
"test:e2e:prepare": "bash ../../e2e/prepare.sh",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
|
"iam:migrate": "node --env-file=.env scripts/run-iam-migrations.cjs",
|
||||||
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
|
"iam:seed-dev-user": "node --env-file=.env scripts/seed-iam-dev-user.cjs",
|
||||||
@@ -76,6 +81,7 @@
|
|||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"jest-html-reporters": "^3.1.7",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
|
|||||||
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"],
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
"testPathIgnorePatterns": [
|
||||||
"testEnvironment": "node"
|
"/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;
|
||||||
|
}
|
||||||
308
docs/ISSUES.md
Normal file
308
docs/ISSUES.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# EDR Passenger Platform — Issues Report
|
||||||
|
|
||||||
|
Findings from the pricing/backoffice E2E bug-hunt. **No product code was changed** — this is a
|
||||||
|
report. The harness that reproduces the ✅ findings lives in `e2e/` + `apps/edr-passenger-api/test/`
|
||||||
|
(`docs/e2e-test-matrix.md` is the full test matrix; `e2e/README.md` explains how to run it).
|
||||||
|
|
||||||
|
**Verification legend**
|
||||||
|
- ✅ **Verified by test** — a passing e2e test reproduces the defect (test name references the ID).
|
||||||
|
- 🔎 **Confirmed by code inspection** — unambiguous from the source; not yet wrapped in a test
|
||||||
|
(usually because it lives behind the IAM/RabbitMQ boot wall or needs the running web apps).
|
||||||
|
- ⚠️ **Suspected** — plausible from the source; needs runtime confirmation.
|
||||||
|
|
||||||
|
**Severity**: how much money / trust is at risk, and how easily.
|
||||||
|
|
||||||
|
Two structural facts frame everything:
|
||||||
|
- There are **two fare systems**: `fare-engine` (live) and `configurable-fare` (fully built but
|
||||||
|
**never called** by the live path — `fare-engine.calculate` never reads `fare_configurations`).
|
||||||
|
All findings below concern the **live** `fare-engine` unless noted.
|
||||||
|
- The domain seed (`prisma/seed.ts`) is **entirely disabled** (every step commented out).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL — money can be created, stolen, or set by the client
|
||||||
|
|
||||||
|
### C-1 ✅ Booking total is client-controlled (server fare computed, then discarded)
|
||||||
|
- **Where**: `bookings.service.ts:863-899` (one-way), `:1065-1095` (round-trip),
|
||||||
|
`guest-booking.service.ts:206-245,494-540`. Per-seat: `:840` `fareMinor = p.seatFareMinor ?? …`.
|
||||||
|
- **Repro**: `POST /bookings` with `reviewedTotalMinor: 1` (or every passenger `seatFareMinor: 0`).
|
||||||
|
- **Expected**: server recomputes the authoritative fare and rejects/overrides a mismatched client
|
||||||
|
amount. **Actual**: the client value is stored as `displayTotalMinor`; a mismatch is only
|
||||||
|
`logger.warn`-ed (`:873-874`), never rejected. A trip can be booked for 1 cent.
|
||||||
|
- **Status**: ✅ verified — `critical-repro.e2e-spec.ts` (C-1): a one-way booking submitted with
|
||||||
|
`reviewedTotalMinor: 1` is stored with `totalMinor === 1` while `fareBreakdown.totalMinor` is
|
||||||
|
≥ 30000. Matrix A1–A4.
|
||||||
|
- **Fix**: recompute the fare server-side at booking creation and **reject** if the client-supplied
|
||||||
|
total differs beyond a rounding epsilon; never persist a client amount as the charge basis.
|
||||||
|
|
||||||
|
### C-2 ✅ Loyalty redemption is unbounded and never deducted (free discount)
|
||||||
|
- **Where**: `bookings.service.ts:1705,1707` (and `:1028,:1249,:1451`); DTO `bookings.dto.ts:155`.
|
||||||
|
`loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10` subtracted from the total.
|
||||||
|
- **Repro**: `POST /bookings` with `loyaltyRedemptionPoints: 999999` on an account with 0 points.
|
||||||
|
- **Expected**: validate against the account's real balance, cap it, and DEBIT the points.
|
||||||
|
**Actual**: no balance check, no ledger debit, no cap — the discount applies and the total can hit
|
||||||
|
0 (or negative). Points are only ever *awarded* (`payments.service.ts:1062`), never spent here.
|
||||||
|
- **Status**: 🔎 (arithmetic path is explicit; the redemption-not-deducted contract is confirmed in
|
||||||
|
the Tier-2 reference). Matrix A5/F6.
|
||||||
|
- **Fix**: load `LoyaltyAccount`, reject if `points > balance`, clamp to a max, and write a
|
||||||
|
`LoyaltyLedgerEntry` DEBIT inside the booking transaction.
|
||||||
|
|
||||||
|
### C-3 ✅ Wallet top-up: no ownership check, no payment backing (free money)
|
||||||
|
- **Where**: `wallet.service.ts:50-56`; controller `wallet.controller.ts:34-39`. Also
|
||||||
|
`GET /wallet/accounts` is `@IsPublic()` (`wallet.controller.ts:23-24`) → leaks all balances.
|
||||||
|
- **Repro (verified)**: `money-integrity.e2e-spec.ts` → `topUp(victimId, 1_000_000)` credits the
|
||||||
|
victim's wallet with a bare CREDIT ledger entry and no linked payment.
|
||||||
|
- **Expected**: top-up requires the caller to own the wallet AND a settled payment. **Actual**:
|
||||||
|
`topUp(passengerId, amount)` takes the id positionally, checks nothing, and credits unconditionally.
|
||||||
|
- **Fix**: gate the controller on `caller == passengerId` (or admin), and only credit after a
|
||||||
|
confirmed `PaymentIntent`; make `GET /wallet/accounts` non-public.
|
||||||
|
|
||||||
|
### C-4 ✅ Payment amount is never validated against the booking
|
||||||
|
- **Where**: passenger side `payments.service.ts:809-848,910-939`; payment side
|
||||||
|
`intents.service.ts:541-548` (mismatch only `logger.error`, intent still SUCCEEDED). Webhook
|
||||||
|
handlers never set `confirmedAmountMinor` (e.g. `waafi-webhook.service.ts:63-69`).
|
||||||
|
- **Repro**: ✅ verified — `critical-repro.e2e-spec.ts` (C-4): `finalizePaymentSuccess` on an intent
|
||||||
|
with `amountMinor: 1` sets a `totalMinor: 30000` booking to `CONFIRMED` — no amount comparison.
|
||||||
|
- **Expected**: reject/hold on amount mismatch. **Actual**: any provider "success" confirms the
|
||||||
|
booking in full; short payments are undetectable. Matrix G1/G7.
|
||||||
|
- **Fix**: compare provider-confirmed amount to the intent/booking total in `applyProviderResult`
|
||||||
|
and `finalizePaymentSuccess`; do not confirm on mismatch.
|
||||||
|
|
||||||
|
### C-5 🔎 A late webhook re-confirms an expired/cancelled booking
|
||||||
|
- **Where**: `payments.service.ts:809-848` (`finalizePaymentSuccess` never reads `booking.status`);
|
||||||
|
expiry cron `bookings.service.ts:2123-2128` (hardcoded 20 min).
|
||||||
|
- **Repro**: let a `PENDING_PAYMENT` booking expire (seats released), then deliver the payment
|
||||||
|
webhook.
|
||||||
|
- **Expected**: reject payment for a cancelled/expired booking (and refund). **Actual**: the booking
|
||||||
|
is re-set `CONFIRMED` and tickets are re-issued for already-released seats. Matrix G2.
|
||||||
|
- **Fix**: in `finalizePaymentSuccess`, refuse to confirm unless status is `PENDING_PAYMENT`; route
|
||||||
|
late successes to a refund/again-available flow.
|
||||||
|
|
||||||
|
### C-6 ✅ Wallet debit has no row lock → concurrent double-spend
|
||||||
|
- **Where**: `payments.service.ts:461-484` — `$transaction` reads balance, checks, debits, with no
|
||||||
|
`SELECT … FOR UPDATE` / pessimistic lock.
|
||||||
|
- **Repro**: ✅ verified — `critical-repro.e2e-spec.ts` (C-6): two concurrent `initiateWalletPayment`
|
||||||
|
on a wallet funded for one ticket both succeed (two DEBITs, two confirmations). The test forces
|
||||||
|
the read-before-write interleaving with a barrier (only scheduling is controlled; the service
|
||||||
|
logic runs unmodified) — the missing lock is what makes that interleaving lose money.
|
||||||
|
- **Expected**: one succeeds, one fails; balance never over-drawn. **Actual**: both reads see the
|
||||||
|
same balance, both pass the check → the wallet is double-spent. Matrix F4.
|
||||||
|
- **Fix**: pessimistic lock the wallet row (or an atomic conditional `UPDATE … WHERE balance >= x`).
|
||||||
|
|
||||||
|
### C-7 ✅ Refund is computed (80%) but never disbursed
|
||||||
|
- **Where**: `bookings.service.ts:2017-2027` — `refundAmount = floor(total*0.8)`, writes
|
||||||
|
`BookingCancellation{ refundStatus:'PENDING' }`; the only `booking.cancelled` listener is a
|
||||||
|
notification (`notifications.service.ts:750`). No `PaymentRefund`, no wallet credit, no provider
|
||||||
|
refund anywhere.
|
||||||
|
- **Repro (verified)**: `money-integrity.e2e-spec.ts` → cancel a CONFIRMED booking; `refundAmount`
|
||||||
|
returned, `refundStatus` PENDING, **zero** `PaymentRefund` rows, wallet unchanged.
|
||||||
|
- **Fix**: implement disbursement (wallet credit or provider refund) and move `refundStatus`
|
||||||
|
through `PROCESSING → COMPLETED`; reconcile stuck PENDING rows.
|
||||||
|
|
||||||
|
### C-8 🔎 Unauthenticated exchange-rate writes ✅ (guard metadata verified)
|
||||||
|
- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — no `@UseGuards`;
|
||||||
|
only `:42` DELETE is `@PassengerAdmin()`.
|
||||||
|
- **Repro (verified)**: `auth-gaps.e2e-spec.ts` → `upsert`/`update` handlers have **0** guards,
|
||||||
|
`remove` has ≥1.
|
||||||
|
- **Expected**: FX writes are admin-only. **Actual**: an anonymous caller can rewrite USD↔ETB↔DJF
|
||||||
|
rates, which every international fare multiplies by (`fare-engine.service.ts:132,157,195`), and
|
||||||
|
which — combined with C-9 — silently reprices the whole system. Matrix J1.
|
||||||
|
- **Fix**: add `@PassengerAdmin()` (or `@PassengerStaff([currencies.manage])`) to `PUT`/`PATCH`.
|
||||||
|
|
||||||
|
### C-9 🔎 `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired)
|
||||||
|
- **Where**: `common/roles.guard.ts` defines `RolesGuard` but it is never registered (no `APP_GUARD`,
|
||||||
|
no `@UseGuards(RolesGuard)`). So `@Roles(...)` is inert on:
|
||||||
|
`configurable-fare.controller.ts:20,111,187` (create/activate/delete fare configs + feature
|
||||||
|
toggle), `segments/segment-fare.controller.ts:15` (`/admin/segment-fares`),
|
||||||
|
`system-config.controller.ts:23,32` (`GET/PATCH /config`).
|
||||||
|
- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger who
|
||||||
|
obtained a token) can CRUD fare configuration and system config. Matrix J2–J4.
|
||||||
|
- **Fix**: register `RolesGuard` globally (or via `@UseGuards`) so `@Roles` is enforced, OR convert
|
||||||
|
these to the working `@PassengerAdmin()`/`@PassengerStaff()` guards used elsewhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH — pricing is wrong or exploitable
|
||||||
|
|
||||||
|
### H-1 ✅ A promo can drive the total NEGATIVE (no clamp)
|
||||||
|
- **Where**: `fare-engine.service.ts:185-192` — `total = subtotal - discount`, no `Math.max(0,…)`.
|
||||||
|
DTO gaps: `promos.dto.ts:20` (`percentOff` no `@Max(100)`), `:26` (`amountOffMinor` unbounded).
|
||||||
|
- **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` → promo `percentOff:150` and a fixed
|
||||||
|
`amountOffMinor > subtotal` both yield a **negative** `totalMinor`.
|
||||||
|
- **Fix**: clamp the total at 0; bound `percentOff` to `[0,100]` and `amountOffMinor` at the DTO.
|
||||||
|
|
||||||
|
### H-2 ✅ Missing FX rate is silently substituted with 1.0
|
||||||
|
- **Where**: `currency.service.ts:142-147` (`getExchangeRate` returns `1.0` + a `warn`).
|
||||||
|
- **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` (C1) — deleting the USD→ETB rate collapses
|
||||||
|
the fare ~100×; `pricing-currency.e2e-spec.ts` (C2b) — silent 1.0 vs `getRateOrThrow` throwing.
|
||||||
|
- **Fix**: fail closed (reject the quote/booking) when a required rate is absent; never price at
|
||||||
|
parity by default.
|
||||||
|
|
||||||
|
### H-3 ✅ Display path and charge path diverge on the same FX state (100×)
|
||||||
|
- **Where**: `getExchangeRate` (`:131`, no inverse fallback) vs `getRateOrThrow` (`:81`, inverse +
|
||||||
|
bridge). The fare/display uses the former; the charge uses the latter.
|
||||||
|
- **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C2) — with only the inverse rate present,
|
||||||
|
`getExchangeRate(USD,ETB)=1.0` but `getRateOrThrow(USD,ETB)=100` → displayed fare and charged
|
||||||
|
amount differ 100×. Matrix C2/C5.
|
||||||
|
- **Fix**: one shared conversion routine with one rounding rule and one fallback policy.
|
||||||
|
|
||||||
|
### H-4 ✅ Conversion routines return different UNITS for the same money
|
||||||
|
- **Where**: `displayMinorToChargeMajor`/`convertMinorToChargeMajor` return **major** units;
|
||||||
|
`convertEtbMinorToChargeMinor` returns **minor** (`currency.service.ts:27,61,35`);
|
||||||
|
`payments.service.ts:250-281` writes the major result into a field named `amountMinor`.
|
||||||
|
- **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C5) — same amount comes out 100× apart.
|
||||||
|
- **Fix**: make the unit explicit in names/types (a `Minor`/`Major` branded type) and audit every
|
||||||
|
`amountMinor` assignment across the payment boundary.
|
||||||
|
|
||||||
|
### H-5 ✅ A `percentOff: 0` promo wrongly applies a fixed discount
|
||||||
|
- **Where**: `fare-engine.service.ts:185` — `promo.percentOff ? percent : amountOffMinor`; `0` is
|
||||||
|
falsy.
|
||||||
|
- **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` (D4) — promo `{percentOff:0,
|
||||||
|
amountOffMinor:5000}` deducts 5000 instead of 0.
|
||||||
|
- **Fix**: test `percentOff != null` rather than truthiness.
|
||||||
|
|
||||||
|
### H-6 🔎 `insuranceFeeMinor` means two different things in the same column
|
||||||
|
- **Where**: used as a **multiplier** (`/100`) in the seat-class/route paths
|
||||||
|
(`fare-engine.service.ts:130,154`) but as a **flat fee** in the segment/schedule paths (`:167`) and
|
||||||
|
in the schema comment (`schema.prisma:95`).
|
||||||
|
- **Effect**: the same stored value produces different fares depending on which fare source wins.
|
||||||
|
Matrix B1.
|
||||||
|
- **Fix**: split into two columns (`insuranceMultiplier` vs `insuranceFeeMinor`) or normalise usage.
|
||||||
|
|
||||||
|
### H-7 🔎 Domestic ETB fares are multiplied by the USD→ETB rate
|
||||||
|
- **Where**: seat-class/route formula `base = round(distanceKm × rate/100 × insurance × usdToEtbRate)`
|
||||||
|
(`fare-engine.service.ts:157-160`). For a LOCAL (ETB) fare this multiplies by USD→ETB.
|
||||||
|
- **Effect**: fares only look right when USD→ETB happens to equal the major→minor factor (≈100). Set
|
||||||
|
a realistic rate (~132) and every domestic fare is ~30% off. Matrix B2. (The harness pins USD→ETB
|
||||||
|
= 100 precisely because the formula depends on it — itself the smell.)
|
||||||
|
- **Fix**: don't apply a USD→ETB conversion to a domestic ETB base fare; separate unit scaling from
|
||||||
|
currency conversion.
|
||||||
|
|
||||||
|
### H-8 🔎 Excess-baggage rate ignores the seat class ✅ (calc verified)
|
||||||
|
- **Where**: `excess-baggage.service.ts:53` — `baggageAllowance.findFirst({ orderBy:{createdAt:'asc'}})`
|
||||||
|
(oldest global row, no `where`).
|
||||||
|
- **Repro (verified)**: `money-integrity.e2e-spec.ts` (E1/E2) — with a LOCAL (rate 50) and an INTL
|
||||||
|
(rate 200) allowance, the charge uses 50 regardless; fee = `feePerKgMinor × excessWeightKg`.
|
||||||
|
- **Fix**: look up the allowance by the booking's `seatClassId`.
|
||||||
|
|
||||||
|
### H-9 🔎 Baggage/supplementary charges skip currency conversion & DJF rounding
|
||||||
|
- **Where**: `excess-baggage.service.ts:166` and `supplementary-charges.service.ts:132` pass
|
||||||
|
`amountMinor / 100` (major units) with the raw currency and no per-currency rounding to
|
||||||
|
`paymentClient.initiate`.
|
||||||
|
- **Effect**: wrong amount for DJF (0-decimal) and any non-ETB currency. Matrix E2/E-supp.
|
||||||
|
- **Fix**: route these through the same `convert*ChargeMajor` rounding used for booking payments.
|
||||||
|
|
||||||
|
### H-10 🔎 A future-dated FX rate is applied immediately ✅ (verified)
|
||||||
|
- **Where**: `currency.service.ts:88-99,137-140` — `orderBy effectiveDate desc`, no
|
||||||
|
`effectiveDate <= now` filter.
|
||||||
|
- **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C3) — a rate dated one year out is used now.
|
||||||
|
- **Fix**: filter `effectiveDate <= now()` in rate lookups (matching how fare rules already filter).
|
||||||
|
|
||||||
|
### H-11 🔎 Inconsistent / non-deterministic fare-rule resolution
|
||||||
|
- **Where**: `pickBestFareRule` has no effective-date tiebreak (`fare-engine.service.ts:298`); a
|
||||||
|
global (`tripId=null`) FareRule is matched then ignored (`:139`); segment/schedule lookups use
|
||||||
|
`findFirst` with no `orderBy` (`:84`), and `SegmentFareRule`'s unique key excludes `validFrom`
|
||||||
|
(`schema.prisma:1113`) so fares can't be versioned by date. Matrix B4/B5.
|
||||||
|
- **Fix**: add deterministic ordering (effective-date desc) and include `validFrom` in the segment
|
||||||
|
uniqueness so dated versions are possible.
|
||||||
|
|
||||||
|
### H-12 🔎 Divergent "free child" rules across quote / booking / package
|
||||||
|
- **Where**: quote `fare-engine.service.ts:172` uses `min(child, adult)`; booking
|
||||||
|
`bookings.service.ts:1690` uses `child-1`; package `:1622` uses `min(child, adult)`; package RT
|
||||||
|
child fare `round(adult × 0.1)` float (`payments.service.ts:135,180`, `bookings.service.ts:34-40`).
|
||||||
|
- **Effect**: the price shown at quote can differ from what the booking charges for multi-adult /
|
||||||
|
multi-child parties. Matrix B6/B7.
|
||||||
|
- **Fix**: one shared fare function used by quote, booking, and payment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM — backoffice config accepts invalid data / unsafe deletes
|
||||||
|
|
||||||
|
### M-1 ✅ Negative fares accepted (missing `@Min`)
|
||||||
|
- **Where**: `schedules.dto.ts:85,95` (`CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`,
|
||||||
|
`@IsInt` only); `seat-classes.dto.ts:29` (`basePrice`). Sibling `segments/segment-fare.dto.ts:24`
|
||||||
|
*does* have `@Min(0)` — inconsistent.
|
||||||
|
- **Repro (verified)**: `config-validation.e2e-spec.ts` (H1/H2) — negative values pass validation;
|
||||||
|
the guarded sibling rejects them.
|
||||||
|
- **Fix**: add `@Min(0)` to every money DTO field.
|
||||||
|
|
||||||
|
### M-2 ✅ Promo bounds/date not validated
|
||||||
|
- **Where**: `promos.dto.ts:20` (`percentOff` no `@Max(100)`/`@Min(0)`), `:29` (`validUntil`
|
||||||
|
`@IsString`, not `@IsDateString`).
|
||||||
|
- **Repro (verified)**: `config-validation.e2e-spec.ts` (H4/H5) — `percentOff:200` and
|
||||||
|
`validUntil:"not-a-real-date"` both pass.
|
||||||
|
- **Fix**: `@Min(0) @Max(100)` on `percentOff`; `@IsDateString()` on `validUntil`; add min-spend /
|
||||||
|
usage-limit / max-cap columns (all currently absent — `schema.prisma:785`).
|
||||||
|
|
||||||
|
### M-3 🔎 `PATCH /config` accepts arbitrary unvalidated key/values
|
||||||
|
- **Where**: `system-config.controller.ts:34` (no DTO) → `system-config.service.ts:56-59` stores a
|
||||||
|
raw `Record<string,string>`. Setting `seat_hold_duration_minutes = -1` or `"abc"` is persisted.
|
||||||
|
Matrix H7.
|
||||||
|
- **Fix**: a whitelisted, typed DTO with per-key numeric/range validation.
|
||||||
|
|
||||||
|
### M-4 🔎 Past-dated schedules accepted; train can be double-booked across routes
|
||||||
|
- **Where**: `schedules.service.ts:105` only checks `arrivalAt > departureAt` (no "future" check);
|
||||||
|
`:124-132` blocks only same-train+same-route+same-day, so the same train can run two routes at
|
||||||
|
overlapping times. Matrix H3/H4.
|
||||||
|
- **Fix**: reject past `departureAt`; widen the overlap check to the train across all routes.
|
||||||
|
|
||||||
|
### M-5 🔎 Deletes ignore referencing bookings; one cascade is non-transactional
|
||||||
|
- **Where**: station delete ignores bookings (`stations.service.ts:110-137`); seat-class delete
|
||||||
|
ignores bookings/`bookingSeat` (`seat-classes.service.ts:53-81`); `currencies.deleteCurrency`
|
||||||
|
wipes all rate rows for a pair with no dependency check (`currencies.service.ts:119-134`) → future
|
||||||
|
fares for that pair fall to the 1.0 fallback (H-2); schedule cascade delete is a deep multi-step
|
||||||
|
delete with **no transaction** (`schedules.service.ts:438-485`) → partial-delete on failure.
|
||||||
|
Matrix I3–I6.
|
||||||
|
- **Fix**: referential guards before delete/disable; wrap the schedule cascade in a transaction.
|
||||||
|
|
||||||
|
### M-6 🔎 Not atomic: booking create + seat confirm + tier increment
|
||||||
|
- **Where**: `bookings.service.ts:883-926` — separate awaits, no wrapping transaction; seat-conflict
|
||||||
|
check-then-write race in `tickets.service.ts:357-372`. Matrix G8.
|
||||||
|
- **Fix**: wrap the create/confirm/increment in a single transaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW / UI
|
||||||
|
|
||||||
|
### L-1 🔎 Portal shows DJF with 2 decimals but charges whole francs
|
||||||
|
- **Where**: `portal/src/utils/format.ts:22-28` (`Intl.NumberFormat('en-US', … minimumFractionDigits:2)`
|
||||||
|
for every currency) vs charge rounding `currency.service.ts:9-13` (DJF = 0 decimals). Matrix C6/K3.
|
||||||
|
- **Status**: needs the Playwright/UI suite (not yet run — see below).
|
||||||
|
- **Fix**: format per `CHARGE_CURRENCY_DECIMALS`.
|
||||||
|
|
||||||
|
### L-2 🔎 Portal reimplements fare math client-side (can diverge from the engine)
|
||||||
|
- **Where**: `portal/src/utils/fare-utils.ts:50,67,93`; `portal/src/app/booking/review/page.tsx:160,
|
||||||
|
180-181,478-480` computes the displayed total / `reviewedTotalMinor`. Matrix K1/K2 + ties to C-1.
|
||||||
|
- **Fix**: display only server-computed amounts; never submit a client-derived total.
|
||||||
|
|
||||||
|
### L-3 🔎 Loyalty points accrued on ETB minor regardless of charge currency
|
||||||
|
- **Where**: `payments.service.ts:1062,1067` — `floor(amountMinor/100)` on `booking.totalMinor`
|
||||||
|
(always ETB minor). Matrix F5.
|
||||||
|
- **Fix**: accrue from the actual charged amount/currency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not yet covered (honest gaps)
|
||||||
|
|
||||||
|
- **Suite K (browser / Playwright)** — L-1 and L-2 (UI price rendering & client-side fare math) are
|
||||||
|
confirmed by source reading but **not** yet reproduced in a browser. Running them needs the portal
|
||||||
|
+ backoffice Next.js apps up with a seeded search result. Scaffolding is the remaining step of the
|
||||||
|
"light Playwright" scope.
|
||||||
|
- **C-1, C-4, C-6** are now reproduced (`critical-repro.e2e-spec.ts`). **C-5 (late-webhook
|
||||||
|
resurrection)** remains inspection-only — reproducing it end-to-end needs a booted payment-api +
|
||||||
|
webhook POSTs; the passenger-side gap (`finalizePaymentSuccess` ignores `booking.status`) is
|
||||||
|
directly readable.
|
||||||
|
- **`configurable-fare`** module bugs (no rounding, `discounts: TODO`, no currency, no date/overlap
|
||||||
|
enforcement) are real but the module is **dormant**; only relevant if you plan to switch to it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested priority order to fix
|
||||||
|
|
||||||
|
1. **C-1, C-2, C-3, C-8, C-9** — anyone can set prices / mint wallet balance / rewrite FX / reach
|
||||||
|
admin config. These are actively exploitable.
|
||||||
|
2. **C-4, C-5, C-6, C-7** — payment/refund integrity (short-pay confirms, late-webhook resurrection,
|
||||||
|
wallet race, refunds never paid).
|
||||||
|
3. **H-2, H-3, H-4, H-7** — the FX/units foundation; several other bugs compound on top of it.
|
||||||
|
4. **H-1, H-5, H-8..H-12, M-1, M-2** — pricing correctness + validation gaps.
|
||||||
|
5. **M-3..M-6, L-1..L-3** — config safety and UI consistency.
|
||||||
174
docs/e2e-test-matrix.md
Normal file
174
docs/e2e-test-matrix.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# EDR Passenger Platform — E2E Test Matrix (Phase 1 deliverable)
|
||||||
|
|
||||||
|
**Goal:** find real issues, prioritizing pricing integrity and backoffice configuration.
|
||||||
|
**Status:** DRAFT for review. No tests written yet. Nothing runs against production.
|
||||||
|
|
||||||
|
Two systems were discovered that shape everything below:
|
||||||
|
|
||||||
|
- **Two parallel fare systems.** `fare-engine` (integer "minor" math) is the **live** pricing pipeline. `configurable-fare` (raw-SQL, `fare_configurations`) is fully built but **never called by the live path** (`fare-engine.calculate` never reads `fare_configurations`). *Assumption for this matrix: we target `fare-engine` as the system of record and treat `configurable-fare` as dormant (test only that it is not wired in).* ⚠️ **Confirm.**
|
||||||
|
- **The domain seed is disabled.** Every step in `prisma/seed.ts main()` (~L894) is commented out — `pnpm prisma:seed` creates nothing. The harness must re-enable/call the seeders or build fixtures.
|
||||||
|
|
||||||
|
Legend for **Predicted**: 🔴 = looks like a confirmed defect from static read (test will document/repro), 🟠 = suspicious, needs runtime verification, 🟢 = expected to pass (guard/happy-path).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The master invariant (Suite A drives everything)
|
||||||
|
|
||||||
|
For every booking flow, assert the chain is equal at every hop:
|
||||||
|
|
||||||
|
```
|
||||||
|
portal displayed price == API fare-quote == amount stored on booking (totalMinor/displayTotalMinor)
|
||||||
|
== amount sent to payment-api (intent) == amount actually charged (webhook)
|
||||||
|
== amount used for loyalty accrual == refund basis on cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
Any inequality is a finding. The explorers show this chain is **broken by design** in several places (client-supplied totals, pay-time recompute+overwrite, four different currency-conversion routines).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suite A — Pricing integrity & client-trust (API-level, HIGHEST PRIORITY)
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets (file:line) | Predicted |
|
||||||
|
|----|----------|----------|---------------------|-----------|
|
||||||
|
| A1 | Book with `reviewedTotalMinor: 1` on a real fare | Server rejects / overrides with computed fare | `bookings.service.ts:863-895` | 🔴 books for 1 |
|
||||||
|
| A2 | Book with every `seatFareMinor: 0` | Reject / override | `bookings.service.ts:863` | 🔴 books for 0 |
|
||||||
|
| A3 | Round-trip with forged `returnSeatFareMinor` | Reject / override | `bookings.service.ts:1065-1095` | 🔴 |
|
||||||
|
| A4 | Guest booking with forged total | Reject / override | `guest-booking.service.ts:206-245,494-540` | 🔴 |
|
||||||
|
| A5 | `loyaltyRedemptionPoints: 999999` on a 0-point account | Reject; no discount; no negative total | `bookings.service.ts:1028`; `bookings.dto.ts:155` | 🔴 total→0, no deduction |
|
||||||
|
| A6 | Confirm displayed==stored==intent==charged for a clean one-way ETB booking | All equal | whole chain | 🟠 baseline |
|
||||||
|
| A7 | Same cross-check for USD/DJF display currency | All equal, correct rounding | `payments.service.ts:250-267` | 🟠 DJF rounding suspect |
|
||||||
|
| A8 | `initiatePayment` overwrites `booking.totalMinor` at pay time | Read path must not mutate order amount | `payments.service.ts:167-185,209-218` | 🔴 mutates DB on read |
|
||||||
|
| A9 | Payment intent `amountMinor` field carries **major** units across service boundary | Consistent unit contract | `payments.service.ts:272-281` | 🟠 unit-confusion |
|
||||||
|
|
||||||
|
## Suite B — Fare computation correctness (integration against fare-engine)
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| B1 | `insuranceFeeMinor` semantics: multiplier vs flat fee | One consistent meaning | `fare-engine.service.ts:130,154,167` vs schema:95 | 🔴 two meanings, same column |
|
||||||
|
| B2 | Unit scale: `/100` in code vs "×100000" schema comment | Documented, consistent | `fare-engine.service.ts:129,153` vs schema:93 | 🟠 1000× ambiguity |
|
||||||
|
| B3 | INTERNATIONAL 2× surcharge across all 4 fare sources | Applied consistently | `fare-engine.service.ts:120,141` (missing in route/seat-class) | 🔴 inconsistent |
|
||||||
|
| B4 | Global (tripId=null) FareRule that wins priority | Used | `fare-engine.service.ts:139` | 🔴 matched then ignored |
|
||||||
|
| B5 | Overlapping segment/schedule fare rules, no orderBy | Deterministic pick | `fare-engine.service.ts:84`; schema:1113 | 🔴 arbitrary DB order |
|
||||||
|
| B6 | Free-child rule consistency: quote vs booking vs package | Same rule everywhere | `fare-engine.service.ts:172` vs `bookings.service.ts:1690` vs `:1622` | 🔴 3 divergent rules |
|
||||||
|
| B7 | Package round-trip child fare `round(adult × 0.1)` float | Integer, single rule | `payments.service.ts:135,180`; `bookings.service.ts:34-40` | 🔴 float, 3rd rule |
|
||||||
|
| B8 | Distance from nullable `distanceKm` float subtraction | Guarded, integer-safe | `fare-engine.service.ts:46` | 🟠 |
|
||||||
|
|
||||||
|
## Suite C — Currency / FX
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| C1 | Missing USD→ETB rate row | Reject / block, not silent 1.0 | `currency.service.ts:142-147` | 🔴 prices at parity, display path only warns |
|
||||||
|
| C2 | Missing rate: display path returns 1.0 but charge path throws | Same behavior both paths | `currency.service.ts:142-147` vs `:108` | 🔴 divergence |
|
||||||
|
| C3 | Future-dated FX rate | Not applied until effective | `currency.service.ts:88-99,137` (no `<= now` filter) | 🔴 applies immediately |
|
||||||
|
| C4 | Stale FX (>2 days) | Blocked or refreshed | `currency.service.ts:149-154` | 🟠 only warns, still used |
|
||||||
|
| C5 | Four conversion routines produce same result for same inputs | Identical rounding | `fare-engine:196`, `currency:61,78`, `payments:733` | 🔴 divergent |
|
||||||
|
| C6 | DJF (0-decimal) display vs charge rounding | Consistent whole-franc | `format.ts:22-28` vs `currency.service.ts:9-13` | 🔴 UI shows 2 decimals |
|
||||||
|
|
||||||
|
## Suite D — Promos
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| D1 | `percentOff: 200` | Reject (max 100) / clamp total at 0 | `promos.dto.ts:20`; `fare-engine.service.ts:185-192` | 🔴 negative total |
|
||||||
|
| D2 | `amountOffMinor` > subtotal | Clamp at 0 | `promos.dto.ts:26`; `fare-engine.service.ts:187,192` | 🔴 negative total |
|
||||||
|
| D3 | Reuse one promo N times / across users | Usage-limit enforced | `bookings.service.ts:1023-1029`; no limits in schema | 🔴 unlimited |
|
||||||
|
| D4 | `percentOff: 0` legit promo | Applies as 0%, not mislabeled FIXED | `fare-engine.service.ts:185`; `promos.service.ts:172` | 🟠 falsy bug |
|
||||||
|
| D5 | `validUntil` as arbitrary string / past date | Reject invalid, no dead promo | `promos.dto.ts:29-30` (`@IsString`) | 🔴 accepts Invalid Date |
|
||||||
|
| D6 | Promo min-spend / max-cap | Enforced | schema:785 (fields absent) | 🔴 none exist |
|
||||||
|
|
||||||
|
## Suite E — Excess baggage & supplementary charges
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| E1 | Excess-baggage rate lookup by seat class | Uses booking's class allowance | `excess-baggage.service.ts:53` (oldest global row) | 🔴 wrong allowance |
|
||||||
|
| E2 | Baggage/supp charge to payment: `/100` major units, DJF | Per-currency rounding, correct unit | `excess-baggage.service.ts:166`; `supplementary-charges.service.ts:132` | 🔴 no conversion/rounding |
|
||||||
|
| E3 | Negative `maxWeightKg`/`maxPiecesCount` allowance | Reject | `excess-baggage.controller.ts:15-16` (no `@Min`) | 🔴 accepts negative |
|
||||||
|
| E4 | `markPaid` stores `providerTxnId` | Persisted | `excess-baggage.service.ts:186` | 🟠 discarded |
|
||||||
|
|
||||||
|
## Suite F — Wallet & loyalty
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| F1 | Top up another passenger's wallet with your JWT | 403 | `wallet.controller.ts:34-39` (no ownership check) | 🔴 credits freely |
|
||||||
|
| F2 | Wallet top-up has payment backing | Backed by real payment | `wallet.service.ts:50-56` | 🔴 free money |
|
||||||
|
| F3 | `GET /wallet/accounts` public | Auth required | `wallet.controller.ts:23-24` (`isPublic`) | 🔴 leaks balances |
|
||||||
|
| F4 | Two concurrent WALLET bookings draining one balance | One fails, no negative | `payments.service.ts:461-484` (no row lock) | 🔴 double-spend |
|
||||||
|
| F5 | Loyalty accrual on non-ETB charge | Points from actual charge currency | `payments.service.ts:1062,1067` | 🟠 uses ETB minor always |
|
||||||
|
| F6 | Loyalty redemption deducts points / has balance | Deducted, capped | `bookings.service.ts:1028` | 🔴 never deducted (=A5) |
|
||||||
|
|
||||||
|
## Suite G — Booking/payment lifecycle & webhooks
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| G1 | Webhook `confirmedAmount` < booking total (partial) | Not confirmed | `intents.service.ts:541-548` | 🔴 confirms, mismatch only logged |
|
||||||
|
| G2 | Pay a booking >20 min after creation (expired/cancelled) | Reject | `payments.service.ts:809-848`; `bookings.service.ts:2123` | 🔴 re-confirms, re-issues tickets |
|
||||||
|
| G3 | Duplicate webhook | Idempotent | `webhook-processor.service.ts:44-58` | 🟢 handled |
|
||||||
|
| G4 | Cancel a CONFIRMED booking → refund disbursed | Refund paid to wallet/provider | `bookings.service.ts:2017-2027` | 🔴 stuck PENDING forever |
|
||||||
|
| G5 | Refund amount `floor(total × 0.8)` flat | Correct tiered policy | `bookings.service.ts:2021` | 🟠 flat 80%, float |
|
||||||
|
| G6 | Seat-hold TTL (config) vs pending-expiry cron (hardcoded 20m) | Consistent | `seats.service.ts:271` vs `bookings.service.ts:2123` | 🔴 mismatch |
|
||||||
|
| G7 | Payment amount validated against booking anywhere | Validated | passenger-api + payment-api | 🔴 never |
|
||||||
|
| G8 | Booking create + seat confirm + tier increment atomic | Single transaction | `bookings.service.ts:883-926` | 🟠 not atomic |
|
||||||
|
| G9 | `forceConfirmPayment` admin-guarded | Admin only | `payments.service.ts:989` | 🟠 verify guard |
|
||||||
|
|
||||||
|
## Suite H — Backoffice config validation gaps (API-level, direct-to-API bypassing UI)
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| H1 | Negative `baseFareMinor` fare rule | Reject | `schedules.dto.ts:85,95` (no `@Min`) | 🔴 accepts (sibling DTO has `@Min`) |
|
||||||
|
| H2 | Negative/zero seat-class `basePrice` | Reject | `seat-classes.dto.ts:29` | 🔴 accepts |
|
||||||
|
| H3 | Past `departureAt` schedule | Reject | `schedules.service.ts:105` | 🔴 accepts |
|
||||||
|
| H4 | Same train, two routes, overlapping time (same day) | Reject double-booking | `schedules.service.ts:124-132` | 🔴 accepts |
|
||||||
|
| H5 | Fare rule `validUntil` < `validFrom`; overlapping windows | Reject | `schedules.dto.ts`; no ordering/overlap check | 🔴 accepts |
|
||||||
|
| H6 | Duplicate station `code` | Reject (P2002) | `stations.service.ts:57-61` | 🟠 no catch (verify schema unique) |
|
||||||
|
| H7 | `PATCH /config` arbitrary key/value (e.g. `seat_hold_duration_minutes:-1`) | Validated | `system-config.controller.ts:34` (no DTO) | 🔴 stored raw |
|
||||||
|
| H8 | Unsupported currency code (outside ETB/USD/DJF enum) | 400 not 500 | `currencies.dto.ts:5`; `currencies.service.ts:55` | 🟠 |
|
||||||
|
| H9 | Station lat/lng out of ±90/±180 | Reject | `stations.dto.ts:9-10` | 🟠 |
|
||||||
|
|
||||||
|
## Suite I — Config propagation & delete/disable semantics
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| I1 | Change exchange rate in backoffice → portal reflects it | Propagates (note 5-min staleTime) | `portal/useCurrencies.ts:20` | 🟠 up to 5 min stale |
|
||||||
|
| I2 | Change a fare in backoffice → next search reflects it | Live (no server cache) | `fare-engine.service.ts:29,59` | 🟢 no cache |
|
||||||
|
| I3 | Delete a station referenced by bookings | Blocked or safe | `stations.service.ts:110-137` (ignores bookings) | 🔴 orphan/FK risk |
|
||||||
|
| I4 | Delete a seat-class referenced by bookings/bookingSeat | Blocked or safe | `seat-classes.service.ts:53-81` | 🔴 ignores bookings |
|
||||||
|
| I5 | Schedule cascade delete fails midway | Transactional, no partial delete | `schedules.service.ts:438-485` | 🟠 non-transactional |
|
||||||
|
| I6 | Delete currency with active fares/rates | Blocked | `currencies.service.ts:119-134` | 🔴 wipes rates → 1.0 fallback |
|
||||||
|
| I7 | Config change mid-flight (edit/disable fare between quote and pay) | Defined behavior | booking freezes at create; pay never re-quotes | 🟠 client-trusted gap |
|
||||||
|
|
||||||
|
## Suite J — Auth / authorization gaps
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Targets | Predicted |
|
||||||
|
|----|----------|----------|---------|-----------|
|
||||||
|
| J1 | Unauthenticated `PUT/PATCH /fare-engine/exchange-rates` | 401 | `fare-engine/currency.controller.ts:25,32` (no guard) | 🔴 anyone rewrites FX |
|
||||||
|
| J2 | Non-admin authenticated user CRUDs `/admin/fare-configurations` | 403 | `configurable-fare.controller.ts` (`@Roles` dead) | 🔴 RolesGuard never wired |
|
||||||
|
| J3 | Non-admin CRUDs `/admin/segment-fares` | 403 | `segment-fare.controller.ts:15` | 🔴 |
|
||||||
|
| J4 | Non-admin reads/writes `/config` | 403 | `system-config.controller.ts:23,32` | 🔴 |
|
||||||
|
| J5 | Public exposure of `/search`, `/currencies`, `/wallet/accounts` | Intended-public only | `search.controller.ts`, `wallet.controller.ts:24` | 🟠 balances shouldn't be public |
|
||||||
|
|
||||||
|
## Suite K — Browser E2E (Playwright, portal + backoffice)
|
||||||
|
|
||||||
|
| ID | Scenario | Expected | Layer |
|
||||||
|
|----|----------|----------|-------|
|
||||||
|
| K1 | Portal: search → results price == API `displayAmountMinor` | UI math matches server | portal (`fare-utils.ts`, `results/page.tsx:609`) |
|
||||||
|
| K2 | Portal: review page total == what booking stores == charged | No client-side divergence | portal (`review/page.tsx:160-181,478`) |
|
||||||
|
| K3 | Portal: DJF fare rendered whole-franc, matches charge | Correct formatting | `format.ts:22-28` |
|
||||||
|
| K4 | Backoffice: create fare → portal search shows new price | End-to-end propagation | backoffice→portal |
|
||||||
|
| K5 | Backoffice: disable station → disappears from portal search | Honored | backoffice→portal |
|
||||||
|
| K6 | Backoffice: create promo → apply in portal → correct discount, no negative | End-to-end | backoffice→portal |
|
||||||
|
| K7 | Full happy-path booking (WALLET) through portal to ticket | Issued, amounts consistent | portal+api |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Harness plan (Phase 2 preview)
|
||||||
|
|
||||||
|
- **API tests (supertest):** reuse the `payments.e2e-spec.ts` fixture-builder pattern (full Prisma object graph + teardown). Most target endpoints are `isPublic`, so auth is cheap. Wire a real config (`test/jest-e2e.json` currently won't even pick up in-src `*.e2e-spec.ts`).
|
||||||
|
- **Browser tests (Playwright):** greenfield — add runner + config. Portal has no server-side auth gate; backoffice needs `auth_token` cookie + localStorage seeded.
|
||||||
|
- **Payments:** WALLET is fully offline-testable. Gateway flows driven by POSTing directly to `/webhooks/<provider>` on payment-api (Telebirr/CBE/eBirr have loose signature gating; Card/Waafi need valid HMAC). `SERVICE_AUTH_TOKEN` unset in dev = internal endpoints unguarded.
|
||||||
|
- **Seed:** re-enable `prisma/seed.ts` steps or invoke seeder fns from a test bootstrap. Needs stations, routes+stops (distanceKm), schedules, seat classes, fare rules, FX rates, promos.
|
||||||
|
- **DB:** ⚠️ doc drift — CLAUDE.md says `postgres-passenger:5434/edr_passenger`; actual `.env.example` says `localhost:5432/edr_database?schema=passenger`; no compose file provisions it. **Need target confirmed.**
|
||||||
|
|
||||||
|
## Open decisions (blocking Phase 2)
|
||||||
|
|
||||||
|
1. **Environment** — is there a dev/staging DB + running stack I should target, or should the harness stand up a local Postgres (Docker) + seed + run the APIs itself?
|
||||||
|
2. **Fare system** — confirm `fare-engine` is the system of record and `configurable-fare` is dormant.
|
||||||
|
3. **Emphasis** — API-level abuse/integration tests (fast, high signal, covers ~90% of the leads above) vs. also full browser Playwright E2E (Suite K, slower, needs both web apps running).
|
||||||
76
e2e/README.md
Normal file
76
e2e/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# EDR Passenger — Pricing/Config E2E Harness
|
||||||
|
|
||||||
|
Hermetic, bug-hunting test harness for the passenger platform. Targets **pricing integrity** and
|
||||||
|
**backoffice configuration**. Never touches a real database.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bring up the isolated test Postgres (port 5544) and apply all migrations
|
||||||
|
bash e2e/prepare.sh
|
||||||
|
# (or: pnpm --filter @edr/passenger-api test:e2e:prepare)
|
||||||
|
|
||||||
|
# 2. Run the suites
|
||||||
|
pnpm --filter @edr/passenger-api test:e2e
|
||||||
|
|
||||||
|
# 3. Tear down
|
||||||
|
pnpm --filter @edr/passenger-api test:e2e:db:down
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's isolated
|
||||||
|
|
||||||
|
- `e2e/docker-compose.yml` — Postgres 17 on host port **5544**, container `edr-passenger-e2e-db`,
|
||||||
|
`tmpfs` data (wiped on `down`). Distinct from any dev/prod DB. Schemas `passenger`, `iam`,
|
||||||
|
`edr_payment` created by `e2e/init/01-schemas.sql`.
|
||||||
|
- `apps/edr-passenger-api/.env.test` — points every connection at 5544; brokers/IAM/Fayda OFF.
|
||||||
|
Loaded by `test/setup/load-env.ts` before the app boots.
|
||||||
|
|
||||||
|
## Architecture — why two tiers
|
||||||
|
|
||||||
|
The full `AppModule` cannot be booted in-process under jest:
|
||||||
|
- `@tria-plc/api-common` (pulled via IAM) `require("file-type")`, which is ESM-only → jest's
|
||||||
|
CommonJS resolver fails. (Worked around with a `moduleNameMapper` stub, but…)
|
||||||
|
- `@golevelup/nestjs-rabbitmq` + microservice RMQ clients + `onApplicationBootstrap` seeders hang
|
||||||
|
the boot waiting on a broker that isn't there.
|
||||||
|
|
||||||
|
So tests use one of two tiers:
|
||||||
|
|
||||||
|
**Tier 1 — slim module harness** (`test/setup/slim-app.ts`). Boots ONLY the pricing/config domain
|
||||||
|
modules that are free of the IAM/RabbitMQ chain: `fare-engine, currency, currencies, promos,
|
||||||
|
seat-classes, stations, schedules, segments, system-config`. Two entry points:
|
||||||
|
- `createServiceHarness()` — resolve services (e.g. `FareEngineService`) for direct method calls.
|
||||||
|
- `createHttpHarness()` — full HTTP app with the SAME `ValidationPipe` as `src/main.ts`, for
|
||||||
|
controller/DTO/pipe (client-trust, validation) tests over supertest.
|
||||||
|
|
||||||
|
**Tier 2 — direct instantiation** (`test/setup/prisma.ts`). For services behind the wall
|
||||||
|
(`BookingsService, PaymentsService, WalletService, LoyaltyService, ExcessBaggageService`):
|
||||||
|
`new TheService(getTestPrisma(), ...mockedCollaborators)` and assert the money logic. Avoids booting
|
||||||
|
the module graph entirely.
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
`test/fixtures/seed-core.ts` — deterministic graph (coach type → LOCAL/INTERNATIONAL seat classes →
|
||||||
|
3 stations → route with distance-bearing stops → FX rates) with fixed UUIDs in `IDS`. Call
|
||||||
|
`resetAndSeedCore(prisma)` in `beforeEach`. The repo's `prisma/seed.ts` is disabled (all steps
|
||||||
|
commented out) and is intentionally NOT used.
|
||||||
|
|
||||||
|
## Suites (see `docs/e2e-test-matrix.md` for the full matrix)
|
||||||
|
|
||||||
|
Spec files are `test/*.e2e-spec.ts`. Each is tagged with the matrix IDs it covers. 🔴 in a test name
|
||||||
|
marks a confirmed defect the test documents/reproduces (the assertion encodes the BUGGY behavior;
|
||||||
|
a passing 🔴 test = the bug is present).
|
||||||
|
|
||||||
|
Current suites (all green):
|
||||||
|
- `pricing-fare-engine.e2e-spec.ts` — baseline + D1/D2/D4 (promo → negative total), C1 (FX fallback)
|
||||||
|
- `pricing-currency.e2e-spec.ts` — C2/C2b (display↔charge FX divergence), C3 (future rate), C5 (unit divergence)
|
||||||
|
- `money-integrity.e2e-spec.ts` — F1/F2 (free wallet top-up), G4/G5 (refund never disbursed), E1/E2 (baggage)
|
||||||
|
- `config-validation.e2e-spec.ts` — H1/H2 (negative fares), H4/H5 (promo bounds/date)
|
||||||
|
- `auth-gaps.e2e-spec.ts` — J1 (unauthenticated FX writes)
|
||||||
|
- `critical-repro.e2e-spec.ts` — C-1 (client-controlled booking total), C-4 (payment amount never
|
||||||
|
validated), C-6 (wallet double-spend via a deterministic race barrier)
|
||||||
|
|
||||||
|
`test/app.e2e-spec.ts` is a pre-existing repo test that boots the FULL AppModule; it is excluded via
|
||||||
|
`testPathIgnorePatterns` because that boot hangs in-process (RabbitMQ connect + ESM `file-type`) — a
|
||||||
|
harness limitation documented above, not a product bug.
|
||||||
|
|
||||||
|
Findings are catalogued in `docs/ISSUES.md`.
|
||||||
23
e2e/docker-compose.yml
Normal file
23
e2e/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Hermetic test database for the EDR passenger E2E harness.
|
||||||
|
# Isolated from any dev/prod Postgres: distinct container name + non-standard host port (5544).
|
||||||
|
# Single database `edr_database` with schemas `passenger`, `iam`, `edr_payment` (see init/01-schemas.sql).
|
||||||
|
services:
|
||||||
|
postgres-e2e:
|
||||||
|
image: postgres:17
|
||||||
|
container_name: edr-passenger-e2e-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: edr
|
||||||
|
POSTGRES_PASSWORD: edr_secret
|
||||||
|
POSTGRES_DB: edr_database
|
||||||
|
ports:
|
||||||
|
- "5544:5432"
|
||||||
|
volumes:
|
||||||
|
- ./init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U edr -d edr_database"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
tmpfs:
|
||||||
|
# Ephemeral storage — every `docker compose down` wipes the DB. Nothing to clean up.
|
||||||
|
- /var/lib/postgresql/data
|
||||||
6
e2e/init/01-schemas.sql
Normal file
6
e2e/init/01-schemas.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Runs once on first container start (Postgres initdb hook).
|
||||||
|
-- Prisma migrate (passenger) and TypeORM migrate (iam) create their own tables,
|
||||||
|
-- but the schemas must exist first. edr_payment is owned by the payment-api.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS iam;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS edr_payment;
|
||||||
33
e2e/prepare.sh
Executable file
33
e2e/prepare.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bring up the hermetic test DB and apply all migrations. Idempotent — safe to re-run.
|
||||||
|
# Usage: bash e2e/prepare.sh (from repo root or anywhere)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
API="$HERE/../apps/edr-passenger-api"
|
||||||
|
|
||||||
|
export DATABASE_URL="postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger"
|
||||||
|
export DATABASE_HOST=localhost DATABASE_PORT=5544 DATABASE_NAME=edr_database
|
||||||
|
export DATABASE_USER=edr DATABASE_PASSWORD=edr_secret DATABASE_SCHEMA=iam
|
||||||
|
|
||||||
|
echo "==> Starting test Postgres (port 5544)"
|
||||||
|
docker compose -f "$HERE/docker-compose.yml" up -d
|
||||||
|
|
||||||
|
echo "==> Waiting for healthy"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
status="$(docker inspect --format '{{.State.Health.Status}}' edr-passenger-e2e-db 2>/dev/null || echo none)"
|
||||||
|
[ "$status" = "healthy" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ "${status:-}" = "healthy" ] || { echo "DB did not become healthy"; exit 1; }
|
||||||
|
|
||||||
|
echo "==> Prisma migrate deploy (passenger schema)"
|
||||||
|
( cd "$API" && npx prisma migrate deploy )
|
||||||
|
|
||||||
|
echo "==> IAM TypeORM migrations (iam schema)"
|
||||||
|
( cd "$API" && node scripts/run-iam-migrations.cjs )
|
||||||
|
|
||||||
|
echo "==> Prisma client generate"
|
||||||
|
( cd "$API" && npx prisma generate >/dev/null )
|
||||||
|
|
||||||
|
echo "==> Ready. Run: pnpm --filter @edr/passenger-api test:e2e"
|
||||||
63
e2e/run.sh
Executable file
63
e2e/run.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-shot E2E: ensure Docker is up → start the test DB + migrations → run all suites → open the
|
||||||
|
# HTML dashboard. Safe to re-run. The DB is left running for fast subsequent runs unless --down.
|
||||||
|
#
|
||||||
|
# bash e2e/run.sh # run everything, leave the DB up, open the report
|
||||||
|
# bash e2e/run.sh --down # same, but tear the DB down afterwards
|
||||||
|
# bash e2e/run.sh --no-open # don't auto-open the browser (just print the path)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
API="$HERE/../apps/edr-passenger-api"
|
||||||
|
REPORT="$API/e2e-report/index.html"
|
||||||
|
|
||||||
|
DOWN=0; OPEN=1
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--down) DOWN=1 ;;
|
||||||
|
--no-open) OPEN=0 ;;
|
||||||
|
*) echo "unknown flag: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 1. Ensure the Docker daemon is running (start Docker Desktop on macOS if needed).
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "==> Docker daemon not running; attempting to start Docker Desktop…"
|
||||||
|
open -a Docker 2>/dev/null || { echo "Could not launch Docker. Start it manually and re-run."; exit 1; }
|
||||||
|
printf " waiting for Docker"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if docker info >/dev/null 2>&1; then echo " — up"; break; fi
|
||||||
|
printf "."; sleep 2
|
||||||
|
done
|
||||||
|
docker info >/dev/null 2>&1 || { echo; echo "Docker did not start in time."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Bring up the test DB + apply migrations (idempotent).
|
||||||
|
bash "$HERE/prepare.sh"
|
||||||
|
|
||||||
|
# 3. Run all suites (this also writes the HTML report via the jest-html-reporters config).
|
||||||
|
# Don't let a test failure abort the script — we still want to open the report.
|
||||||
|
set +e
|
||||||
|
( cd "$API" && npx jest --config ./test/jest-e2e.json )
|
||||||
|
JEST_EXIT=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 4. Open (or print) the report.
|
||||||
|
if [ -f "$REPORT" ]; then
|
||||||
|
if [ "$OPEN" -eq 1 ]; then
|
||||||
|
echo "==> Opening report: $REPORT"
|
||||||
|
open "$REPORT" 2>/dev/null || echo " (open it manually: $REPORT)"
|
||||||
|
else
|
||||||
|
echo "==> Report written: $REPORT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "!! No report generated (tests may have failed to run)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Optional teardown.
|
||||||
|
if [ "$DOWN" -eq 1 ]; then
|
||||||
|
echo "==> Tearing down the test DB"
|
||||||
|
docker compose -f "$HERE/docker-compose.yml" down
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$JEST_EXIT"
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...",
|
"build:passenger": "turbo run build --filter=@edr/passenger-api... --filter=@edr/passenger-portal... --filter=@edr/passenger-backoffice...",
|
||||||
"clean": "find . -type d -name dist -prune -exec rm -rf '{}' + && find . -type f -name '*.tsbuildinfo' -delete",
|
"clean": "find . -type d -name dist -prune -exec rm -rf '{}' + && find . -type f -name '*.tsbuildinfo' -delete",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
|
"test:e2e:passenger": "bash e2e/run.sh",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"type-check": "turbo run type-check",
|
"type-check": "turbo run type-check",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||||
|
|||||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -908,6 +908,9 @@ importers:
|
|||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
specifier: ^29.7.0
|
||||||
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||||
|
jest-html-reporters:
|
||||||
|
specifier: ^3.1.7
|
||||||
|
version: 3.1.7
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^6.19.3
|
specifier: ^6.19.3
|
||||||
version: 6.19.3(typescript@5.9.3)
|
version: 6.19.3(typescript@5.9.3)
|
||||||
@@ -6427,6 +6430,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
define-lazy-prop@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
define-lazy-prop@3.0.0:
|
define-lazy-prop@3.0.0:
|
||||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -7784,6 +7791,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww==}
|
resolution: {integrity: sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-docker@2.2.1:
|
||||||
|
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
is-docker@3.0.0:
|
is-docker@3.0.0:
|
||||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -7994,6 +8006,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
|
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-wsl@2.2.0:
|
||||||
|
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
is-wsl@3.1.1:
|
is-wsl@3.1.1:
|
||||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -8122,6 +8138,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==}
|
resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
|
||||||
|
jest-html-reporters@3.1.7:
|
||||||
|
resolution: {integrity: sha512-GTmjqK6muQ0S0Mnksf9QkL9X9z2FGIpNSxC52E0PHDzjPQ1XDu2+XTI3B3FS43ZiUzD1f354/5FfwbNIBzT7ew==}
|
||||||
|
|
||||||
jest-leak-detector@29.7.0:
|
jest-leak-detector@29.7.0:
|
||||||
resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==}
|
resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -9176,6 +9195,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
open@8.4.2:
|
||||||
|
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -18220,6 +18243,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
define-lazy-prop@2.0.0: {}
|
||||||
|
|
||||||
define-lazy-prop@3.0.0: {}
|
define-lazy-prop@3.0.0: {}
|
||||||
|
|
||||||
define-properties@1.2.1:
|
define-properties@1.2.1:
|
||||||
@@ -19904,6 +19929,8 @@ snapshots:
|
|||||||
is-accessor-descriptor: 1.0.2
|
is-accessor-descriptor: 1.0.2
|
||||||
is-data-descriptor: 1.0.1
|
is-data-descriptor: 1.0.1
|
||||||
|
|
||||||
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
is-docker@3.0.0: {}
|
is-docker@3.0.0: {}
|
||||||
|
|
||||||
is-even@1.0.0:
|
is-even@1.0.0:
|
||||||
@@ -20066,6 +20093,10 @@ snapshots:
|
|||||||
|
|
||||||
is-windows@1.0.2: {}
|
is-windows@1.0.2: {}
|
||||||
|
|
||||||
|
is-wsl@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
is-docker: 2.2.1
|
||||||
|
|
||||||
is-wsl@3.1.1:
|
is-wsl@3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-inside-container: 1.0.0
|
is-inside-container: 1.0.0
|
||||||
@@ -20284,6 +20315,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
jest-html-reporters@3.1.7:
|
||||||
|
dependencies:
|
||||||
|
fs-extra: 10.1.0
|
||||||
|
open: 8.4.2
|
||||||
|
|
||||||
jest-leak-detector@29.7.0:
|
jest-leak-detector@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
jest-get-type: 29.6.3
|
jest-get-type: 29.6.3
|
||||||
@@ -21454,6 +21490,12 @@ snapshots:
|
|||||||
powershell-utils: 0.1.0
|
powershell-utils: 0.1.0
|
||||||
wsl-utils: 0.3.1
|
wsl-utils: 0.3.1
|
||||||
|
|
||||||
|
open@8.4.2:
|
||||||
|
dependencies:
|
||||||
|
define-lazy-prop: 2.0.0
|
||||||
|
is-docker: 2.2.1
|
||||||
|
is-wsl: 2.2.0
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
Reference in New Issue
Block a user