mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
10
.gitignore
vendored
10
.gitignore
vendored
@@ -37,3 +37,13 @@ e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
e2e/freight/.e2e-ports.json
|
||||
|
||||
# local run scripts (contain personal DB credentials — never commit)
|
||||
run-passenger-local.sh
|
||||
run-passenger-web.sh
|
||||
|
||||
# generated test output
|
||||
e2e-ui-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
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",
|
||||
"test": "jest",
|
||||
"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",
|
||||
"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",
|
||||
@@ -78,6 +83,7 @@
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-html-reporters": "^3.1.7",
|
||||
"prisma": "^6.19.3",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
|
||||
@@ -865,6 +865,9 @@ export class BookingsService {
|
||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||
let resolvedTotalMinor: number;
|
||||
let displayTotalMinor: number;
|
||||
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
|
||||
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
|
||||
let usedClientSubtotal = false;
|
||||
|
||||
if (allFaresProvided && !dto.packageId) {
|
||||
// Server has every passenger's berth fare — sum is the authoritative display total.
|
||||
@@ -872,6 +875,7 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
|
||||
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
|
||||
}
|
||||
@@ -893,13 +897,35 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
usedClientSubtotal = true;
|
||||
} else {
|
||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
||||
: resolvedTotalMinor;
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// H-13 fix: the portal sums UNDISCOUNTED per-passenger fares into the total it sends, silently
|
||||
// dropping the promo the fare engine recognized (the discount lives only in the fare-breakdown).
|
||||
// When the total came from that client subtotal, apply the authoritative promo discount so the
|
||||
// customer is charged the discounted price. No-op when no promo applies (discountMinor === 0).
|
||||
// The fallback branch above already books fareCalculation.totalMinor (discount included), so it is
|
||||
// excluded via usedClientSubtotal to avoid double-subtracting.
|
||||
if (usedClientSubtotal && fareCalculation.discountMinor > 0) {
|
||||
resolvedTotalMinor = Math.max(0, resolvedTotalMinor - fareCalculation.discountMinor);
|
||||
const discountDisplayMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(fareCalculation.discountMinor, Currency.ETB, displayCurrency)
|
||||
: fareCalculation.discountMinor;
|
||||
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
|
||||
}
|
||||
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} displayTotalMinor=${displayTotalMinor} displayCurrency=${displayCurrency} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} discountMinor=${fareCalculation.discountMinor} fareEngine=${fareCalculation.totalMinor})`);
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative fare. resolvedTotalMinor
|
||||
// is the ETB charge basis; fareCalculation.totalMinor is the authoritative ETB fare (already net
|
||||
// of promo/loyalty/free-child). A client that forges seatFareMinor / reviewedTotalMinor below it
|
||||
// is rejected. Floor (not equality) so legitimate berth surcharges — which only raise the total —
|
||||
// still pass; the tolerance absorbs FX-conversion rounding.
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, fareCalculation.totalMinor, 'createOneWayBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -911,7 +937,9 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: displayCurrency,
|
||||
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
|
||||
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
|
||||
currency: Currency.ETB,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1030,6 +1058,9 @@ export class BookingsService {
|
||||
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
}
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
const taxesMinor = 0;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||
@@ -1095,6 +1126,9 @@ export class BookingsService {
|
||||
: dto.reviewedTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createRoundTripBooking');
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -1105,7 +1139,7 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1298,7 +1332,7 @@ export class BookingsService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -1508,7 +1542,7 @@ export class BookingsService {
|
||||
destinationStationId: dto.leg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
// Outbound transit leg-2
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
@@ -1677,6 +1711,21 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C-1 protection: reject a booking whose ETB charge basis is below the server-recomputed
|
||||
* authoritative fare. A floor (not equality) so legitimate berth surcharges — which only raise
|
||||
* the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged seatFareMinor /
|
||||
* reviewedTotalMinor that lowers the charge (e.g. to 1 or 0) is refused with a 400 and nothing is
|
||||
* persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
private async calculateFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
@@ -42,6 +42,8 @@ function calculateAge(dateOfBirth: Date): number {
|
||||
|
||||
@Injectable()
|
||||
export class GuestBookingService {
|
||||
private readonly logger = new Logger(GuestBookingService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
@@ -52,6 +54,21 @@ export class GuestBookingService {
|
||||
private eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* C-1 protection (guest path): reject a booking whose ETB charge basis is below the
|
||||
* server-recomputed authoritative fare. A floor (not equality) so legitimate berth surcharges —
|
||||
* which only raise the total — still pass; a 1% tolerance absorbs FX-conversion rounding. A forged
|
||||
* seatFareMinor / reviewedTotalMinor that lowers the charge (e.g. to 0) is refused with a 400 and
|
||||
* nothing is persisted.
|
||||
*/
|
||||
private assertTotalNotUnderAuthoritative(resolvedTotalMinor: number, authoritativeMinor: number, context: string): void {
|
||||
const tolerance = Math.max(1, Math.round(authoritativeMinor * 0.01));
|
||||
if (resolvedTotalMinor < authoritativeMinor - tolerance) {
|
||||
this.logger.warn(`${context}: rejecting booking — resolvedTotalMinor=${resolvedTotalMinor} below authoritative fare=${authoritativeMinor}`);
|
||||
throw new BadRequestException('Booking total does not match the authoritative fare');
|
||||
}
|
||||
}
|
||||
|
||||
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
|
||||
// The portal calls /passengers/save-details before booking but doesn't re-send contact
|
||||
@@ -250,6 +267,9 @@ export class GuestBookingService {
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative ETB fare (net of promo).
|
||||
this.assertTotalNotUnderAuthoritative(resolvedTotalMinor, Math.max(0, totalBaseFareMinor - discountMinor), 'createGuestBooking');
|
||||
|
||||
// Resolve or create the guest Passenger record
|
||||
const firstPassenger = passengersData[0];
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, firstPassenger, req);
|
||||
@@ -284,7 +304,9 @@ export class GuestBookingService {
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: displayCurrency,
|
||||
// Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in
|
||||
// displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units.
|
||||
currency: Currency.ETB,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -485,6 +507,9 @@ export class GuestBookingService {
|
||||
|
||||
const taxesMinor = 0;
|
||||
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
// C-1 guard: authoritative ETB fare for both legs, captured before the client-driven branches
|
||||
// below may overwrite totalMinor with a per-seat sum or reviewedTotalMinor.
|
||||
const authoritativeTotalMinor = totalMinor;
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
@@ -540,6 +565,9 @@ export class GuestBookingService {
|
||||
: displayTotalMinor;
|
||||
}
|
||||
|
||||
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');
|
||||
|
||||
// Create or resolve guest passenger (same as one-way)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
|
||||
@@ -557,7 +585,7 @@ export class GuestBookingService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -761,7 +789,7 @@ export class GuestBookingService {
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'TRANSIT',
|
||||
totalMinor,
|
||||
currency: displayCurrency,
|
||||
currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -977,7 +1005,7 @@ export class GuestBookingService {
|
||||
destinationStationId: dto.returnLeg2DestinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||
totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||
totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor
|
||||
leg2ScheduleId: dto.leg2ScheduleId,
|
||||
leg2OriginStationId: dto.transitStationId,
|
||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||
|
||||
@@ -140,10 +140,14 @@ export class CurrencyService {
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
this.logger.warn(
|
||||
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
|
||||
// H-2: fail closed. Never price at parity (1.0) when a required rate is absent — a silent 1.0
|
||||
// substitution underprices international fares ~100×. Reject the quote/booking instead.
|
||||
this.logger.error(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}; refusing to price at parity`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||
);
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
|
||||
|
||||
@@ -23,6 +23,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Put()
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Upsert an exchange rate for today' })
|
||||
@ApiResponse({ status: 200, description: 'Rate created or updated for today\'s effective date' })
|
||||
upsert(@Body() dto: UpsertExchangeRateDto) {
|
||||
@@ -30,6 +32,8 @@ export class CurrencyController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Update an exchange rate by ID' })
|
||||
@ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Rate updated' })
|
||||
|
||||
@@ -23,8 +23,11 @@ export class FareEngineService {
|
||||
|
||||
if (!originStop) throw new BadRequestException('Origin station not found on this route');
|
||||
if (!destStop) throw new BadRequestException('Destination station not found on this route');
|
||||
if (originStop.sequence >= destStop.sequence)
|
||||
throw new BadRequestException('Origin must come before destination in the route sequence');
|
||||
// Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip
|
||||
// return leg traverses the same route high→low (e.g. C→A), so we price the segment by its
|
||||
// absolute distance rather than rejecting the reverse order.
|
||||
if (originStop.sequence === destStop.sequence)
|
||||
throw new BadRequestException('Origin and destination must be different stops on this route');
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
@@ -43,8 +46,8 @@ export class FareEngineService {
|
||||
},
|
||||
}) ?? seatClass;
|
||||
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
|
||||
const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!);
|
||||
if (totalDistanceKm <= 0 || isNaN(totalDistanceKm))
|
||||
throw new BadRequestException('Invalid distance calculation - check route stop distances');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -1017,6 +1017,19 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
// C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
|
||||
// amount against the booking's display-currency total (the amount the customer agreed to pay);
|
||||
// a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
|
||||
// divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
|
||||
const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
|
||||
const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
|
||||
if (event.amountMinor < expectedMinor - shortPayTolerance) {
|
||||
this.logger.error(
|
||||
`mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
|
||||
);
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsInt, IsBoolean } from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, Min, Max } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePromotionDto {
|
||||
@@ -15,14 +15,17 @@ export class CreatePromotionDto {
|
||||
@IsString()
|
||||
subtitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@ApiPropertyOptional({ example: 15, description: 'Percentage discount, bounded 0..100' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
percentOff?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5000 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
amountOffMinor?: number;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31T23:59:59Z' })
|
||||
|
||||
@@ -103,6 +103,8 @@ export class SchedulesService {
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
// M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this.
|
||||
if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future');
|
||||
|
||||
const [train, route] = await Promise.all([
|
||||
this.prisma.train.findUnique({ where: { id: dto.trainId } }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, IsIn, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateSeatClassDto {
|
||||
@@ -27,11 +27,13 @@ export class CreateSeatClassDto {
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
basePrice: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
insuranceFeeMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { UpdateSystemConfigDto } from './system-config.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@@ -31,7 +32,12 @@ export class SystemConfigController {
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiOperation({ summary: 'Update system config (admin)' })
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
update(@Body() dto: UpdateSystemConfigDto) {
|
||||
// The DTO validates/coerces each known key to a positive integer; persist back as strings.
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(dto)) {
|
||||
if (value !== undefined) entries[key] = String(value);
|
||||
}
|
||||
return this.service.updateMany(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every
|
||||
* known key is a positive integer (durations, hour windows, throttle limits/TTLs). Values arrive as
|
||||
* strings from the backoffice form; `@Type(() => Number)` coerces them so the numeric/range checks
|
||||
* apply (M-3 — the endpoint previously stored any raw string, e.g. `seat_hold_duration_minutes: -1`).
|
||||
* Unknown keys are stripped by the global whitelisting ValidationPipe.
|
||||
*/
|
||||
export class UpdateSystemConfigDto {
|
||||
@ApiPropertyOptional({ example: 5, description: 'Seat-hold duration in minutes (1..60)' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(60)
|
||||
seat_hold_duration_minutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
hold_cutoff_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
boarding_window_hours_before_departure?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 5 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_auth_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_strict_ttl_ms?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 100 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60000 })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
throttle_default_ttl_ms?: number;
|
||||
}
|
||||
34
apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts
Normal file
34
apps/edr-passenger-api/test/auth-gaps.e2e-spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Auth/authorization gaps (matrix Suite J), via route guard metadata — no boot needed.
|
||||
*
|
||||
* C-8 🔴 The exchange-rate write routes (PUT upsert, PATCH update) carry no METHOD-LEVEL guard, so
|
||||
* they get only the global JwtGuard (authentication) and NOT @PassengerAdmin (authorization)
|
||||
* — unlike DELETE, which is admin-gated. Net effect (verified live in
|
||||
* e2e-ui .../pb-config-propagation.spec.ts BC-11): anonymous → 401, but ANY authenticated
|
||||
* user incl. a passenger → 200 rewrites live FX. fare-engine/currency.controller.ts:25,32,42
|
||||
*
|
||||
* NOTE: this metadata check proves the missing ADMIN guard, NOT "unauthenticated" — a global
|
||||
* APP_GUARD=JwtGuard (SharedAuthModule) still requires a valid token. The earlier "unauthenticated
|
||||
* FX write" reading was a false positive corrected by the live BC-11 test.
|
||||
*/
|
||||
import "reflect-metadata";
|
||||
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
|
||||
|
||||
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("C-8 🔴 PUT upsert exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.upsert)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("C-8 🔴 PATCH update exchange-rate has NO admin guard (only the global JwtGuard applies)", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.update)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => {
|
||||
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* C-9-UI 🔴 Authenticated POST /bookings is broken: the controller overrides passengerId with the
|
||||
* JWT user id (`bookings.controller.ts:528-532`, "never trust the request body"), but the service
|
||||
* only resolves an iamUserId → Passenger when it is NON-UUID (`bookings.service.ts:773`). Real IAM
|
||||
* ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`),
|
||||
* so `booking.create` uses the iamUserId directly as passengerId → foreign-key violation.
|
||||
*
|
||||
* This reproduces the controller's behavior by calling BookingsService.create with passengerId set to
|
||||
* a UUID iamUserId (not the Passenger.id), exactly as the authed controller does. It also shows the
|
||||
* CONTROL: passing the real Passenger.id succeeds — proving the resolution gap, not a fixture problem.
|
||||
*/
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
function asyncStub(): any {
|
||||
return new Proxy({}, { get: () => async () => undefined });
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function buildBookableGraph(prisma: any, passengerId: string, iamUserId: string) {
|
||||
const passenger = await prisma.passenger.create({ data: { id: passengerId, iamUserId } });
|
||||
const train = await prisma.train.create({ data: { number: `AB-${++seq}`, name: "T" } });
|
||||
const schedule = await 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,
|
||||
},
|
||||
});
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 1 },
|
||||
{ scheduleId: schedule.id, stationId: IDS.stationB, sequence: 2 },
|
||||
],
|
||||
});
|
||||
const coach = await prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `AB-${seq}` } });
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "1" } });
|
||||
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, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
return { schedule, seat, hold };
|
||||
}
|
||||
|
||||
function dtoFor(passengerId: string, schedule: any, hold: any, seat: any) {
|
||||
return {
|
||||
passengerId, // the controller passes req.user.id here (the iamUserId)
|
||||
scheduleId: schedule.id,
|
||||
holdId: hold.id,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
bookingType: "ONE_WAY",
|
||||
passengers: [
|
||||
{
|
||||
seatId: seat.id,
|
||||
passengerName: "Auth User",
|
||||
dateOfBirth: new Date("1990-01-01"),
|
||||
idDocumentType: "PASSPORT",
|
||||
passportNumber: "P1",
|
||||
passportCountry: "ET",
|
||||
nationality: "Ethiopian",
|
||||
seatFareMinor: 30_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Authenticated booking passengerId resolution (regression)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
let bookings: BookingsService;
|
||||
|
||||
beforeAll(() => {
|
||||
bookings = new BookingsService(
|
||||
prisma as any,
|
||||
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
|
||||
asyncStub(), // seatsService
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // verifaydaService (PASSPORT skips)
|
||||
asyncStub(), // currencyService (ETB skips)
|
||||
asyncStub(), // fareEngine (FareRule short-circuits)
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
it("🔴 create() with a UUID iamUserId (as the authed controller passes) FAILS the passenger FK", async () => {
|
||||
const passengerId = "aaaaaaaa-0000-4000-8000-000000000001"; // real Passenger.id
|
||||
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000002"; // UUID iamUserId ≠ Passenger.id
|
||||
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
|
||||
|
||||
// The controller calls service.create({ ...dto, passengerId: req.user.id }) — i.e. the iamUserId.
|
||||
await expect(
|
||||
bookings.create(dtoFor(iamUserId, schedule, hold, seat) as any),
|
||||
).rejects.toThrow(); // Prisma P2003 on Booking_passengerId_fkey
|
||||
|
||||
expect(await prisma.booking.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("control: create() with the real Passenger.id succeeds — proving the gap is the id, not the fixture", async () => {
|
||||
const passengerId = "aaaaaaaa-0000-4000-8000-000000000003";
|
||||
const iamUserId = "bbbbbbbb-0000-4000-8000-000000000004";
|
||||
const { schedule, hold, seat } = await buildBookableGraph(prisma, passengerId, iamUserId);
|
||||
|
||||
const booking: any = await bookings.create(dtoFor(passengerId, schedule, hold, seat) as any);
|
||||
expect(booking.id).toBeTruthy();
|
||||
expect(booking.passengerId).toBe(passengerId);
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
89
apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts
vendored
Normal file
89
apps/edr-passenger-api/test/fixtures/seed-passenger-session.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Seeds a passenger IAM user + session directly (no OTP flow) and mints an access token the portal
|
||||
* accepts. The API JwtGuard verifies the JWT signature (JWT_ACCESS_TOKEN_SECRET) and looks up the
|
||||
* session by its `id` claim; the portal then calls /auth/profile which needs a Passenger row linked
|
||||
* by iamUserId. Returns { token, profile } for the Playwright passenger storageState.
|
||||
*
|
||||
* Run standalone to validate: `npx ts-node test/fixtures/seed-passenger-session.ts` (prints the
|
||||
* token and the /auth/profile status via the running API on :4000).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { SignJWT } from "jose";
|
||||
import { UI_IDS } from "./seed-ui";
|
||||
|
||||
export const PASSENGER_USER_ID = "11111111-0000-4000-8000-000000000001";
|
||||
export const PASSENGER_SESSION_ID = "11111111-0000-4000-8000-000000000002";
|
||||
const EMAIL = "test.passenger@edr.local";
|
||||
const USERNAME = "test_passenger";
|
||||
|
||||
export async function seedPassengerSession(prisma: PrismaClient): Promise<{ token: string }> {
|
||||
const secret = process.env.JWT_ACCESS_TOKEN_SECRET;
|
||||
if (!secret) throw new Error("JWT_ACCESS_TOKEN_SECRET is required to mint the passenger token");
|
||||
|
||||
const userInfo = {
|
||||
id: PASSENGER_USER_ID,
|
||||
name: { en: "Test Passenger" },
|
||||
email: EMAIL,
|
||||
roles: [] as unknown[],
|
||||
status: "accepted",
|
||||
employee: [] as unknown[],
|
||||
userType: "individual",
|
||||
username: USERNAME,
|
||||
permissions: [] as unknown[],
|
||||
};
|
||||
const expiry = new Date(Date.now() + 7 * 86400_000);
|
||||
|
||||
// iam.users (delete-then-insert so re-seeding is idempotent).
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM iam.sessions WHERE id = $1::uuid`, PASSENGER_SESSION_ID);
|
||||
await prisma.$executeRawUnsafe(`DELETE FROM iam.users WHERE id = $1::uuid`, PASSENGER_USER_ID);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO iam.users (created_at, id, name, username, email, user_type, status, is_active, has_set_password, is_phone_number_verified, verified_by)
|
||||
VALUES (now(), $1::uuid, $2::jsonb, $3, $4, 'individual', 'accepted', true, true, true, 'SYSTEM')`,
|
||||
PASSENGER_USER_ID,
|
||||
JSON.stringify(userInfo.name),
|
||||
USERNAME,
|
||||
EMAIL,
|
||||
);
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO iam.sessions (created_at, id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (now(), $1::uuid, $2, 'e2e', $3::jsonb, $4, 0, 'ACTIVE', $5::uuid)`,
|
||||
PASSENGER_SESSION_ID,
|
||||
EMAIL,
|
||||
JSON.stringify(userInfo),
|
||||
expiry,
|
||||
PASSENGER_USER_ID,
|
||||
);
|
||||
|
||||
// Link the seeded Passenger row to this IAM user so /auth/profile resolves.
|
||||
await prisma.passenger.update({
|
||||
where: { id: UI_IDS.passenger },
|
||||
data: { iamUserId: PASSENGER_USER_ID },
|
||||
});
|
||||
|
||||
const token = await new SignJWT({ id: PASSENGER_SESSION_ID })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(new TextEncoder().encode(secret));
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const { token } = await seedPassengerSession(prisma);
|
||||
const api = process.env.API_URL ?? "http://localhost:4000";
|
||||
const res = await fetch(`${api}/auth/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[passenger-session] /auth/profile -> HTTP ${res.status}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log((await res.text()).slice(0, 400));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
}
|
||||
197
apps/edr-passenger-api/test/fixtures/seed-ui.ts
vendored
Normal file
197
apps/edr-passenger-api/test/fixtures/seed-ui.ts
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* UI E2E seed — extends seed-core with a BOOKABLE trip + payment methods + promos so the portal
|
||||
* search/booking flow and the backoffice config flow have real data to drive. Runnable standalone
|
||||
* (`ts-node test/fixtures/seed-ui.ts`) or importable (`seedUi(prisma)`) from the Playwright
|
||||
* global-setup. Reads DATABASE_URL from the environment (the UI stack points at the 5544 test DB).
|
||||
*
|
||||
* Searchability: a schedule shows in POST /search when it is SCHEDULED, not package-only, has a
|
||||
* future departure on the searched date, operational coaches with AVAILABLE seats, and a resolvable
|
||||
* fare (seat-class distance formula using the seeded route-stop distances + USD→ETB rate).
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { IDS, resetAndSeedCore } from "./seed-core";
|
||||
|
||||
export const UI_IDS = {
|
||||
train: "00000000-0000-4000-8000-000000000100",
|
||||
schedule: "00000000-0000-4000-8000-000000000101",
|
||||
coach: "00000000-0000-4000-8000-000000000102",
|
||||
// Passenger.id is set EQUAL to the IAM user id. The bookings controller overrides passengerId
|
||||
// with the JWT user id (req.user.id), and the service only resolves iamUserId→passenger when it
|
||||
// is NON-UUID — since IAM ids are UUIDs, it uses the id directly, so Passenger.id must equal it.
|
||||
passenger: "11111111-0000-4000-8000-000000000001",
|
||||
promoValid: "PROMO10",
|
||||
promoExpired: "EXPIRED50",
|
||||
// Return leg C→A on the same calendar date, for ROUND_TRIP scenarios (UA-6). The route stops are
|
||||
// symmetric in distance (A=0, C=250) so the reverse leg prices identically to the outbound.
|
||||
returnSchedule: "00000000-0000-4000-8000-000000000201",
|
||||
returnCoach: "00000000-0000-4000-8000-000000000202",
|
||||
} as const;
|
||||
|
||||
/** Days-from-now the sample trip departs (tests search on this calendar date, Addis TZ). */
|
||||
export const DEPART_IN_DAYS = 2;
|
||||
|
||||
export function sampleDepartAt(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + DEPART_IN_DAYS);
|
||||
d.setUTCHours(6, 0, 0, 0); // 06:00Z ~ 09:00 Addis — safely same calendar day either TZ
|
||||
return d;
|
||||
}
|
||||
|
||||
/** The date string a test passes to POST /search for the sample trip (YYYY-MM-DD). */
|
||||
export function sampleDepartDate(): string {
|
||||
return sampleDepartAt().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function seedUi(prisma: PrismaClient): Promise<void> {
|
||||
await resetAndSeedCore(prisma);
|
||||
|
||||
// Give the two seed-core seat classes the exact names the portal review flow maps by.
|
||||
await prisma.seatClass.update({
|
||||
where: { id: IDS.seatClassLocal },
|
||||
data: { name: "Economy Regular" },
|
||||
});
|
||||
await prisma.seatClass.update({
|
||||
where: { id: IDS.seatClassIntl },
|
||||
data: { name: "Economy Regular Intl" },
|
||||
});
|
||||
|
||||
// Enabled payment methods — the portal pay page renders ONLY enabled PaymentMethod rows.
|
||||
await prisma.paymentMethod.createMany({
|
||||
data: [
|
||||
{ type: "WALLET", displayName: "Wallet", currency: "ETB", enabled: true, isDefault: true, sortOrder: 0 },
|
||||
{ type: "TELEBIRR", displayName: "telebirr", currency: "ETB", enabled: true, sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const departAt = sampleDepartAt();
|
||||
const arriveAt = new Date(departAt.getTime() + 4 * 3600_000);
|
||||
|
||||
await prisma.train.create({
|
||||
data: { id: UI_IDS.train, number: "UI-100", name: "UI Test Express" },
|
||||
});
|
||||
|
||||
await prisma.trainSchedule.create({
|
||||
data: {
|
||||
id: UI_IDS.schedule,
|
||||
trainId: UI_IDS.train,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationC,
|
||||
departureAt: departAt,
|
||||
arrivalAt: arriveAt,
|
||||
durationMinutes: 240,
|
||||
status: "SCHEDULED",
|
||||
stopsCount: 3,
|
||||
isPackageOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationA, sequence: 1, plannedDepartureAt: departAt, status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(departAt.getTime() + 2 * 3600_000), status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.schedule, stationId: IDS.stationC, sequence: 3, plannedArrivalAt: arriveAt, status: "OPEN" },
|
||||
],
|
||||
});
|
||||
|
||||
// Enough seats that a full suite run (many bookings share one seeded DB, seats are not released
|
||||
// between specs) never exhausts availability: 12 rows × 4 cols = 48 seats.
|
||||
const SEAT_ROWS = 12;
|
||||
await prisma.coach.create({
|
||||
data: { id: UI_IDS.coach, coachTypeId: IDS.coachType, number: "UI-C1", capacity: SEAT_ROWS * 4, sequence: 1, status: "ACTIVE" },
|
||||
});
|
||||
await prisma.coachAssignment.create({
|
||||
data: { scheduleId: UI_IDS.schedule, coachId: UI_IDS.coach, positionNumber: 1, isOperational: true },
|
||||
});
|
||||
await prisma.seat.createMany({ data: buildSeats(UI_IDS.coach, SEAT_ROWS) });
|
||||
|
||||
// Logged-in passenger with a funded wallet + loyalty (used by the portal storageState + WALLET pay).
|
||||
await prisma.passenger.create({ data: { id: UI_IDS.passenger } });
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: UI_IDS.passenger, balanceMinor: 100_000_000 },
|
||||
});
|
||||
await prisma.loyaltyAccount.create({
|
||||
data: { passengerId: UI_IDS.passenger, pointsBalance: 500 },
|
||||
});
|
||||
|
||||
// Promotions (schema field names, NOT the backoffice UI names). Unique exact codes.
|
||||
await prisma.promotion.createMany({
|
||||
data: [
|
||||
{ title: "10% off", code: UI_IDS.promoValid, percentOff: 10, validUntil: new Date(Date.now() + 30 * 86400_000), active: true },
|
||||
{ title: "Expired", code: UI_IDS.promoExpired, percentOff: 50, validUntil: new Date(Date.now() - 86400_000), active: true },
|
||||
],
|
||||
});
|
||||
|
||||
// One baggage allowance (for excess-baggage flows later).
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
|
||||
});
|
||||
|
||||
// ── Return leg (C→A) for ROUND_TRIP (UA-6) ──────────────────────────────────
|
||||
// Same train, same day, departs after the outbound arrives. Distances are symmetric
|
||||
// (A=0km … C=250km) so the reverse leg prices the same as the outbound.
|
||||
const returnDepart = new Date(departAt.getTime() + 8 * 3600_000); // 8h after outbound departs
|
||||
const returnArrive = new Date(returnDepart.getTime() + 4 * 3600_000);
|
||||
await prisma.trainSchedule.create({
|
||||
data: {
|
||||
id: UI_IDS.returnSchedule,
|
||||
trainId: UI_IDS.train,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationC,
|
||||
destinationStationId: IDS.stationA,
|
||||
departureAt: returnDepart,
|
||||
arrivalAt: returnArrive,
|
||||
durationMinutes: 240,
|
||||
status: "SCHEDULED",
|
||||
stopsCount: 3,
|
||||
isPackageOnly: false,
|
||||
},
|
||||
});
|
||||
await prisma.tripStopTime.createMany({
|
||||
data: [
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: returnDepart, status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationB, sequence: 2, plannedDepartureAt: new Date(returnDepart.getTime() + 2 * 3600_000), status: "OPEN" },
|
||||
{ scheduleId: UI_IDS.returnSchedule, stationId: IDS.stationA, sequence: 3, plannedArrivalAt: returnArrive, status: "OPEN" },
|
||||
],
|
||||
});
|
||||
await prisma.coach.create({
|
||||
data: { id: UI_IDS.returnCoach, coachTypeId: IDS.coachType, number: "UI-C2", capacity: 48, sequence: 1, status: "ACTIVE" },
|
||||
});
|
||||
await prisma.coachAssignment.create({
|
||||
data: { scheduleId: UI_IDS.returnSchedule, coachId: UI_IDS.returnCoach, positionNumber: 1, isOperational: true },
|
||||
});
|
||||
await prisma.seat.createMany({ data: buildSeats(UI_IDS.returnCoach, 12) });
|
||||
}
|
||||
|
||||
/** Build `rows × 4` seats (cols A–D) for a coach. */
|
||||
function buildSeats(coachId: string, rows: number) {
|
||||
const cols = ["A", "B", "C", "D"];
|
||||
const seats: Array<{ coachId: string; seatNumber: string; row: number; col: string; isWindow: boolean; isAisle: boolean }> = [];
|
||||
for (let row = 1; row <= rows; row++) {
|
||||
for (const col of cols) {
|
||||
seats.push({
|
||||
coachId,
|
||||
seatNumber: `${row}${col}`,
|
||||
row,
|
||||
col,
|
||||
isWindow: col === "A" || col === "D",
|
||||
isAisle: col === "B" || col === "C",
|
||||
});
|
||||
}
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
// Standalone runner
|
||||
if (require.main === module) {
|
||||
(async () => {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await seedUi(prisma);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[seed-ui] done. Sample trip ${IDS.stationA}→${IDS.stationC} on ${sampleDepartDate()} (schedule ${UI_IDS.schedule}).`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -2,6 +2,34 @@
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"testEnvironment": "node"
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"test/app.e2e-spec.ts"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": ["ts-jest", { "isolatedModules": true }]
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": ["<rootDir>/setup/load-env.ts"],
|
||||
"moduleNameMapper": {
|
||||
"^file-type$": "<rootDir>/setup/stubs/file-type.ts",
|
||||
"^@edr/types$": "<rootDir>/../../../packages/types/src/index.ts",
|
||||
"^@edr/types/(.*)$": "<rootDir>/../../../packages/types/src/$1",
|
||||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||
},
|
||||
"testTimeout": 60000,
|
||||
"maxWorkers": 1,
|
||||
"reporters": [
|
||||
"default",
|
||||
[
|
||||
"jest-html-reporters",
|
||||
{
|
||||
"publicPath": "<rootDir>/../e2e-report",
|
||||
"filename": "index.html",
|
||||
"pageTitle": "EDR Passenger — Pricing/Config E2E Results",
|
||||
"expand": true,
|
||||
"hideIcon": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
175
apps/edr-passenger-api/test/money-integrity.e2e-spec.ts
Normal file
175
apps/edr-passenger-api/test/money-integrity.e2e-spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tier-2 money-integrity suite — services behind the IAM/RabbitMQ wall, instantiated directly with
|
||||
* a real Prisma (test DB) + stubbed collaborators. Confirms critical findings:
|
||||
* F1/F2 🔴 WalletService.topUp credits any passenger's wallet with no ownership check and no
|
||||
* payment backing (free money).
|
||||
* G4/G5 🔴 BookingsService.cancel computes an 80% refund but NEVER disburses it — no PaymentRefund,
|
||||
* no wallet credit; the cancellation sits at refundStatus PENDING forever.
|
||||
* E1/E2 🔴 ExcessBaggageService.logCharge picks the OLDEST BaggageAllowance globally, ignoring the
|
||||
* booking's seat class, and computes fee = feePerKgMinor × excessWeightKg.
|
||||
*/
|
||||
import { WalletService } from "../src/modules/wallet/wallet.service";
|
||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||
import { ExcessBaggageService } from "../src/modules/excess-baggage/excess-baggage.service";
|
||||
import { getTestPrisma, disconnectTestPrisma } from "./setup/prisma";
|
||||
import { truncateAllPassenger, seedCore, IDS } from "./fixtures/seed-core";
|
||||
|
||||
/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
|
||||
function asyncStub(): any {
|
||||
return new Proxy(
|
||||
{},
|
||||
{ get: () => async () => undefined },
|
||||
);
|
||||
}
|
||||
|
||||
describe("Money integrity (Tier-2 direct instantiation)", () => {
|
||||
const prisma = getTestPrisma();
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllPassenger(prisma);
|
||||
await seedCore(prisma);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await disconnectTestPrisma();
|
||||
});
|
||||
|
||||
// ── F1 / F2 ────────────────────────────────────────────────────────────────
|
||||
it("F1/F2 🔴 topUp credits another passenger's wallet — no ownership check, no payment backing", async () => {
|
||||
const victim = await prisma.passenger.create({ data: {} });
|
||||
await prisma.walletAccount.create({
|
||||
data: { passengerId: victim.id, balanceMinor: 0 },
|
||||
});
|
||||
|
||||
const wallet = new WalletService(prisma as any);
|
||||
|
||||
// An attacker-controlled call: just pass the victim's id. Nothing checks caller identity,
|
||||
// and no PaymentIntent/settlement backs the credit.
|
||||
await wallet.topUp(victim.id, 1_000_000, "free money");
|
||||
|
||||
const after = await prisma.walletAccount.findUnique({
|
||||
where: { passengerId: victim.id },
|
||||
});
|
||||
expect(after?.balanceMinor).toBe(1_000_000);
|
||||
|
||||
// The only ledger entry is a bare CREDIT — no linked payment.
|
||||
const ledger = await prisma.walletLedgerEntry.findMany({
|
||||
where: { walletId: after!.id },
|
||||
});
|
||||
expect(ledger).toHaveLength(1);
|
||||
expect(ledger[0].type).toBe("CREDIT");
|
||||
expect(ledger[0].relatedBookingId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
// ── G4 / G5 ────────────────────────────────────────────────────────────────
|
||||
it("G4/G5 🔴 cancel() computes floor(total*0.8) refund but never disburses it (stuck PENDING)", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
// Give the passenger a wallet so we can prove NO refund lands in it.
|
||||
const w = await prisma.walletAccount.create({
|
||||
data: { passengerId: passenger.id, balanceMinor: 0 },
|
||||
});
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "CXL-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
displayCurrency: "ETB",
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
const bookings = new BookingsService(
|
||||
prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
asyncStub(), // seatsService
|
||||
{ emit: () => true } as any, // eventEmitter
|
||||
asyncStub(), // verifaydaService
|
||||
asyncStub(), // currencyService
|
||||
asyncStub(), // fareEngine
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
|
||||
const result: any = await bookings.cancel(booking.bookingRef, "test");
|
||||
|
||||
// Refund is COMPUTED as 80%:
|
||||
expect(result.refundAmount).toBe(Math.floor(30_000 * 0.8) / 100); // 240.00
|
||||
|
||||
// …but recorded only as PENDING, and never actually paid out:
|
||||
const cancellation = await prisma.bookingCancellation.findFirst({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
expect(cancellation?.refundStatus).toBe("PENDING");
|
||||
|
||||
// No PaymentRefund row was created anywhere (isolated DB) and the wallet was NOT credited.
|
||||
const refundCount = await prisma.paymentRefund.count();
|
||||
expect(refundCount).toBe(0);
|
||||
const walletAfter = await prisma.walletAccount.findUnique({ where: { id: w.id } });
|
||||
expect(walletAfter?.balanceMinor).toBe(0);
|
||||
});
|
||||
|
||||
// ── E1 / E2 ────────────────────────────────────────────────────────────────
|
||||
it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "BAG-0001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
// Oldest allowance is for the LOCAL class (rate 50). A later one for INTL (rate 200) should win
|
||||
// for an intl booking — but logCharge ignores seat class and takes the oldest row.
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 50 },
|
||||
});
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassIntl, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 200 },
|
||||
});
|
||||
|
||||
const service = new ExcessBaggageService(
|
||||
prisma as any,
|
||||
asyncStub(), // auditService
|
||||
asyncStub(), // paymentClient
|
||||
asyncStub(), // notifications
|
||||
asyncStub(), // smsClient
|
||||
asyncStub(), // emailClient
|
||||
);
|
||||
|
||||
const charge: any = await service.logCharge({
|
||||
bookingId: booking.id,
|
||||
excessWeightKg: 10,
|
||||
collectCash: true,
|
||||
} as any);
|
||||
|
||||
// Used the oldest (LOCAL, 50) not any seat-class-matched rate; fee = 50 × 10.
|
||||
expect(charge.feePerKgMinor).toBe(50);
|
||||
expect(charge.totalMinor).toBe(50 * 10);
|
||||
});
|
||||
});
|
||||
|
||||
let trainSeq = 0;
|
||||
|
||||
/** Minimal TrainSchedule (+train) so booking/cancel fixtures satisfy FKs. */
|
||||
async function makeSchedule(prisma: any, _passengerId: string) {
|
||||
const train = await prisma.train.create({
|
||||
data: { number: `T-${++trainSeq}`, name: "Test Train" },
|
||||
});
|
||||
return prisma.trainSchedule.create({
|
||||
data: {
|
||||
trainId: train.id,
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
departureAt: new Date(Date.now() + 86_400_000),
|
||||
arrivalAt: new Date(Date.now() + 90_000_000),
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
}
|
||||
81
apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts
Normal file
81
apps/edr-passenger-api/test/pricing-currency.e2e-spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Currency / FX suite (matrix Suite C). Exercises CurrencyService directly.
|
||||
* C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws —
|
||||
* the display path degrades but the charge path errors on the SAME condition (divergence).
|
||||
* C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter).
|
||||
* C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor
|
||||
* return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both
|
||||
* written into fields named `amountMinor` at their call sites.
|
||||
*/
|
||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||
import { resetAndSeedCore, USD_TO_ETB } from "./fixtures/seed-core";
|
||||
|
||||
describe("Pricing — CurrencyService (Suite C)", () => {
|
||||
let harness: ServiceHarness;
|
||||
let currency: CurrencyService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
currency = harness.moduleRef.get(CurrencyService);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
});
|
||||
|
||||
it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => {
|
||||
// Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays.
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||
});
|
||||
|
||||
// Display/fare path (getExchangeRate) has NO inverse fallback → silently returns 1.0 (wrong).
|
||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
||||
|
||||
// Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct).
|
||||
await expect(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).resolves.toBe(USD_TO_ETB);
|
||||
// → the display fare and the charged amount for the same trip differ by 100x.
|
||||
});
|
||||
|
||||
it("C2b 🔴 truly-missing pair: getExchangeRate → 1.0 (silent), getRateOrThrow → throws", async () => {
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromCurrency: "USD", toCurrency: "ETB" },
|
||||
{ fromCurrency: "ETB", toCurrency: "USD" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
|
||||
await expect(
|
||||
currency.getRateOrThrow("USD" as any, "ETB" as any),
|
||||
).rejects.toThrow(/No exchange rate/i);
|
||||
});
|
||||
|
||||
it("C3 🔴 a future-dated rate is used right now (no effective-date gate)", async () => {
|
||||
const future = new Date(Date.now() + 365 * 24 * 3600 * 1000);
|
||||
await harness.prisma.currencyExchangeRate.create({
|
||||
data: { fromCurrency: "ETB", toCurrency: "USD", rate: 999, effectiveDate: future },
|
||||
});
|
||||
|
||||
// Correct behavior: ignore not-yet-effective rates. Actual: latest-by-date wins immediately.
|
||||
const rate = await currency.getExchangeRate("ETB" as any, "USD" as any);
|
||||
expect(rate).toBe(999);
|
||||
});
|
||||
|
||||
it("C5 🔴 conversion routines return different UNITS for the same money (100x apart)", async () => {
|
||||
// 100000 ETB minor = 1000.00 ETB. With ETB→USD = 1/100:
|
||||
const asMajor = await currency.convertMinorToChargeMajor(100000, "ETB", "USD"); // → 10.00 (major)
|
||||
const asMinor = await currency.convertEtbMinorToChargeMinor(100000, "USD"); // → 1000 (minor)
|
||||
|
||||
expect(asMajor).toBeCloseTo(1000 / USD_TO_ETB, 2); // 10.00
|
||||
expect(asMinor).toBe(Math.round((100000 * 1) / USD_TO_ETB)); // 1000
|
||||
// Same amount, but the two results differ by 100x — and both feed fields named `amountMinor`.
|
||||
expect(asMinor).toBe(asMajor * 100);
|
||||
});
|
||||
});
|
||||
131
apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts
Normal file
131
apps/edr-passenger-api/test/pricing-fare-engine.e2e-spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly.
|
||||
* Also confirms two matrix findings against the running engine:
|
||||
* D1 — a promo with percentOff > 100 drives the total NEGATIVE (no clamp at 0).
|
||||
* C1 — a missing USD→ETB FX rate is silently substituted with 1.0 (fares collapse ~100x).
|
||||
*/
|
||||
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
|
||||
import {
|
||||
createServiceHarness,
|
||||
ServiceHarness,
|
||||
} from "./setup/slim-app";
|
||||
import {
|
||||
IDS,
|
||||
resetAndSeedCore,
|
||||
DISTANCE,
|
||||
USD_TO_ETB,
|
||||
} from "./fixtures/seed-core";
|
||||
|
||||
describe("Pricing — FareEngineService (slim harness)", () => {
|
||||
let harness: ServiceHarness;
|
||||
let fareEngine: FareEngineService;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createServiceHarness();
|
||||
fareEngine = harness.moduleRef.get(FareEngineService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
});
|
||||
|
||||
// nationality 'Ethiopian' → LOCAL seat class (3.00 ETB/km) and ETB billing (rate 1).
|
||||
const baseDto = () => ({
|
||||
routeId: IDS.route,
|
||||
originStationId: IDS.stationA,
|
||||
destinationStationId: IDS.stationB,
|
||||
seatClassId: IDS.seatClassLocal,
|
||||
nationality: "Ethiopian",
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
});
|
||||
|
||||
it("boots and computes a positive baseline fare (A→B, local, 1 adult)", async () => {
|
||||
const result = await fareEngine.calculate(baseDto() as any);
|
||||
// 100km × 3.00 ETB/km × 1 × USD_TO_ETB(100) = 30000 minor (see seat-class formula).
|
||||
expect(result.totalMinor).toBeGreaterThan(0);
|
||||
expect(result.totalMinor).toBe(DISTANCE.B * 3 * USD_TO_ETB);
|
||||
});
|
||||
|
||||
it("D1 🔴 promo percentOff=150 produces a NEGATIVE total (no floor at 0)", async () => {
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Overshoot",
|
||||
code: "OVER150",
|
||||
percentOff: 150,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "OVER150",
|
||||
} as any);
|
||||
|
||||
// Expected (correct) behavior: total clamped at >= 0. Actual: negative.
|
||||
expect(result.totalMinor).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("D2 🔴 fixed amountOffMinor larger than subtotal drives total NEGATIVE", async () => {
|
||||
const base = await fareEngine.calculate(baseDto() as any); // 30000 minor
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Huge fixed",
|
||||
code: "FIXEDBIG",
|
||||
amountOffMinor: base.totalMinor + 10_000,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "FIXEDBIG",
|
||||
} as any);
|
||||
expect(result.totalMinor).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("D4 🔴 promo with percentOff=0 is treated as FIXED (0 is falsy) and applies amountOffMinor", async () => {
|
||||
// A promo intended as '0% off' but also carrying a stray fixed amount: the falsy check
|
||||
// `promo.percentOff ? percent : amountOffMinor` wrongly applies the fixed discount.
|
||||
await harness.prisma.promotion.create({
|
||||
data: {
|
||||
title: "Zero percent",
|
||||
code: "ZERO0",
|
||||
percentOff: 0,
|
||||
amountOffMinor: 5000,
|
||||
validUntil: new Date(Date.now() + 86_400_000),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const base = await fareEngine.calculate(baseDto() as any);
|
||||
const withPromo = await fareEngine.calculate({
|
||||
...baseDto(),
|
||||
promoCode: "ZERO0",
|
||||
} as any);
|
||||
|
||||
// A true 0% promo should not change the price; here it deducts the fixed 5000.
|
||||
expect(withPromo.totalMinor).toBe(base.totalMinor - 5000);
|
||||
});
|
||||
|
||||
it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => {
|
||||
const withRate = await fareEngine.calculate(baseDto() as any);
|
||||
|
||||
// Remove the USD→ETB rate the seat-class formula multiplies by.
|
||||
await harness.prisma.currencyExchangeRate.deleteMany({
|
||||
where: { fromCurrency: "USD", toCurrency: "ETB" },
|
||||
});
|
||||
|
||||
const withoutRate = await fareEngine.calculate(baseDto() as any);
|
||||
|
||||
// Correct behavior would be to reject/flag; instead the fare silently drops by the rate factor.
|
||||
expect(withoutRate.totalMinor).toBe(withRate.totalMinor / USD_TO_ETB);
|
||||
expect(withoutRate.totalMinor).toBeLessThan(withRate.totalMinor);
|
||||
});
|
||||
});
|
||||
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
39
apps/edr-passenger-api/test/setup/load-env.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Loads apps/edr-passenger-api/.env.test into process.env BEFORE the Nest AppModule boots.
|
||||
* Registered as a jest `setupFile` (runs per test file, before the framework and before any
|
||||
* `Test.createTestingModule`). Zero-dependency KEY=VALUE parser — dotenv is not a direct dep here.
|
||||
* Existing process.env values win (so CI can override the DB URL without editing the file).
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Prefer a local (gitignored) .env.test; fall back to the tracked .env.test.example so a fresh
|
||||
// checkout of the branch runs the suites without a manual copy step.
|
||||
const localPath = join(__dirname, "..", "..", ".env.test");
|
||||
const examplePath = join(__dirname, "..", "..", ".env.test.example");
|
||||
const envPath = existsSync(localPath) ? localPath : examplePath;
|
||||
|
||||
try {
|
||||
const raw = readFileSync(envPath, "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
// strip surrounding quotes if present
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface loudly — a missing .env.test means every suite would boot against the wrong DB.
|
||||
throw new Error(
|
||||
`[load-env] could not read ${envPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
26
apps/edr-passenger-api/test/setup/prisma.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Singleton PrismaClient against the hermetic test DB (DATABASE_URL from .env.test, loaded by
|
||||
* setup/load-env.ts). Used by:
|
||||
* - the fixture seeder (fixtures/seed-core.ts), and
|
||||
* - "direct-instantiation" specs for services behind the IAM/RabbitMQ wall (BookingsService,
|
||||
* PaymentsService, WalletService, …) which cannot be booted through their Nest modules because
|
||||
* those transitively import the @tria-plc IAM stack (ESM-only `file-type`) / golevelup RabbitMQ.
|
||||
* Those specs `new TheService(prisma, ...mockedCollaborators)` and assert the money logic.
|
||||
*/
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
let client: PrismaClient | undefined;
|
||||
|
||||
export function getTestPrisma(): PrismaClient {
|
||||
if (!client) {
|
||||
client = new PrismaClient();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function disconnectTestPrisma(): Promise<void> {
|
||||
if (client) {
|
||||
await client.$disconnect();
|
||||
client = undefined;
|
||||
}
|
||||
}
|
||||
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
145
apps/edr-passenger-api/test/setup/slim-app.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Slim Nest test harness — boots ONLY the passenger domain modules needed for pricing/booking
|
||||
* tests, deliberately excluding the IAM (TriaIamModule), SharedAuth, and MinIO stack from
|
||||
* app.module.ts. Those drag in `@tria-plc/api-common`'s file-crud/minio chain which requires the
|
||||
* ESM-only `file-type` package that jest's CommonJS resolver cannot load.
|
||||
*
|
||||
* Two entry points:
|
||||
* - createServiceHarness(): resolve services directly (FareEngineService, etc.) for unit/DB-level
|
||||
* assertions on the money math.
|
||||
* - createHttpHarness(): a full Nest HTTP app with the SAME global ValidationPipe as main.ts, so
|
||||
* controller/DTO/pipe behavior (client-trust, DTO validation) is exercised end-to-end over HTTP.
|
||||
*
|
||||
* The IAM JwtGuard is overridden with an always-allow stub so protected routes are reachable; auth
|
||||
* *enforcement* findings (which guards are missing) are asserted separately via route metadata, not
|
||||
* by booting the real guard.
|
||||
*/
|
||||
import { Global, INestApplication, Module, ValidationPipe } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { getDataSourceToken } from "@nestjs/typeorm";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { PrismaModule } from "../../src/common/prisma.module";
|
||||
import { PrismaService } from "../../src/common/prisma.service";
|
||||
import { SessionActivityInterceptor } from "../../src/common/interceptors/session-activity.interceptor";
|
||||
import { FareEngineModule } from "../../src/modules/fare-engine/fare-engine.module";
|
||||
import { CurrencyModule } from "../../src/modules/currency/currency.module";
|
||||
import { CurrenciesModule } from "../../src/modules/currencies/currencies.module";
|
||||
import { PromosModule } from "../../src/modules/promos/promos.module";
|
||||
import { SeatClassesModule } from "../../src/modules/seat-classes/seat-classes.module";
|
||||
import { StationsModule } from "../../src/modules/stations/stations.module";
|
||||
import { SchedulesModule } from "../../src/modules/schedules/schedules.module";
|
||||
import { SegmentsModule } from "../../src/modules/segments/segments.module";
|
||||
import { SystemConfigModule } from "../../src/modules/system-config/system-config.module";
|
||||
|
||||
/**
|
||||
* A stub TypeORM DataSource, provided globally so IAM-derived providers that reach the slim
|
||||
* harness transitively (e.g. NotificationsService via ExcessBaggageModule) can instantiate.
|
||||
* Pricing tests never trigger the code paths that actually use it.
|
||||
*/
|
||||
const fakeDataSource = {
|
||||
query: async () => [],
|
||||
transaction: async (cb: (m: unknown) => unknown) => cb({}),
|
||||
getRepository: () => ({}),
|
||||
createQueryRunner: () => ({
|
||||
connect: async () => undefined,
|
||||
startTransaction: async () => undefined,
|
||||
commitTransaction: async () => undefined,
|
||||
rollbackTransaction: async () => undefined,
|
||||
release: async () => undefined,
|
||||
manager: {},
|
||||
}),
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: getDataSourceToken(), useValue: fakeDataSource }],
|
||||
exports: [getDataSourceToken()],
|
||||
})
|
||||
class TestGlobalsModule {}
|
||||
|
||||
/** Modules that are safe to import in isolation (verified free of the IAM/MinIO chain). */
|
||||
const DOMAIN_MODULES = [
|
||||
FareEngineModule,
|
||||
CurrencyModule,
|
||||
CurrenciesModule,
|
||||
PromosModule,
|
||||
SeatClassesModule,
|
||||
StationsModule,
|
||||
SchedulesModule,
|
||||
SegmentsModule,
|
||||
SystemConfigModule,
|
||||
];
|
||||
// NOTE: ExcessBaggageModule/PaymentsModule/BookingsModule are intentionally excluded — they pull in
|
||||
// NotificationsModule → @golevelup RabbitMQ which connects at boot. Their suites instantiate the
|
||||
// service directly with mocked collaborators (see excess-baggage / booking-trust specs).
|
||||
|
||||
async function buildModule(): Promise<TestingModule> {
|
||||
return Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
EventEmitterModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
TestGlobalsModule,
|
||||
PrismaModule,
|
||||
...DOMAIN_MODULES,
|
||||
],
|
||||
})
|
||||
// SessionActivityInterceptor needs the IAM TypeORM DataSource, which the slim harness
|
||||
// deliberately omits. Replace it with a pass-through — it does not affect pricing logic.
|
||||
.overrideProvider(SessionActivityInterceptor)
|
||||
.useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() })
|
||||
.compile();
|
||||
}
|
||||
|
||||
export interface ServiceHarness {
|
||||
moduleRef: TestingModule;
|
||||
prisma: PrismaClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Resolve services for direct method-level assertions. */
|
||||
export async function createServiceHarness(): Promise<ServiceHarness> {
|
||||
const moduleRef = await buildModule();
|
||||
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
|
||||
return {
|
||||
moduleRef,
|
||||
prisma,
|
||||
close: async () => {
|
||||
await moduleRef.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HttpHarness {
|
||||
app: INestApplication;
|
||||
moduleRef: TestingModule;
|
||||
prisma: PrismaClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Boot a full HTTP app with the production ValidationPipe config from src/main.ts:56. */
|
||||
export async function createHttpHarness(): Promise<HttpHarness> {
|
||||
const moduleRef = await buildModule();
|
||||
const app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidUnknownValues: false,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
const prisma = moduleRef.get(PrismaService) as unknown as PrismaClient;
|
||||
return {
|
||||
app,
|
||||
moduleRef,
|
||||
prisma,
|
||||
close: async () => {
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
10
apps/edr-passenger-api/test/setup/stubs/file-type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* CommonJS stub for the ESM-only `file-type` package (v21). jest's CommonJS resolver cannot load
|
||||
* the real one, and `@tria-plc/api-common`'s minio.service `require("file-type")` at import time,
|
||||
* dragging the whole IAM stack down with it. minio.service only calls fileTypeFromBuffer when
|
||||
* actually processing an upload — never during pricing/booking tests — so a stub is sufficient to
|
||||
* let the full AppModule boot. Mapped via jest `moduleNameMapper` (^file-type$).
|
||||
*/
|
||||
export async function fileTypeFromBuffer(): Promise<undefined> {
|
||||
return undefined;
|
||||
}
|
||||
@@ -596,6 +596,7 @@ export default function PaymentPage() {
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
data-testid={`pay-method-${method.type}`}
|
||||
onClick={() => setSelectedMethod(method.type)}
|
||||
disabled={isProcessing || !method.enabled}
|
||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||
|
||||
@@ -318,7 +318,7 @@ export default function ResultsPage() {
|
||||
);
|
||||
// Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed.
|
||||
const minFare = coachType?.classes.length
|
||||
? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
|
||||
? Math.min(...coachType.classes.map((c) => c.displayAmountMinor ?? c.baseFareMinor))
|
||||
: 0;
|
||||
const fareCurrency = displayCurrencyCode;
|
||||
|
||||
@@ -484,6 +484,7 @@ export default function ResultsPage() {
|
||||
return (
|
||||
<div
|
||||
key={coachType.coachId}
|
||||
data-testid="coach-option"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={selectThisCoach}
|
||||
@@ -633,6 +634,7 @@ export default function ResultsPage() {
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="continue-passenger-details"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
@@ -829,6 +831,7 @@ export default function ResultsPage() {
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
data-testid="result-select-btn"
|
||||
onClick={() =>
|
||||
setClassModal({ ...schedule, isOutbound } as any)
|
||||
}
|
||||
|
||||
@@ -472,7 +472,13 @@ export default function SeatsPage() {
|
||||
// For package bookings both legs always use the same coach type — mirror the
|
||||
// switch to the inbound schedule so the auto-assign fetches the right seatmap.
|
||||
if (isPackageBooking && inboundSchedule) {
|
||||
setInboundSchedule({ ...(inboundSchedule as any), ...updatedSchedule, id: inboundSchedule.id });
|
||||
setInboundSchedule({
|
||||
...(inboundSchedule as any),
|
||||
...updatedSchedule,
|
||||
id: inboundSchedule.id,
|
||||
originStationId: (inboundSchedule as any).originStationId,
|
||||
destinationStationId: (inboundSchedule as any).destinationStationId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSelectedSchedule(updatedSchedule);
|
||||
|
||||
463
docs/ISSUES.md
Normal file
463
docs/ISSUES.md
Normal file
@@ -0,0 +1,463 @@
|
||||
# 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.
|
||||
- **Resolution (authenticated paths)** ✅ — `bookings.service.ts` now guards both `createOneWayBooking`
|
||||
and `createRoundTripBooking` with `assertTotalNotUnderAuthoritative(resolvedTotalMinor,
|
||||
fareCalculation.totalMinor)`: a booking whose ETB charge basis falls below the server-recomputed
|
||||
authoritative fare (net of promo/loyalty/free-child) by more than a 1% FX-rounding tolerance is
|
||||
rejected with `BadRequestException` and nothing is persisted. It's a **floor** (not equality) so
|
||||
legitimate berth surcharges — which only raise the total — still pass. Proven by
|
||||
`e2e-ui/specs/portal/ua13-forged-total.spec.ts` (now asserts a 4xx + no 1-minor booking; red before
|
||||
the guard, green after).
|
||||
- **Resolution (guest paths)** ✅ — `guest-booking.service.ts` now applies the identical
|
||||
`assertTotalNotUnderAuthoritative` floor guard to both the one-way and round-trip guest booking
|
||||
creation paths (authoritative ETB fare captured before the client-driven per-seat/reviewed branches
|
||||
overwrite the total). Proven by `e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts` (forged
|
||||
`seatFareMinor=0` + `reviewedTotalMinor=0` now rejected with a 4xx and no 0-minor booking persisted;
|
||||
red before the guard, green after). C-1 is now closed on all four booking-creation paths
|
||||
(authenticated one-way/round-trip + guest one-way/round-trip).
|
||||
|
||||
### 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.
|
||||
- **Resolution (passenger side)** ✅ — `payments.service.ts` `handlePaymentEvent` (the consumer of
|
||||
the payment service's `mark-paid` relay — the passenger-side settlement entry point) now compares
|
||||
the provider-settled `event.amountMinor` against the booking's display-currency total
|
||||
(`displayTotalMinor`, i.e. the amount the passenger was quoted) before materializing the intent or
|
||||
finalizing. A short payment (below the expected amount beyond a 1% rounding tolerance) is refused
|
||||
with `{ processed: false, reason: 'amount-mismatch' }` and the booking is left unconfirmed — no
|
||||
ticket. Amount-only by design: the display↔charge-currency divergence for USD/DJF (UA-1b/2/3) is
|
||||
tracked separately, so the guard compares against `displayTotalMinor` to stay correct for both ETB
|
||||
and the currently-diverging currencies. Proven by `e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts`
|
||||
(forged `amountMinor:1` now leaves the booking unconfirmed; red before the guard, green after). The
|
||||
**payment-side** `intents.service.ts` mismatch (`applyProviderResult`) lives in `edr-payment-api`
|
||||
and is out of scope for the passenger-app fix.
|
||||
|
||||
### 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 ✅ Exchange-rate writes are missing the ADMIN check (any passenger can rewrite FX) — CORRECTED
|
||||
- **⚠️ Corrected by live testing** — the original claim (*unauthenticated* FX writes) was a **false
|
||||
positive**: `@tria-plc/api-common`'s `SharedAuthModule` registers a **global `APP_GUARD` = JwtGuard**
|
||||
(`shared-auth.module` `APP_GUARD`), so anonymous requests get **401**. The metadata-only J1 check
|
||||
saw no *method-level* guard and wrongly concluded "unauthenticated". The real defect is
|
||||
**authorization**, not authentication.
|
||||
- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — authenticated but
|
||||
**no `@PassengerAdmin`** (only `:42` DELETE has it).
|
||||
- **Repro (verified live)**: `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11) —
|
||||
anon → **401**, but a **regular passenger token → 200** rewrites the live USD↔ETB rate.
|
||||
- **Expected**: FX writes are admin-only. **Actual**: any logged-in user (incl. a passenger) can
|
||||
rewrite USD↔ETB↔DJF rates, which every international fare multiplies by
|
||||
(`fare-engine.service.ts:132,157,195`). Needs a valid login (not anonymous), so **HIGH, not
|
||||
CRITICAL** — but a single passenger can still distort all international pricing. Same class as C-9.
|
||||
- **Fix**: add `@PassengerAdmin()` (or `@PassengerStaff([currencies.manage])`) to `PUT`/`PATCH`.
|
||||
- **Resolution** ✅ — `fare-engine/currency.controller.ts` now decorates both `@Put()` and
|
||||
`@Patch(':id')` with `@PassengerAdmin()` + `@ApiBearerAuth('IAM-auth')`, matching the existing
|
||||
`@Delete` handler. `@PassengerAdmin()` is the repo's established guard decorator (`JwtGuard` +
|
||||
`PassengerPermissionGuard(admin)`) — no new auth code, and no `@edr/auth` placeholder needed since
|
||||
the permission infra already exists and the seeded staff admin carries the permission. The sibling
|
||||
`/currencies` write surfaces (`currency.controller.ts`, `currencies.controller.ts`) were already
|
||||
guarded, so `/fare-engine/exchange-rates` was the sole gap. Proven by
|
||||
`e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11): anon → 401, regular passenger PUT
|
||||
and PATCH → **403**, staff admin → 200; red before the guard, green after.
|
||||
|
||||
### C-9 ✅ `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired) — verified live
|
||||
- **Where**: `common/roles.guard.ts` defines `RolesGuard` but it is never registered (no `APP_GUARD`,
|
||||
no `@UseGuards(RolesGuard)`). The global `JwtGuard` (SharedAuthModule) does authN but NOT authZ, so
|
||||
`@Roles(...)` is inert on: `configurable-fare.controller.ts:20,111,187` (fare configs + feature
|
||||
toggle), `segments/segment-fare.controller.ts:15` (`/admin/segment-fares`),
|
||||
`system-config.controller.ts:23,32` (`GET/PATCH /config`).
|
||||
- **Repro (verified live)**: a **regular passenger token** → `PATCH /config` (`@Roles('ADMIN')`) →
|
||||
**HTTP 200** (wrote admin-only system config); `POST /admin/fare-configurations` → 400 (reached DTO
|
||||
validation, i.e. it passed the role guard). So any authenticated user bypasses the ADMIN gate.
|
||||
- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger) 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.
|
||||
|
||||
### C-10 ✅ Authenticated `POST /bookings` is BROKEN (passengerId resolution regression)
|
||||
- **Where**: `bookings.controller.ts:528-532` overrides `passengerId` with the JWT user id
|
||||
(`req.user.id`, the iamUserId — "never trust the request body", added in commit `25fdf88a`).
|
||||
`bookings.service.ts:773` resolves an iamUserId → Passenger ONLY when it is **non-UUID**. IAM user
|
||||
ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`
|
||||
— only `iamUserId` is set; `id` auto-generates). So the resolver never fires and `booking.create`
|
||||
(`bookings.service.ts:905`) uses the iamUserId directly as `passengerId`.
|
||||
- **Repro**: ✅ verified two ways — (1) live browser: the full UI booking flow returns **HTTP 400
|
||||
P2003** on `Booking_passengerId_fkey` for a logged-in passenger whose `Passenger.id ≠ iamUserId`
|
||||
(the realistic case); (2) deterministic API test `test/authed-booking-passengerid.e2e-spec.ts` —
|
||||
`create()` with a UUID iamUserId fails the FK, while `create()` with the real `Passenger.id`
|
||||
succeeds (control). The UI suite only goes green because `seed-ui.ts` deliberately sets
|
||||
`Passenger.id == iamUserId`.
|
||||
- **Expected**: every IAM-authenticated passenger can book. **Actual**: every authenticated
|
||||
`POST /bookings` fails with a foreign-key error; only the guest path (`/bookings/guest`, which
|
||||
creates a fresh passenger) works. This is a **regression** — before `25fdf88a`, the controller
|
||||
used the frontend-supplied `passengerId` (the real `Passenger.id`), which worked.
|
||||
- **Fix**: resolve the passenger by iamUserId unconditionally (`passenger.findUnique({ where: {
|
||||
iamUserId } })`) in the controller or service — drop the UUID-format gate at `bookings.service.ts:773`
|
||||
— and pass the resolved `Passenger.id` to `booking.create`. (Keep the "don't trust the body"
|
||||
intent; just translate the identity correctly.)
|
||||
- **⚠️ Confirm the deployment window**: verify whether `25fdf88a` is already in production. If so,
|
||||
authenticated bookings are down platform-wide; if it's only on `dev`, this is a pre-release blocker.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **Resolution** ✅ — `currency.service.ts` `getExchangeRate` no longer substitutes `1.0` on a missing
|
||||
rate; it logs and throws `BadRequestException` (`No exchange rate configured for X->Y`), matching
|
||||
`getRateOrThrow`. Fare pricing therefore fails closed: with the `USD→ETB` pair deleted the fare
|
||||
engine (`fare-engine.service.ts:157`) throws, so the search returns **no priced class** for the
|
||||
affected currency (the per-seat-class fare error is caught in `search.service.ts:1041`, so the trip
|
||||
is listed without a fare rather than 500ing), and an authoritative `calculateFare` on the booking
|
||||
path — which does not swallow the error — rejects the booking. No path prices at parity by default.
|
||||
Proven by `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (PB-10): with the rate present the
|
||||
USD search returns a priced fare; with it deleted the search returns an empty `faresByClass` instead
|
||||
of a ~100×-collapsed fare (red before the fix, green after). Note: `getExchangeRate` still resolves
|
||||
only the *direct* rate (no inverse/bridge) — unifying it with `getRateOrThrow` is the separate H-3
|
||||
cleanup; failing closed here is strictly safer than the old silent 1.0.
|
||||
|
||||
### 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.
|
||||
- **Resolution (booking-record coherence, UA-1b/UA-2/UA-3w)** ✅ — the stored booking record no longer
|
||||
mislabels its amount. Every `booking.create` path (`bookings.service.ts` one-way/round-trip/
|
||||
transit/round-trip-transit + the guest equivalents) now stores `currency: Currency.ETB` (the actual
|
||||
currency of `totalMinor`/the ETB charge basis) instead of the display currency. The passenger-facing
|
||||
amount stays in `displayCurrency`/`displayTotalMinor` (Birr for Ethiopian, DJF for Djiboutian, USD
|
||||
for Other), and every read endpoint already prefers those. The portal `results/page.tsx` on-select
|
||||
now carries the passenger-currency fare (`displayAmountMinor`) forward, aligning with the seats
|
||||
page's already-`displayAmountMinor` fare logic. Net effect (agreed model **A**): the passenger sees
|
||||
and is charged in their own currency; the internal charge basis stays ETB (the unit every downstream
|
||||
calc — wallet debit, loyalty, refund, gateway conversion — already assumes), now honestly labeled.
|
||||
Proven by `e2e-ui/specs/portal/ua2-usd-booking.spec.ts` and `ua3-djf.spec.ts` (UA-3w): `currency`
|
||||
is `ETB` while `displayCurrency`/`displayTotalMinor` carry USD/DJF — red before the fix
|
||||
(`currency` was `USD`/`DJF`), green after; UA-1 (ETB) unchanged. The deeper H-3 (unify
|
||||
`getExchangeRate`/`getRateOrThrow`) and H-4 (branded Minor/Major units) refactors remain open.
|
||||
|
||||
### 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.
|
||||
|
||||
### H-13 ✅ A valid promo is silently dropped in the browser flow (customer overcharged)
|
||||
- **Where**: `GET /search/fare-breakdown` (`search.service.ts:940-970`) computes the discount into a
|
||||
SEPARATE `discountMinor` / discounted `totalMinor`, but returns per-passenger `displayFareMinor`
|
||||
**undiscounted**. The review page (`portal/src/app/booking/review/page.tsx:587`) reduces the
|
||||
per-passenger fares and sends their sum as `reviewedTotalMinor` — i.e. the **undiscounted
|
||||
subtotal** — ignoring `discountMinor`. Promo only enters via the `?promoCode=` URL param (no UI
|
||||
input).
|
||||
- **Repro**: ✅ verified in-browser — `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`: with a valid 10%
|
||||
promo, the breakdown shows `discountMinor > 0` and `totalMinor < subtotalMinor`, yet the booking is
|
||||
stored at the full `subtotalMinor`.
|
||||
- **Expected**: the discounted total is booked and charged. **Actual**: the customer is charged full
|
||||
price despite a valid promo — a silent overcharge (and a broken promo feature). Matrix D / UA-8.
|
||||
- **Fix**: book the breakdown's discounted `totalMinor` (not the client-summed per-pax undiscounted
|
||||
fares); or return discounted per-pax fares. Best combined with C-1 (server recomputes the
|
||||
authoritative total, promo included, and rejects a client mismatch).
|
||||
- **Resolution (authed one-way)** ✅ — `bookings.service.ts` `createOneWayBooking` now applies the
|
||||
authoritative promo discount server-side. The portal still forwards `promoCode` in the booking body,
|
||||
so `calculateFare` already computes `discountMinor` — the total-resolution branches simply never
|
||||
subtracted it. When the total comes from a client-summed subtotal (per-seat sum or
|
||||
`reviewedTotalMinor`, both undiscounted), the code now subtracts `fareCalculation.discountMinor`
|
||||
(converted to display currency for the display total) so the stored/charged `totalMinor` =
|
||||
`subtotal − discount`. The engine-fallback branch already booked the discounted `totalMinor`, so it
|
||||
is excluded (via a `usedClientSubtotal` flag) to avoid double-subtracting; no-op when no promo
|
||||
applies (`discountMinor === 0`), so UA-11 (expired promo) and the non-promo specs are unaffected.
|
||||
This composes with the C-1 floor guard: after the discount is applied the resolved total equals the
|
||||
authoritative fare, so the guard passes. Proven by `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`
|
||||
(booking now stored at `subtotal − discount`; red before the fix, green after). The **round-trip**
|
||||
and **guest** paths share the same latent frontend drop but have no UI spec yet — tracked for a
|
||||
follow-up; the guest service additionally still overrides its discounted total with
|
||||
`reviewedTotalMinor` (see the PROMO REALITY note in `docs/ui-e2e-test-matrix.md`).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **Resolution (seat-class base price)** ✅ — `seat-classes.dto.ts` `CreateSeatClassDto.basePrice` and
|
||||
`insuranceFeeMinor` now carry `@Min(0)`; because `UpdateSeatClassDto extends PartialType(...)` the
|
||||
constraint applies to `PATCH /seat-classes/:id` too. A negative `basePrice` is rejected with 400 at
|
||||
the DTO layer (matching the backoffice form's `min=0`), so it never reaches the DB. Proven by
|
||||
`e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-7): `basePrice:-500` → 400, a valid
|
||||
write still succeeds (red before the `@Min`, green after). The other money DTOs named above
|
||||
(`schedules.dto.ts` `CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`) are not exercised
|
||||
by a UI spec and remain a follow-up for full M-1 closure.
|
||||
|
||||
### 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`).
|
||||
- **Resolution (percentOff bounds)** ✅ — `promos.dto.ts` `CreatePromotionDto.percentOff` now carries
|
||||
`@Min(0) @Max(100)` and `amountOffMinor` carries `@Min(0)`, so `POST /promos` with `percentOff:200`
|
||||
is rejected with 400 at the DTO layer while a valid ≤100% promo still saves. Proven by
|
||||
`e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-8): `percentOff:200` → 400, `percentOff:50`
|
||||
→ 201 (red before the bounds, green after). The `validUntil` `@IsDateString` tightening and the
|
||||
missing min-spend/usage-limit/max-cap columns remain a follow-up (not exercised by BC-8).
|
||||
|
||||
### 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.
|
||||
- **Resolution** ✅ — `system-config.dto.ts` adds `UpdateSystemConfigDto`, a whitelisted body listing
|
||||
every known config key, each `@Type(() => Number) @IsInt() @Min(...)` (seat-hold bounded 1..60,
|
||||
throttle limits/TTLs `@Min(1)`, hour windows `@Min(0)`). The controller now accepts the DTO (so the
|
||||
global whitelisting ValidationPipe strips unknown keys and enforces the ranges) and persists the
|
||||
validated values back as strings. `PATCH /config {seat_hold_duration_minutes:"-1"}` (or `"abc"`) is
|
||||
rejected with 400; a sane value still stores. Proven by
|
||||
`e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-9): `-1` → 400, `15` → stored (red before
|
||||
the DTO, green after).
|
||||
|
||||
### 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.
|
||||
- **Resolution (past departure)** ✅ — `schedules.service.ts` `createSchedule` now rejects a
|
||||
`departureAt` in the past (`dep.getTime() < Date.now()` → 400) alongside the existing
|
||||
`arrivalAt > departureAt` check. Proven by `e2e-ui/specs/backoffice/config-validation.spec.ts`
|
||||
(BC-10): a 2020 departure → 400 while a future schedule still creates (red before the guard, green
|
||||
after). Scoped to creation (an admin may still need to edit metadata on an already-departed
|
||||
schedule via `updateSchedule`). The cross-route train double-booking overlap widening remains a
|
||||
follow-up (not exercised by BC-10).
|
||||
|
||||
### 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.
|
||||
141
docs/SOLUTIONS.md
Normal file
141
docs/SOLUTIONS.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# EDR Passenger — Solutions
|
||||
|
||||
Concrete fixes for the confirmed findings in `docs/ISSUES.md`. Ordered by priority. Each references
|
||||
the exact site and the intended change. Code sketches are illustrative, not drop-in patches.
|
||||
|
||||
**Test coverage backing these:** 28 automated tests (25 API `jest` + 3 UI Playwright) reproduce the
|
||||
✅ findings. Fix a finding → its 🔴 test flips from "bug present" to failing; update the test to
|
||||
assert the corrected behavior.
|
||||
|
||||
---
|
||||
|
||||
## P0 — deploy blockers (money creation/theft, broken booking)
|
||||
|
||||
### C-10 — Authenticated `POST /bookings` is broken
|
||||
`bookings.service.ts:773` resolve unconditionally; delete the UUID-format gate:
|
||||
```ts
|
||||
// BEFORE: resolves only when passengerId is NOT a UUID (never fires for real IAM ids)
|
||||
if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f-]{36}$/i)) { … }
|
||||
// AFTER: always translate the authenticated identity → the Passenger.id
|
||||
if (dto.passengerId) {
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: dto.passengerId }, select: { id: true },
|
||||
});
|
||||
if (passenger) dto = { ...dto, passengerId: passenger.id };
|
||||
// else: leave as-is only if it already IS a Passenger.id (guest/admin paths)
|
||||
}
|
||||
```
|
||||
Keep the controller's "don't trust the body" intent (`bookings.controller.ts:528`) — it's correct to
|
||||
take identity from the JWT; the service just has to map iamUserId → Passenger.id. Regression test:
|
||||
`test/authed-booking-passengerid.e2e-spec.ts`.
|
||||
|
||||
### C-1 — Booking total is client-controlled
|
||||
`bookings.service.ts:863-899`: stop trusting `reviewedTotalMinor`/`seatFareMinor`. Recompute the fare
|
||||
server-side and reject a mismatch:
|
||||
```ts
|
||||
const server = fareCalculation.totalMinor;
|
||||
if (dto.reviewedTotalMinor != null && Math.abs(dto.reviewedTotalMinor - server) > 1) {
|
||||
throw new BadRequestException('Price changed — please review the updated fare');
|
||||
}
|
||||
resolvedTotalMinor = server; // never persist a client amount as the charge basis
|
||||
```
|
||||
Apply the same to `guest-booking.service.ts:206-245`.
|
||||
|
||||
### C-2 — Loyalty redemption unbounded + never deducted
|
||||
`bookings.service.ts:1705` (and `:1028/:1249/:1451`): validate + debit inside the booking transaction:
|
||||
```ts
|
||||
const acct = await tx.loyaltyAccount.findUnique({ where: { passengerId } });
|
||||
const pts = Math.min(dto.loyaltyRedemptionPoints ?? 0, acct?.pointsBalance ?? 0, MAX_REDEEM);
|
||||
const loyaltyMinor = pts * POINTS_TO_MINOR;
|
||||
await tx.loyaltyLedgerEntry.create({ data: { accountId: acct.id, delta: -pts, reason: 'REDEEMED', balanceAfter: acct.pointsBalance - pts } });
|
||||
await tx.loyaltyAccount.update({ where: { id: acct.id }, data: { pointsBalance: { decrement: pts } } });
|
||||
```
|
||||
|
||||
### C-3 — Wallet top-up: no ownership, no backing
|
||||
- `wallet.controller.ts:34`: enforce `req.user` owns `:passengerId` (or is admin) before top-up.
|
||||
- `wallet.service.ts:50`: only credit after a confirmed `PaymentIntent` (a top-up is a purchase).
|
||||
- `wallet.controller.ts:23-24`: remove `@IsPublic()` from `GET /wallet/accounts`.
|
||||
|
||||
### C-4 — Payment amount never validated
|
||||
- `intents.service.ts:541-548`: on `confirmedAmountMinor !== intent.amountMinor`, do NOT mark
|
||||
SUCCEEDED — set a `AMOUNT_MISMATCH` state and alert. Populate `confirmedAmountMinor` in each
|
||||
webhook handler (e.g. `waafi-webhook.service.ts:63`).
|
||||
- `payments.service.ts:809` `finalizePaymentSuccess`: assert the settled amount equals
|
||||
`booking.totalMinor` before confirming.
|
||||
|
||||
### C-5 — Late webhook resurrects an expired/cancelled booking
|
||||
`payments.service.ts:809` `finalizePaymentSuccess`: refuse to confirm unless the booking is still
|
||||
`PENDING_PAYMENT`; route a late success to the refund/again-available flow:
|
||||
```ts
|
||||
if (booking.status !== 'PENDING_PAYMENT') { await this.refundLatePayment(intent); return { alreadyFinalized: true }; }
|
||||
```
|
||||
|
||||
### C-6 — Wallet double-spend (no row lock)
|
||||
`payments.service.ts:461-484`: lock the row or use an atomic conditional update:
|
||||
```ts
|
||||
const res = await tx.$executeRaw`UPDATE passenger."WalletAccount"
|
||||
SET "balanceMinor" = "balanceMinor" - ${total}
|
||||
WHERE "passengerId" = ${booking.passengerId} AND "balanceMinor" >= ${total}`;
|
||||
if (res === 0) return { success: false }; // insufficient / lost the race
|
||||
```
|
||||
Regression test: `critical-repro.e2e-spec.ts` (C-6, barrier-forced interleave).
|
||||
|
||||
### C-7 — Refund computed but never disbursed
|
||||
`bookings.service.ts:2017-2027`: on `booking.cancelled`, actually disburse — credit the wallet or call
|
||||
the provider refund — and drive `refundStatus PENDING → PROCESSING → COMPLETED`. Add a reconciliation
|
||||
sweep for stuck `PENDING` rows.
|
||||
|
||||
### C-8 — Exchange-rate writes missing the ADMIN check (any passenger can write FX)
|
||||
`fare-engine/currency.controller.ts:25,32`: add `@PassengerAdmin()` (+ `@ApiBearerAuth`) to the `PUT`
|
||||
and `PATCH` handlers, matching the already-guarded `DELETE`. (Not unauthenticated — the global
|
||||
JwtGuard requires a token; the gap is the missing *authorization*. Verified live: passenger → 200.)
|
||||
|
||||
### C-9 — `@Roles('ADMIN')` is dead
|
||||
Register the guard globally so `@Roles` is enforced:
|
||||
```ts
|
||||
// app.module.ts providers
|
||||
{ provide: APP_GUARD, useClass: RolesGuard }
|
||||
```
|
||||
…or convert `configurable-fare` / `segment-fare` / `system-config` controllers to the working
|
||||
`@PassengerAdmin()`/`@PassengerStaff()` guards.
|
||||
|
||||
---
|
||||
|
||||
## P1 — pricing correctness (HIGH)
|
||||
|
||||
- **H-1 promo → negative total** (`fare-engine.service.ts:192`): `totalEtbMinor = Math.max(0, subtotal - discount)`; DTO `@Min(0) @Max(100)` on `percentOff`, `@Min(0)` on `amountOffMinor` (`promos.dto.ts:20,26`).
|
||||
- **H-2 missing FX → 1.0** (`currency.service.ts:142`): remove the silent `return 1.0` — throw / block the quote so it fails closed.
|
||||
- **H-3/H-4 FX divergence & unit confusion** (`currency.service.ts`): collapse the 4 routines into one `convert(fromMinor, from, to): {minor|major}` with one rounding + one fallback policy; give it a branded `Minor`/`Major` return type and audit every `amountMinor` assignment across the payment boundary.
|
||||
- **H-5 `percentOff:0` treated as FIXED** (`fare-engine.service.ts:185`): use `promo.percentOff != null ? … : promo.amountOffMinor`.
|
||||
- **H-6 `insuranceFeeMinor` dual meaning** (`fare-engine.service.ts:130,154,167`): split into `insuranceMultiplierBps` and `insuranceFeeMinor`; use one consistently.
|
||||
- **H-7 domestic ETB fare × USD→ETB rate** (`fare-engine.service.ts:157`): don't apply a currency conversion to a domestic base fare — separate the minor-unit scaling from FX.
|
||||
- **H-8 baggage ignores seat class** (`excess-baggage.service.ts:53`): `findFirst({ where: { seatClassId } })`.
|
||||
- **H-9 baggage/supp skip conversion + DJF rounding** (`excess-baggage.service.ts:166`, `supplementary-charges.service.ts:132`): route through `convertMinorToChargeMajor`.
|
||||
- **H-10 future-dated FX applied now** (`currency.service.ts:88,137`): add `effectiveDate: { lte: new Date() }` to the rate lookups.
|
||||
- **H-11 non-deterministic fare resolution** (`fare-engine.service.ts:84,298`): add `orderBy: { validFrom: 'desc' }`; include `validFrom` in `SegmentFareRule`'s unique key (`schema.prisma:1113`) to allow dated versions.
|
||||
- **H-12 divergent free-child rules** (`fare-engine.service.ts:172` vs `bookings.service.ts:1690` vs `:1622`): extract ONE `computeFare()` used by quote, booking, and payment.
|
||||
- **H-13 promo silently dropped → overcharge** (`review/page.tsx:587`, `search.service.ts:940-970`): book the breakdown's discounted `totalMinor`, not the client-summed undiscounted per-pax fares — or return discounted per-pax fares. Fold into the C-1 fix (server recomputes the authoritative total incl. promo).
|
||||
|
||||
---
|
||||
|
||||
## P2 — config validation & safety (MEDIUM) / UI (LOW)
|
||||
|
||||
- **M-1 negative fares**: add `@Min(0)` to `baseFareMinor` (`schedules.dto.ts:85,95`) and `basePrice` (`seat-classes.dto.ts:29`).
|
||||
- **M-2 promo bounds/date**: `@Min(0) @Max(100)` on `percentOff`, `@IsDateString()` on `validUntil` (`promos.dto.ts`); add min-spend / usage-limit / max-cap columns.
|
||||
- **M-3 `PATCH /config` arbitrary**: replace the raw body with a whitelisted, typed DTO with per-key range checks (`system-config.controller.ts:34`).
|
||||
- **M-4 past-date / double-booked schedules** (`schedules.service.ts:105,124`): reject past `departureAt`; widen the overlap check to the train across all routes.
|
||||
- **M-5 deletes ignore references** (`stations`/`seat-classes`/`currencies`/`schedules` services): add referential guards before delete/disable; wrap the schedule cascade (`schedules.service.ts:438-485`) in a transaction.
|
||||
- **M-6 non-atomic booking write** (`bookings.service.ts:883-926`): wrap create + confirmSeats + tier increment in one `$transaction`.
|
||||
- **L-1 DJF shown with 2 decimals** (`portal/src/utils/format.ts:22`): format per `CHARGE_CURRENCY_DECIMALS` (DJF = 0).
|
||||
- **L-2 client-side fare math** (`portal/src/utils/fare-utils.ts`, `review/page.tsx`): render only server-computed amounts; never submit a client-derived total (ties to C-1).
|
||||
- **L-3 loyalty accrual currency** (`payments.service.ts:1062`): accrue from the actual charged amount/currency, not ETB minor.
|
||||
|
||||
---
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
1. **C-10, C-3, C-8, C-9** — quickest high-impact (a few lines each): unblock authenticated booking, stop free wallet credit, guard FX writes, enforce roles.
|
||||
2. **C-1, C-2, C-4, C-5, C-6, C-7** — the money-integrity core (needs transactions + validation).
|
||||
3. **H-2, H-3, H-4, H-7** — the FX/units foundation others compound on.
|
||||
4. **H-1, H-5, H-8..H-12, M-1, M-2** — pricing correctness + validation.
|
||||
5. **M-3..M-6, L-1..L-3** — config safety + UI consistency.
|
||||
137
docs/TESTING.md
Normal file
137
docs/TESTING.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# EDR Passenger — Testing Runbook
|
||||
|
||||
How to run **everything**: the API bug-hunt harness and the UI (browser) harness, view the reports,
|
||||
run a single test, and troubleshoot. Both are hermetic (their own Postgres on 5544 — never prod).
|
||||
|
||||
- **Findings:** `docs/ISSUES.md` · **Fixes:** `docs/SOLUTIONS.md`
|
||||
- **Test plans:** `docs/e2e-test-matrix.md` (API) · `docs/ui-e2e-test-matrix.md` (UI)
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites (one time)
|
||||
|
||||
- **Docker Desktop** running (the harness starts Postgres + RabbitMQ containers).
|
||||
- **Node ≥ 20**, **pnpm 11** (`corepack enable` if needed).
|
||||
- Install deps once: `pnpm install` (from repo root).
|
||||
|
||||
That's it — no manual DB, env, or auth setup. The scripts handle migrations, seeding, and auth.
|
||||
|
||||
---
|
||||
|
||||
## 1. Run the API harness (fast — no browser)
|
||||
|
||||
Covers pricing math, FX, wallet/refund/payment integrity, config validation, auth gaps, and the
|
||||
authenticated-booking regression. **25 tests.**
|
||||
|
||||
```bash
|
||||
bash e2e/run.sh # infra + migrate + run + open HTML report
|
||||
# or: pnpm test:e2e:passenger
|
||||
```
|
||||
|
||||
Flags: `bash e2e/run.sh --down` (tear DB down after) · `--no-open` (don't open the browser).
|
||||
|
||||
Report → `apps/edr-passenger-api/e2e-report/index.html`.
|
||||
|
||||
**Run a single API suite / test:**
|
||||
```bash
|
||||
cd apps/edr-passenger-api
|
||||
npx jest --config ./test/jest-e2e.json test/pricing-fare-engine.e2e-spec.ts
|
||||
npx jest --config ./test/jest-e2e.json -t "double-spend" # by test name
|
||||
```
|
||||
(The test DB must be up — run `bash e2e/prepare.sh` once if you skipped `e2e/run.sh`.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Run the UI harness (browser — Playwright)
|
||||
|
||||
Covers the real portal booking flow (search → pay → confirm) with the price cross-check, plus
|
||||
backoffice auth. **One command boots the whole stack** (Postgres + RabbitMQ + passenger-api +
|
||||
portal + backoffice), seeds a bookable trip, mints passenger + staff auth, runs, and opens the report.
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:ui # = bash e2e-ui/run.sh (turnkey)
|
||||
```
|
||||
|
||||
First run takes ~1–2 min (it builds `@edr/types` and boots the Next.js apps). If the stack is already
|
||||
running, it reuses it. Report → `e2e-ui-report/index.html`.
|
||||
|
||||
**Run a subset / single UI test** (stack already up):
|
||||
```bash
|
||||
pnpm test:e2e:ui:only --project=portal # just the portal booking tests
|
||||
pnpm test:e2e:ui:only --project=backoffice
|
||||
npx playwright test -c e2e-ui/playwright.config.ts ua1 # by file name
|
||||
```
|
||||
|
||||
**Watch it run in a real browser** (headed) or step through it:
|
||||
```bash
|
||||
npx playwright test -c e2e-ui/playwright.config.ts --project=portal --headed
|
||||
npx playwright test -c e2e-ui/playwright.config.ts --project=portal --debug # Playwright Inspector
|
||||
npx playwright show-report e2e-ui-report # open a past report
|
||||
npx playwright show-trace test-results/**/trace.zip # trace of a failed run
|
||||
```
|
||||
|
||||
Projects: `portal` (logged-in passenger), `guest` (no auth), `backoffice` (staff), `propagation`
|
||||
(Track B — staff writes config via API → passenger portal reads; 4 tests).
|
||||
|
||||
---
|
||||
|
||||
## 3. Run absolutely everything
|
||||
|
||||
```bash
|
||||
bash e2e/run.sh --no-open # API: 25 tests
|
||||
pnpm test:e2e:ui # UI: 9 tests (boots the stack)
|
||||
```
|
||||
|
||||
Or the standalone hermetic API DB only: `bash e2e/prepare.sh` then `pnpm --filter @edr/passenger-api test:e2e`.
|
||||
|
||||
---
|
||||
|
||||
## 4. What each harness contains
|
||||
|
||||
| Harness | Location | What it proves |
|
||||
| --- | --- | --- |
|
||||
| API | `apps/edr-passenger-api/test/*.e2e-spec.ts` + `e2e/` | fare/FX math, promo/negative-total, wallet double-spend, refund-never-paid, FX-write authz gap, DTO validation gaps, **C-10 authed-booking FK regression** |
|
||||
| UI | `e2e-ui/` | **UA-1** booking money cross-check; **UA-13** 🔴 client-forged total (C-1); **UA-8** 🔴 promo dropped (H-13); **Track B** — fare change propagates live (PB-2), **C-8** passenger rewrites FX, **M-1** negative price accepted; smokes |
|
||||
|
||||
A test name with **🔴** encodes buggy behavior — when it **passes**, the bug is present. After you
|
||||
apply a fix from `docs/SOLUTIONS.md`, flip that test to assert the corrected behavior.
|
||||
|
||||
Seed for the UI flow: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (bookable Train/Schedule/
|
||||
Coach/Seats + WALLET/TELEBIRR payment methods + promos + funded wallet). Standalone:
|
||||
`DATABASE_URL=…5544 npx ts-node test/fixtures/seed-ui.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Teardown
|
||||
|
||||
```bash
|
||||
docker compose -f e2e/docker-compose.yml down # stops + wipes the test DB + RabbitMQ
|
||||
```
|
||||
The dev app processes (api/portal/backoffice) started by Playwright's `webServer` stop with the run;
|
||||
if you booted them manually, `lsof -ti :4000 :5174 :5184 | xargs kill`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| `Cannot find module '@edr/types'` on API boot | Types not built → `pnpm --filter @edr/types build` (the run scripts do this). |
|
||||
| API boot hangs on `AmqpConnection … ECONNREFUSED` | RabbitMQ not up → `docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e`. |
|
||||
| `EADDRINUSE :::4000` | A stale API instance is bound → `lsof -ti :4000 | xargs kill -9`, then re-run. |
|
||||
| Backoffice test redirects to `/login` | Staff storageState missing/expired → it's re-minted every run by `global-setup`; ensure `SEED_PASSENGER_STAFF=true` in `apps/edr-passenger-api/.env`. |
|
||||
| Portal booking 400 `Booking_passengerId_fkey` | **This is finding C-10** (real bug). The harness seeds `Passenger.id == iamUserId` to work around it — see `docs/ISSUES.md` C-10. |
|
||||
| Docker daemon not running | `open -a Docker`, wait ~15s, re-run. |
|
||||
| Ports differ | api 4000, portal 5174, backoffice 5184, payment 3003, Postgres 5544, RabbitMQ 5672. Override via `PORTAL_URL` / `BACKOFFICE_URL` / `API_URL` / `DATABASE_URL` env. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Coverage status & what's next
|
||||
|
||||
- **Done:** full hermetic harness, 34 green tests (25 API + 9 UI). UA-1 keystone + UA-8/UA-13 abuse
|
||||
rows, Track B propagation (PB-2, C-8, M-1), both auth roles, `BookingFlow` page-object.
|
||||
- **Next (Track A):** more `bookOneAdult` variations — UA-2 (USD), UA-6 (round-trip); multi-passenger
|
||||
free-child (UA-4/5) + gateway/DJF (UA-3) need helper extensions (per-pax form, forged webhook).
|
||||
- **Next (Track B):** PB-5 (disable station→gone), PB-10 (delete FX→1.0 fallback), config-mid-flight.
|
||||
|
||||
See `docs/ui-e2e-test-matrix.md` for the full row-by-row plan.
|
||||
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).
|
||||
259
docs/ui-e2e-test-matrix.md
Normal file
259
docs/ui-e2e-test-matrix.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# EDR Passenger Platform — Playwright UI E2E: Scenario Matrix + Phase 2 Harness Plan
|
||||
|
||||
**Phase 1 synthesis (MAPPING ONLY).** Consolidates the four mapping passes (portal booking, backoffice config, API/network contracts, auth+seed gaps) plus the adversarial review into a reviewable plan. Ties every scenario to an existing finding in `docs/ISSUES.md` (C-/H-/M-/L-) and `docs/e2e-test-matrix.md` (Suites A–K). **No tests written, no code changed, stack not run.**
|
||||
|
||||
Two framing facts inherited from Phase 1: (a) `fare-engine` is the live pricing pipeline; `configurable-fare` is dormant. (b) The domain seed (`prisma/seed.ts`) is disabled — the harness must build fixtures. Two blockers discovered this pass that flip several scenarios from "green/repro" to "invalid as written": **(1) the portal never applies promo discounts to the booked total** (§2 note), and **(2) both web apps have ZERO `data-testid`** (`grep -rn data-testid src` → 0 in both). Section 6 is the prerequisite testid checklist.
|
||||
|
||||
Ports: portal 5174, backoffice 5184, passenger-api 4000 (bare paths, no `/v1` except IAM `/v1/auth/*`), payment-api 3003 (`/webhooks/*`). Test DB port 5544 (`.env.test`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Master assertion recipe — capture price at every hop
|
||||
|
||||
Each UI test drives the browser but asserts the **money chain** via (a) Playwright network interception (`page.route` / `page.waitForResponse`), (b) direct DB reads against the 5544 test DB (Prisma client or SQL), and (c) DOM text assertions on rendered price nodes. The master invariant (matrix "Suite A"), trimmed to links that actually have backing in the maps:
|
||||
|
||||
```
|
||||
portal card price (displayAmountMinor)
|
||||
── [BREAK] on-select stored fare == Math.min(baseFareMinor) (results/page.tsx:320) ──
|
||||
== /search/fare-breakdown displayFareMinor
|
||||
== review computedTotal (reviewedTotalMinor sent, review/page.tsx:587)
|
||||
== Booking.totalMinor/displayTotalMinor
|
||||
== PaymentIntent.amountMinor
|
||||
== charged amount (WALLET debit OR gateway webhook)
|
||||
```
|
||||
|
||||
> **Removed from the stated invariant (over-claimed):** `loyalty accrual` — no §1.2 DB read captures a `LoyaltyAccount.pointsBalance` increment and no green row asserts accrual vs price; and `refund basis` — there is **no refund endpoint anywhere in the API map** and no refund scenario. If a loyalty-accrual assertion is wanted, add a `LoyaltyAccount.pointsBalance` read to a green WALLET row (§1.2) and re-add only that link. Refund is out of scope until a refund surface is mapped (see §7).
|
||||
|
||||
### 1.1 Network interception targets (exact method + path, in flow order)
|
||||
|
||||
| Hop | Method + Path (passenger-api :4000) | Capture for assertion | Source |
|
||||
|---|---|---|---|
|
||||
| Fayda gate | `GET /config/fayda-status` (confirm prefix — see §5.2) | `enabled` — must be `false` to expose manual passenger form | system-config.controller.ts:12,16 |
|
||||
| Stations load | `GET /stations` | station list (search inventory) | portal search page.tsx:606 |
|
||||
| Search | `POST /search` | body `{originStationId,destinationStationId,date,adultCount,childCount,nationality,journeyType,returnDate?}`; resp `outbound[].coachTypes[].classes[].{displayAmountMinor,baseFareMinor}` | results/page.tsx:234; search.controller.ts:13 |
|
||||
| **On-select stored fare** | (client-side, no request) | `minFare = Math.min(...classes.map(c => c.baseFareMinor))` — **`baseFareMinor`, NOT the card's `displayAmountMinor`**; diverges for USD/DJF and flows downstream as `baseFareAdult` | results/page.tsx:320,821 |
|
||||
| Promo (URL-injected) | `POST /promos/validate` `{code}` — **note: no in-portal "apply promo" input**; promo enters via `?promoCode=` → `searchCriteria.promoCode` | discount echo (does NOT reach booked total, see §2) | results/page.tsx:142 |
|
||||
| Save passengers | `POST /passengers/save-details` | `{passengers[],userId,deviceId}` | passengers/page.tsx:1062 |
|
||||
| Seatmap | `GET /seats/seatmap/{scheduleId}?coachTypeId=&journeyDirection=` | seat fares (`displayAmountMinor??baseFareMinor`) | seats/page.tsx:352 |
|
||||
| Hold | `POST /seats/hold` `{scheduleId,origin,dest,journeyDirection,passengers:[{passengerId,seatId}]}` | resp `{holdId,expiresAt}` — **capture `expiresAt`** for PB-9 | seats/page.tsx:617; seats.controller.ts:164 |
|
||||
| Fare breakdown | `GET /search/fare-breakdown?scheduleId=&...&passengers=<JSON>&displayCurrency=[&promoCode]` | resp per-pax `{fareMinor,displayFareMinor,isFree}` (**undiscounted**) + separate top-level `discountMinor`/`totalMinor` (**ignored by portal**) | review/page.tsx:550,559,574; search.service.ts:906-975 |
|
||||
| Create booking | `POST /bookings` (auth) **or** `POST /bookings/guest` | body `reviewedTotalMinor` (undiscounted per-pax sum), per-pax `seatFareMinor`; resp `{bookingId/pnr,totalMinor}` | review/page.tsx:209,394,457; bookings.controller.ts:364/181 |
|
||||
| Booking amount | `GET /payments/booking-amount?bookingId=¤cy=` | resp `{amount (MAJOR, plain /100), currency}` — portal ×100; currency is driven by the **PaymentMethod.currency**, not the booking | payment/page.tsx:68,73 |
|
||||
| Initiate | `POST /payments/initiate` `{bookingId,method,paymentMethodId,payerAccount?,platform}` | resp `clientAction{type,url}` **and `merchantOrderId`** (required to key the forged webhook) | payment/page.tsx:123,149; payments.controller.ts:108 |
|
||||
| Confirm (CAC) | `POST /payments/{bookingId}/confirm` `{otp}` | — | payment/page.tsx:174 |
|
||||
| Poll intent | `GET /payments/intents/{bookingId}` | status transitions | confirmation/page.tsx:125 |
|
||||
| Ticket | `GET /bookings/{bookingId}` | `{status,totalMinor,payment:{amountMinor,currency},tickets[].barcodePayload}` | confirmation/page.tsx:105 |
|
||||
|
||||
**Payment methods are DB-driven and must be seeded.** The portal renders only `PaymentMethod` rows where `enabled=true` (`payment/page.tsx:593`); `getSupportedPaymentMethods` returns enabled rows from the DB (`payments.controller.ts:273`). `seed-core.ts` seeds **none** → the pay page is empty and **every Track A row (WALLET included) hangs before paying**. See §5.4.
|
||||
|
||||
**WALLET path** (fully offline, no payment-api/webhook): `POST /payments/initiate {method:"WALLET"}` short-circuits server-side to `finalizePaymentSuccess`, debiting `booking.totalMinor` directly (payments.service.ts:461-523). Best UI settlement path for green tests. **Note:** WALLET produces **no `edr_payment.payment_intent`** and **bypasses the charge-currency conversion** — so the DJF whole-franc rounding is not observable here (see UA-3/UA-17, §2).
|
||||
|
||||
**Settlement injection for gateway tests** (no real gateway):
|
||||
- **Forge webhook** to payment-api :3003 — `POST /webhooks/telebirr` or `/webhooks/dmoney` (both `signatureValid=true` hardcoded) with `merch_order_id = <captured merchantOrderId>`, `trade_status=success`. Card/Waafi require valid HMAC — avoid. A TELEBIRR initiate returns `clientAction REDIRECT` and the portal does `window.location.href = url` (`payment/page.tsx:149`) → the test must `page.route`-abort that navigation to the non-existent gateway, forge the webhook, then drive to `/booking/confirmation`.
|
||||
- **Direct internal** — `POST /internal/payments/mark-paid` on :4000 with `{version:1,eventType:"payment.succeeded",service:"PASSENGER",referenceType:"BOOKING",referenceId:<bookingId>,...}`. `ServiceAuthGuard` returns true when `SERVICE_AUTH_TOKEN` unset (dev). Fastest deterministic settlement — but it will **not** reproduce a *late* webhook race (C-5, see UA-15) nor the charge-currency conversion (DJF, see UA-3).
|
||||
|
||||
### 1.2 DB reads to assert (test DB 5544)
|
||||
|
||||
- **passenger.Booking** (schema.prisma:510): `totalMinor`(:520), `currency`(:519), `displayCurrency`(:523), `displayTotalMinor`(:524), `status`(:518 → `"CONFIRMED"` on settle, payments.service.ts:846), `paidAt`(:550), `bookingType`, `returnLegStatus`.
|
||||
- **passenger.BookingSeat**: `fareMinor`, `displayCurrency`, `displayFareMinor` (:597-599).
|
||||
- **passenger.PaymentIntent** (:621): `amountMinor` **Float** (:624 — assert numeric, not int-exact), `currency`, `status`, `method`, `merchantOrderId`(unique), `paidAt`.
|
||||
- **edr_payment.payment_intent** (payment-api source of truth): `amount_minor`/`confirmed_amount_minor` **double precision** (migration 1782000000000). Assert as numeric. **Scope: gateway rows only (UA-15)** — WALLET creates no payment-api intent.
|
||||
- **WALLET extras**: `WalletLedgerEntry` DEBIT of `totalMinor` w/ `relatedBookingId`; `WalletAccount.balanceMinor` decremented; ticket row / `GET /tickets/{bookingRef}`.
|
||||
|
||||
### 1.3 Currency-formatting assertion (the L-1 target)
|
||||
|
||||
Portal renders every price through `formatFare(amountMinor, code)` = `` `${code} ${(amountMinor/100).toFixed(2)}` `` (fare-utils.ts:86) — **always /100, always 2 decimals**. So DJF renders `DJF 1234.56`. Whole-franc rounding lives on the **charge conversion** (`currency.service.ts:9-13`, `CHARGE_CURRENCY_DECIMALS`; `payments.service.ts:223-298`), which **WALLET short-circuits past**.
|
||||
- **ETB / USD**: assert DOM shows 2dp; assert `renderedMajor*100 == amountMinor`.
|
||||
- **DJF (WALLET)**: can only assert the **shape** mismatch — DOM shows 2dp (`DJF x.yy`) while `GET /payments/booking-amount` returns whole-franc-less major via plain `/100`. No settled 0-decimal `amount_minor` exists on this path.
|
||||
- **DJF (gateway / forged-telebirr)**: the real L-1 settle-side repro — assert DOM 2dp vs the charge-currency-converted, whole-franc `amount_minor`/`confirmed_amount_minor`. UA-3/UA-17 must route here to observe it.
|
||||
|
||||
---
|
||||
|
||||
## 2. TRACK A — Booking combinations matrix (pruned cross-product)
|
||||
|
||||
Axes: booking type {one-way, round-trip} × pax mix {1A, 2A, 1A+1C-free, 1A+2C (1 free/1 paid), 2A+3C} × class/berth {Economy Regular, Economy Bed} × nationality/currency {Ethiopian→ETB / LOCAL, Djiboutian→DJF / LOCAL, Other→USD / INTERNATIONAL} × promo {none, %valid, expired} × payment {WALLET, forged-telebirr}. Pruned to meaningful, finding-bearing rows.
|
||||
|
||||
> **PROMO REALITY (blocking correction).** `GET /search/fare-breakdown` returns **undiscounted per-pax `displayFareMinor`** and puts the discount only in *separate* top-level `discountMinor`/`totalMinor` (`search.service.ts:906-975`). The portal review page **ignores** that top-level total and client-reduces the per-pax fares (`review/page.tsx:587`), sending that **undiscounted** sum as `reviewedTotalMinor`. The (guest) booking service then **overrides its own discounted total with `reviewedTotalMinor` when `>0`** and clamps its fallback with `Math.max(0,…)` (`guest-booking.service.ts:240-244,487,536-537,744`). Consequences: **through the browser, a valid promo is silently dropped and `Booking.totalMinor` = full price**, and a negative total is **not reproducible via UI**. Promo enters only via `?promoCode=` URL param (no selector); lookup is `findUnique({where:{code}})` (`search.service.ts:941`) — seed codes must be unique and exact. Whether the **authed** `/bookings` path shares the same override+clamp is unverified (§7).
|
||||
|
||||
| ID | Scenario | Key inputs | Price cross-check expectation | Finding tie-in |
|
||||
|---|---|---|---|---|
|
||||
| **UA-1** | One-way, 1 adult, Economy Regular, Ethiopian/ETB, WALLET, no promo | ETB LOCAL regular class | Baseline green: card price == fare-breakdown == reviewedTotalMinor == Booking.totalMinor == PaymentIntent.amountMinor == wallet DEBIT. All equal, 2dp. (Optionally assert `LoyaltyAccount.pointsBalance` accrual here if the accrual link is kept.) | matrix A6 (baseline) |
|
||||
| **UA-1b** | One-way, 1 adult, **Other/USD** — assert card `displayAmountMinor` vs internal `baseFareMinor` | nationality OTHER → USD; INTL class | ✅ FIXED — the card shows the USD fare (`displayAmountMinor`), the internal `baseFareMinor` is the ETB source it was converted from (rate apart, coherent). The portal now carries the USD value forward (`results/page.tsx` on-select uses `displayAmountMinor`, aligning with the already-USD seats-page logic). | div #1; H-3/H-4 |
|
||||
| **UA-2** | One-way, 1 adult, Other/USD, INTERNATIONAL Regular, WALLET | OTHER → displayCurrency USD | ✅ FIXED — money chain COHERENT: `displayCurrency=USD`/`displayTotalMinor` = what the passenger saw (=`reviewedTotalMinor`); `currency=ETB`/`totalMinor` = the ETB charge basis (=`displayTotalMinor × rate`). The prior `currency:USD`-on-an-ETB-amount mislabel is gone. | H-3/H-4, matrix B3 |
|
||||
| **UA-3** | One-way, 1 adult, **Djiboutian/DJF**, **forged-telebirr** | DJIBOUTIAN → DJF; gateway path | **DJF displayed 2dp (`DJF x.yy`) but charged whole-franc** — assert DOM-2dp vs settled `amount_minor` (0-decimal). Must be a **gateway** row (WALLET bypasses the charge conversion). | **L-1** ✅, matrix C6/K3 |
|
||||
| **UA-3w** | One-way, 1 adult, Djiboutian/DJF, WALLET (shape-only) | DJF, WALLET | Assert only the DOM-2dp vs `booking-amount`-major **shape** mismatch (no settle-side rounding on WALLET). | L-1 (partial) |
|
||||
| **UA-4** | One-way, **1A + 1 child ≤5yr (free)**, ETB, WALLET | childCount 1, DOB<5yr | Child shows "CHILD - FREE"; free child excluded from total; `fare-breakdown.isFree==true` agrees with client `fare-utils.isFirstChild` | **H-12**, matrix B6 |
|
||||
| **UA-5** | One-way, **1A + 2 children** (first free, second paid), ETB, WALLET | childCount 2 | Second child paid; client reduce (review:587) == breakdown sum; assert booking vs quote free-child count agree (quote uses min(child,adult); booking uses child-1) | **H-12**, matrix B6 |
|
||||
| **UA-6** | Round-trip, 1 adult, Economy Regular, ETB, WALLET | ROUND_TRIP, outbound+inbound holds | ✅ FIXED — the fare engine now prices the reverse (C→A) leg by absolute distance (was: threw "origin must come before destination", leaving the inbound leg with seats but no priced coach → unbookable). Full two-leg flow completes: 2 seats (one per leg), total = 2× the one-way fare. | div #6; matrix A3 |
|
||||
| **UA-7** | Round-trip, 2 adults, **Economy Bed / berth**, INTERNATIONAL/USD, WALLET | bed seat-class; berth seats (`bedPosition`) | Berth priced as separate class; `getSeatFare` bedPosition match (seats:433) == breakdown; INTL berth surcharge consistent | requires **berth seed** (§5); matrix B3 |
|
||||
| **UA-8** | One-way, 1 adult, ETB, **valid % promo via `?promoCode=`**, WALLET | valid `percentOff:10` in URL | ✅ FIXED — the browser still sends the undiscounted `reviewedTotalMinor`, but the authed `bookings.service` recomputes the authoritative fare and applies the promo, so `Booking.totalMinor` = `subtotal − discount`. | H-13 fixed & guarded; matrix D |
|
||||
| **UA-11** | One-way, 1 adult, ETB, **expired promo** (validUntil past, active:true) via URL, WALLET | expired code | Promo rejected/ignored; total unaffected; UI shows no discount (consistent with UA-8 drop). | matrix D5 |
|
||||
| **UA-13** | One-way, 1 adult, ETB, **client-forged low total** (intercept `POST /bookings`, rewrite `reviewedTotalMinor:1` + every `seatFareMinor:1`) | mutate body via `page.route` | ✅ FIXED — server recomputes the authoritative fare and REJECTS the underpayment (4xx); no booking persisted | **C-1** fixed & guarded, matrix A1 |
|
||||
| **UA-14** | One-way guest, forged per-pax `seatFareMinor:0` (+ `reviewedTotalMinor:0`) | intercept `/bookings/guest` | ✅ FIXED — server recomputes the authoritative fare and REJECTS the free-ride underpayment (4xx); no booking persisted | **C-1** fixed & guarded, matrix A2/A4 |
|
||||
| **UA-15** | One-way, 1 adult, ETB, **forged-telebirr short-pay** | booking total in the thousands; forge `/internal/payments/mark-paid` success with `amountMinor:1` | ✅ FIXED — the server compares the settled amount to the booking's display total and REFUSES a short payment; booking stays unconfirmed, no ticket | **C-4** fixed & guarded, matrix G1/G7 |
|
||||
| **UA-16** | Round-trip, 2A+3C, mixed, ETB, WALLET | max pax spread | Stress free-child + per-leg split + total reduce; every backed hop equal | H-12, div #6 |
|
||||
|
||||
**Moved to the API-level harness (no valid browser path):**
|
||||
- **UA-9 / UA-10 / UA-17** — over-100% `percentOff:150`, fixed `amountOffMinor > subtotal`, DJF×promo negative total. Not reproducible via UI: `reviewedTotalMinor` is positive-undiscounted and the server clamps to 0 (`guest-booking.service.ts`). Keep as API-only for **H-1**.
|
||||
- **UA-12** — loyalty over-redeem (**C-2**). No browser path: `grep loyalty|redeem` across `portal/src/app/booking/**` + `booking-store.ts` → zero hits; `loyaltyRedemptionPoints` exists only on `POST /search/fare-quote`, which the portal never calls (it uses fare-breakdown, no loyalty param). Keep as API-only.
|
||||
|
||||
> Payment method: default all rows to WALLET (deterministic, offline). UA-3 and UA-15 use forged-telebirr. Rows tagged ✅ have an existing API-level repro in `docs/ISSUES.md`; the UI test proves the defect surfaces through the real browser flow (closing the "Suite K not yet run" gap, ISSUES.md L285-290).
|
||||
|
||||
---
|
||||
|
||||
## 3. TRACK B — Config→portal propagation matrix
|
||||
|
||||
Each row: change made in backoffice UI (:5184) → API write → portal read (:5174) → propagation + staleTime → predicted finding. Backoffice self-refreshes immediately (each mutation invalidates its own React-Query key). Staleness only bites the **portal**.
|
||||
|
||||
| ID | Config change (backoffice UI) | Write endpoint | Portal read path | Propagation + staleTime | Predicted |
|
||||
|---|---|---|---|---|---|
|
||||
| **PB-1** | `/currencies` → edit ETB↔USD rate | `PATCH /currencies/{id}` `{rate}` | `GET /currencies` via useCurrencies.ts:19 | **staleTime 5min** — up to 5 min stale in portal | 🟠 matrix I1; ties H-2/H-3 |
|
||||
| **PB-2** | `/tariff-rates` Tab1 → edit seat-class base | `PATCH /seat-classes/{id}` `{basePrice}` (**field `basePrice`**) | next `POST /search` (staleTime:0) + `GET /search/fare-breakdown` | Live, no cache | 🟢 matrix I2/K4; **field-name split** (basePrice vs baseFareMinor) — verify which fare-engine reads (§7) |
|
||||
| **PB-3** | `/tariff-rates` Tab2/3 → route/segment fare override | `POST /schedules/routes/{routeId}/fare-rules` / `POST /schedules/segment-fares` | `POST /search` results | Live | 🟠 segment rule may not bite: engine matches `dto.nationality` or null; seeder writes 'LOCAL'/'INTERNATIONAL' → won't match (§5 note); matrix B5 / H-11 |
|
||||
| **PB-4** | `/stations` → add station | `POST /stations` | `GET /stations` (SearchWidget staleTime 60s; root prefetch raw fetch) | ≤60s stale in SearchWidget; prefetch uncached | 🟠 matrix K5 |
|
||||
| **PB-5** | `/stations` → **disable station** (isOperational=false) | `PATCH /stations/{id}` | `GET /stations` (portal passes **no operational filter**) | Only disappears if API omits non-operational server-side — **verify** (§7) | 🔴/🟠 matrix K5/I3 |
|
||||
| **PB-6** | `/classes` → create seat class | `POST /fleet/classes` `{baseFareMinor,...}` (**field `baseFareMinor`**, different endpoint than PB-2) | `POST /search` + seats page | Live (search staleTime:0) | 🟠 **two seat-class stores** (`/seat-classes` vs `/fleet/classes`) — confirm which live search reads (§7) |
|
||||
| **PB-7** | `/promos` (URL, nav commented) → create promo | `POST /promos` | `POST /promos/validate {code}` at results:142 | On demand | 🔴 **field-name mismatch**: UI sends `discountType/discountValue/isActive`; DTO expects `percentOff/amountOffMinor/active` → possibly inert promo. Verify; own finding. matrix K6 |
|
||||
| **PB-8** | `/schedules` → create schedule for search date | `POST /schedules` | `POST /search` | Live | 🟢 must satisfy all 9 searchability rules (§5); matrix I2 |
|
||||
| **PB-9** | `/settings` → change seat-hold TTL | `PATCH /config` (raw, no RQ, no invalidate) | **no portal read path** — config is ignored by the hold | Server-side runtime | 🔴 **reframed:** capture `expiresAt` from `POST /seats/hold` and assert it does **NOT** track the config value (hold uses a fixed TTL — reconcile 15-min `seats.service.ts:~272` vs the "20-min" claim). **G6** |
|
||||
| **PB-10** | `/currencies` → **delete** a rate pair | `DELETE /currencies/{id}` | `POST /search` (USD/Other) faresByClass | ✅ FIXED — `getExchangeRate` fails closed (throws) instead of substituting 1.0; the USD search returns NO priced class (no bogus ~100×-underpriced fare), and a booking would be rejected too | **M-5 / H-2** fixed & guarded, matrix I6 |
|
||||
|
||||
**Deferred config surfaces (mapped, out of Phase 2 scope — stated so the matrix doesn't read as complete):** `/fare-management` (schedule-scoped `FareRule`, fare-source #3), `/pricing` (`/admin/segment-fares`, the dead-`@Roles` route), and `/routes` fare-rule CRUD beyond PB-3.
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGH-VALUE bug-class scenarios (concrete steps)
|
||||
|
||||
### 4A. Config-mid-flight (edit/disable between quote and pay) — matrix I7
|
||||
- **BC-1**: Portal: search → results → select → hold → `/booking/review` (fare frozen). Second (backoffice) context: `PATCH /seat-classes/{id}` to triple the base. Back in portal: **Confirm**. **Assert** booking created at the *frozen* review price (`reviewedTotalMinor`), not the new one — booking never re-quotes; payment never re-validates. (ties C-1/L-2)
|
||||
- **BC-2**: Same, but **disable the station** mid-flight (PB-5). Assert the in-flight booking still completes (no re-validation of station operational state).
|
||||
|
||||
### 4B. Delete-referenced (M-5 / matrix I3–I6)
|
||||
- **BC-3**: Create a CONFIRMED booking (UA-1). Backoffice `/stations` → delete the origin station (accept cascade if FK 400 offered). **Assert** either a referential block OR an orphaned booking (`GET /bookings/{id}` resolves but station lookups break). `stations.service.ts:110-137` ignores bookings.
|
||||
- **BC-4**: `/classes` delete a seat-class referenced by a booking's `bookingSeat`. Assert orphan/FK behavior. matrix I4.
|
||||
- **BC-5**: `/currencies` delete the USD↔ETB pair with active INTL fares. Next portal INTL search → fare collapses ~100× (1.0 fallback). **H-2**, matrix I6.
|
||||
- **BC-3b** (new): `/routes` → delete a route referenced by a live schedule; assert orphaned schedule vs referential block. `routes.controller.ts`.
|
||||
- **BC-4b** (new): `/schedules` → cancel a schedule with a CONFIRMED booking; assert whether the booking is stranded. *(Both new rows may be explicitly deferred if Phase 2 scope is tight.)*
|
||||
|
||||
### 4C. Staleness (matrix I1)
|
||||
- **BC-6**: Backoffice edit ETB↔USD rate. Immediately do a portal USD search → **assert** portal may show the OLD rate (useCurrencies staleTime 5×60×1000). **Then force a reload / navigation / window-focus** to trigger the refetch (React-Query `staleTime` does NOT auto-refetch on its own), and assert the new rate. Distinguishes the 5-min window from live search pricing.
|
||||
|
||||
### 4D. Validation-via-UI vs direct-API (Suite H — direct-API bypass class; UI proves the client gaps)
|
||||
- **BC-7** ✅ FIXED: `PATCH /seat-classes` with `basePrice:-500` is now rejected with **400** — `CreateSeatClassDto.basePrice` (and `insuranceFeeMinor`) carry `@Min(0)`, applied to updates via `PartialType`. **M-1**, matrix H1/H2.
|
||||
- **BC-8** ✅ FIXED: `POST /promos` with `percentOff:200` is now rejected with **400** — `CreatePromotionDto.percentOff` carries `@Min(0) @Max(100)` (and `amountOffMinor` `@Min(0)`). A valid ≤100% promo still succeeds. **M-2**, matrix H4.
|
||||
- **BC-9** ✅ FIXED: `PATCH /config {seat_hold_duration_minutes:"-1"}` is now rejected with **400** — a whitelisted `UpdateSystemConfigDto` coerces each known key to a positive integer (`seat_hold_duration_minutes` bounded 1..60). A sane value still stores. **M-3**, matrix H7.
|
||||
- **BC-10** ✅ FIXED: `POST /schedules` with a past `departureAt` is now rejected with **400** — `schedules.service.createSchedule` guards `departureAt >= now` alongside the existing `arrival > departure` check. A future schedule still creates. **M-4**, matrix H3.
|
||||
- **BC-11** ✅ FIXED: `PUT/PATCH /fare-engine/exchange-rates` now carry `@PassengerAdmin()` (as DELETE already did). Anon → 401, regular passenger → **403 forbidden**, staff admin → 200. **C-8**, matrix J1.
|
||||
|
||||
---
|
||||
|
||||
## 5. PHASE 2 harness plan
|
||||
|
||||
### 5.1 `playwright.config.ts` structure
|
||||
```
|
||||
e2e-ui/ # new; sibling to existing e2e/ (API harness)
|
||||
playwright.config.ts
|
||||
global-setup.ts # boot+await stack (VERIFAYDA_ENABLED=false), seed, mint storageStates
|
||||
fixtures/
|
||||
storage/passenger.json # generated by global-setup
|
||||
storage/staff.json # generated by global-setup
|
||||
seed-ui.ts # domain fixtures (see 5.4)
|
||||
specs/
|
||||
portal/*.spec.ts # Track A (UA-*), BC-1/2/6
|
||||
backoffice/*.spec.ts # Track B config CRUD
|
||||
propagation/*.spec.ts # BC-3..BC-11 cross-app
|
||||
```
|
||||
- **projects**: `portal` (baseURL `http://localhost:5174`, storageState `passenger.json`), `backoffice` (baseURL `http://localhost:5184`, storageState `staff.json`), plus a `guest` project (no storageState) for guest rows (UA-14). Pin `viewport` per project — portal desktop layout is `hidden md:block`; mobile diverges heavily. One shared **`globalSetup`**.
|
||||
- `webServer`: optionally let Playwright start portal+backoffice (`pnpm --filter @edr/passenger-portal dev` etc.); reuseExistingServer in local dev.
|
||||
|
||||
### 5.2 global-setup
|
||||
1. Ensure Postgres :5544 up and migrated (`.env.test`, `JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000…`).
|
||||
2. Boot passenger-api :4000 (**with `VERIFAYDA_ENABLED=false`** so the portal exposes the manual passenger form — otherwise Fayda defaults ON and every booking flow is blocked; the flag is `enabled = process.env.VERIFAYDA_ENABLED !== 'false'`, system-config.controller.ts:12,16) and payment-api :3003 (or assert reachable). Await `/health`-style ping. **Confirm which `fayda-status` prefix the portal hits** (`/config` vs `fare-engine.controller.ts:65`, which defaults `false`) so the right flag is set.
|
||||
3. Run `seed-core.ts` + new `seed-ui.ts` (§5.4).
|
||||
4. Mint the two storageStates (§5.3), write to `fixtures/storage/`.
|
||||
|
||||
### 5.3 The two storageState fixtures (grounded in auth map)
|
||||
|
||||
The passenger-API `JwtGuard` is **DB-backed against `iam.sessions`** — a fake JWT 401s. JWT payload is `{ id: <sessionId> }` (NOT userId); roles/permissions live in the session's `userInfo` jsonb.
|
||||
|
||||
**Passenger storageState (portal :5174)** — no server gate, but `/auth/profile` runs on load and self-ejects on 401:
|
||||
1. Insert `iam.users` (individual, active).
|
||||
2. Insert `iam.sessions` (`status='ACTIVE'`, future expiry, `userInfo.roles=[]`).
|
||||
3. Insert Prisma `Passenger{iamUserId}` **+ `LoyaltyAccount` + `WalletAccount`(funded balanceMinor) + `UserPreferences`** — required or `getProfile` throws "Passenger not found" (passenger-auth.service.ts:262) and the portal logs out.
|
||||
4. Mint JWT `{id: sessionId}` with `JWT_ACCESS_TOKEN_SECRET`.
|
||||
5. Write storageState `localStorage` for origin :5174: `auth_token=<jwt>`, `auth_user=<profile JSON matching getProfile shape>`.
|
||||
6. *Simplest alternative*: drive real `POST /auth/login` once with a seeded passenger, snapshot localStorage.
|
||||
|
||||
**Staff/admin storageState (backoffice :5184)** — server middleware requires the `auth_token` **cookie**; API staff calls require `userInfo.roles` carrying `super_admin`/`organization_admin` or the right permission keys:
|
||||
- **Path A (robust)**: set `SEED_EDR_PASSENGER_ORG=true` + `SEED_PASSENGER_STAFF=true`, boot API → seeds org `edr`, roles, users (`passenger.admin@edr.local` / `Test@1234`). Then `POST /v1/auth/login` → `GET /v1/auth/me`, snapshot `localStorage` (`auth_token`,`auth_user`,`auth_refresh_token`) **and** set `auth_token` cookie.
|
||||
- **Path B (fast)**: insert `iam.users`+`iam.sessions` with `userInfo.roles=[{key:'super_admin'}]`, mint JWT, write storageState localStorage + `auth_token` cookie for :5184, `auth_user` with `isSuperAdmin:true`. Config pages don't use `PermissionGuard` — only middleware cookie + API guards matter.
|
||||
- **Note:** the `auth_token` cookie is **host-scoped (`localhost`), not port-scoped**, so it is also sent to the portal origin. Harmless (portal reads localStorage, not this cookie) but relevant if a single shared browser context is reused across projects.
|
||||
|
||||
### 5.4 Seed extensions (add to `seed-core.ts` or new `seed-ui.ts`)
|
||||
`seed-core.ts` today has CoachType×1, SeatClass×2 (LOCAL 300 / INTL 500, both regular), Station×3 (A/B/C), Route×1 + 3 RouteStop (0/100/250km), 4 FX rows. **No Train/Schedule/Coach/Seat/Passenger/PaymentMethod.** Add:
|
||||
- **PaymentMethod rows (BLOCKING — pay page is empty without them):** at minimum an **enabled `WALLET`** (currency ETB) and an **enabled `TELEBIRR`** (for UA-15). The method's `.currency` drives `booking-amount` and the displayed pay total (`payment/page.tsx:68`), so a DJF booking paid by an ETB wallet renders ETB on the pay page — relevant to UA-3/UA-3w.
|
||||
- **Bookable trip** (all 9 searchability rules): `Train`×1 → `TrainSchedule`(A→C, `status:'SCHEDULED'`, `isPackageOnly:false`, `departureAt = now+2d`, whole-day in Addis TZ, **>30min ahead**) → 3 `TripStopTime`(A/B/C seq 1/2/3, future `plannedDepartureAt`) → `Coach`×1(`status:'ACTIVE'`) → `CoachAssignment`(`isOperational:true`) → `Seat`×N (AVAILABLE, non-empty `seatNumber`, `bedPosition:null`). Fares resolve via `SEAT_CLASS_BASE_FARE` distance formula with the existing USD→ETB row — no fare-rule rows needed for the green path.
|
||||
- **Seat-class names (pin exactly):** the review flow builds a `seatClassName → seatClassId` map from `GET /seat-classes` (`review/page.tsx:277`) and fare-quote expects exact names `"Economy Regular"|"Economy Bed"` (search.dto.ts). Set `SeatClass.name` to the exact client strings, or those rows won't resolve. (The axis's "VIP Bed" has no seed/scenario — seed it or drop it from the axis; this matrix drops it.)
|
||||
- **Berth combos** (UA-7): LOCAL+INTL SeatClasses with `bedPosition IN ('UPPER','MIDDLE','LOWER')` + a bed `Coach` + `Seat`s with lowercase `bedPosition:'upper'|'middle'|'lower'`.
|
||||
- **Promotions** (UA-8/11): valid `percentOff:10`; expired (`validUntil` past, `active:true`). **Use schema field names `percentOff/amountOffMinor/active`** — NOT the backoffice UI field names. **Pin exact, unique `code` values** (lookup is `findUnique({where:{code}})`, search.service.ts:941); tests navigate with `?promoCode=<code>`. (Over-100% / over-subtotal promos belong to the API harness, not Track A.)
|
||||
- **BaggageAllowance** ×1 per seat class (for excess-baggage rows).
|
||||
- **Passenger satellite** for logged-in/WALLET: `Passenger{iamUserId}` + funded `WalletAccount(balanceMinor)` + `LoyaltyAccount`.
|
||||
- **Blocked-seat negative case**: one `SeatBlock` row.
|
||||
- **Segment override that actually bites** (PB-3): seed `SegmentFareRule` with `nationality:null` (engine matches `dto.nationality` string or null; 'LOCAL'/'INTERNATIONAL' rows won't match a real search).
|
||||
- FX: existing 4 rows suffice for ETB/USD/DJF via ETB pivot; add `USD↔DJF` only if a direct-path currency test needs it.
|
||||
|
||||
### 5.5 Two smoke tests
|
||||
- **Portal smoke** (`guest` project): home → search (seeded A→C, date = Addis date of `departureAt`) → results shows ≥1 card with a price → `formatFare` renders `ETB N.NN`. Asserts stack+seed+search+Fayda-flag wired.
|
||||
- **Backoffice smoke** (`backoffice` project): staff storageState → `/currencies` loads list → open "Add Rate" modal. Asserts staff auth (cookie+localStorage+API token) all valid.
|
||||
|
||||
### 5.6 pnpm scripts + turbo task
|
||||
- Root `package.json`: `"test:e2e:ui": "playwright test -c e2e-ui/playwright.config.ts"`.
|
||||
- turbo `test:e2e:ui` task `"cache": false`; global-setup owns boot/seed. Single command: `pnpm test:e2e:ui`.
|
||||
- Specs under `e2e-ui/specs/{portal,backoffice,propagation}`.
|
||||
|
||||
---
|
||||
|
||||
## 6. SELECTORS TO ADD — `data-testid` checklist (PREREQUISITE; both apps have 0 today)
|
||||
|
||||
Without these, every locator hangs off role/text/`name=`/placeholder, which is brittle across the portal's mobile/desktop breakpoint split. Recommend adding these before authoring (out of scope this phase; flag for user approval). **Promo has no selector — it enters via `?promoCode=` URL param.**
|
||||
|
||||
### Portal (`apps/edr-passenger-web/portal/src`)
|
||||
- **Search**: `search-trip-type-oneway`/`-roundtrip` (page.tsx:777/789), `search-origin-input` (:1234), `search-dest-input` (:1274), `search-swap` (:1261), `search-depart-date` (:1305), `search-return-date` (:1486), `search-pax-trigger` (:1334), `pax-adult-plus`/`-minus`, `pax-child-plus`/`-minus` (PassengerModal:319/330), `nationality-eth`/`-dji`/`-other` (:352), `search-submit` (:1362).
|
||||
- **Results**: `result-card` (per schedule), `result-card-price` (:821 — "starting from"), `result-select-btn` (:831), `coach-option` (:487), `coach-class-price` (:609), `continue-passenger-details` (:642), `modify-search` (:1282).
|
||||
- **Passengers**: `pax-name-{i}`, `pax-dob-btn` (:327), `pax-gender`, `pax-nationality`, `pax-phone`, `pax-passport`, `verify-fayda-btn`, `enter-manually-toggle` (:1003), `create-account-checkbox`, `passengers-continue`.
|
||||
- **DOB picker (`DobPickerModal` — required for UA-4/UA-5 free-child):** `dob-cal-etgc-toggle` (:346), `dob-manual-toggle` (:354), `dob-manual-day`/`-month`/`-year` inputs, `dob-day-cell-{n}`, `dob-confirm`.
|
||||
- **Seats**: `seat-cell-{label}` (SeatButton:119), `berth-cell-{label}` (BedCard:38), `passenger-tab-{i}`, `auto-assign-seats` (~:2006), `seats-continue` (~:1989), `fare-change-confirm` (CustomModal).
|
||||
- **Review**: `review-total` (:683 desktop / :1031 mobile), `review-pax-fare-{i}` (:662), `review-outbound-line`/`-return-line` (:670/674), `review-child-badge` (:657), `confirm-and-pay` (:694), `seat-hold-timer` (:719).
|
||||
- **Payment**: `pay-method-{type}` (:597), `pay-total` (:390 / :651 mobile), `pay-submit` (:406), `cac-phone-input` (:488), `cac-otp-input` (:531).
|
||||
- **Confirmation**: `confirmation-pnr` (:404), `confirmation-status` (:615), `confirmation-total-paid` (:631), `ticket-number-{i}` (:659), `download-voucher` (:794), `book-another` (:815).
|
||||
|
||||
### Backoffice (`apps/edr-passenger-web/backoffice/src`)
|
||||
- **Login**: `login-email` (:165), `login-password` (:189), `login-submit` (:221).
|
||||
- **DataTable / dialogs (shared)**: `add-entity-btn` (ActionButton), `row-edit-{id}`, `row-delete-{id}`, `confirm-dialog-confirm`, `confirm-cascade-checkbox`, `modal-submit`.
|
||||
- **Tariff Rates** (`/tariff-rates`): `tab-seatclass`/`tab-route`/`tab-segment`/`tab-baggage`; RateModal fields already have `name=` (`name`, `baseFareMinor`, `insuranceFeeMinor`/`surchargeMinor`, `isActive`) — add `testid` on submit + modal.
|
||||
- **Currencies** (`/currencies`): controlled form (no `name=`) — add `currency-from`, `currency-to`, `currency-rate`, `currency-save`, `currency-edit-rate`.
|
||||
- **Classes** (`/classes`): FormData has `name=` (`coachTypeId,name,baseFareMinor,insuranceFeeMinor,isActive`) — add submit testid.
|
||||
- **Schedules** (`/schedules`): controlled `addForm`/`DateTimePicker` — add `schedule-train`, `schedule-route`, `schedule-departure`, `schedule-arrival`, `schedule-status`, `schedule-save`, `schedule-cancel-btn`.
|
||||
- **Stations** (`/stations`): FormData `name=` present — add submit testid.
|
||||
- **Settings** (`/settings`): real `id=` (`hold-duration`, `hold-cutoff`, `boarding-window`, throttle-*) — usable, but add `config-save` testid.
|
||||
- **Promos** (`/promos`, URL-only): FormData `name=` present — add submit + note field-name mismatch (PB-7).
|
||||
|
||||
---
|
||||
|
||||
## 7. OPEN QUESTIONS / RISKS (decide before Phase 2)
|
||||
|
||||
1. **Valid IAM token for storageState** — Path A (real `/v1/auth/login` after enabling `SEED_EDR_PASSENGER_ORG` + `SEED_PASSENGER_STAFF`) vs Path B (direct `iam.sessions` insert with `userInfo.roles=[{key:'super_admin'}]` + self-signed JWT). **Recommend Path A for staff, Path B acceptable for passenger.** Confirm.
|
||||
2. **Seed `iam.sessions` vs dev bypass** — there is **no dev auth bypass** in the passenger-API `JwtGuard` (DB-backed, no env short-circuit). A session row is mandatory for any authenticated flow. Confirm we may write directly to `iam.sessions` in the test DB.
|
||||
3. **Target DB / stack** — doc drift: CLAUDE.md says `postgres-passenger:5434/edr_passenger`; `.env.example` says `localhost:5432/edr_database?schema=passenger`; `.env.test` uses `5544`; no compose file provisions it. **Confirm the harness stands up its own Postgres :5544 + boots both APIs, or targets an existing dev stack.**
|
||||
4. **Stack-startup reliability** — global-setup must boot passenger-api (:4000) + payment-api (:3003) + portal (:5174) + backoffice (:5184) + RabbitMQ (vhost `payment`), or route settlement through `/internal/payments/mark-paid` to avoid RabbitMQ. **Recommend the internal-endpoint path for green settlement determinism** — but note it will **not** reproduce a *late*-webhook race (C-5) nor the charge-currency conversion (DJF, UA-3), which both require a real forged-gateway webhook to :3003.
|
||||
5. **Gateway webhook signing** — Telebirr/dmoney accept forged payloads (`signatureValid=true` hardcoded); Card/Waafi require valid HMAC. UA-3/UA-15/gateway rows must use Telebirr/dmoney or the internal endpoint. Confirm we won't need real Card/Waafi HMAC in Phase 2.
|
||||
6. **Fayda flag & prefix** — global-setup must set `VERIFAYDA_ENABLED=false` (else the manual passenger form is hidden and every booking flow blocks). **Confirm which `fayda-status` route the portal reads** (`/config`, default-ON, vs `fare-engine.controller.ts:65`, default-OFF) so the correct flag is set.
|
||||
7. **Promo money-flow — does the authed path share the guest override+clamp?** The guest booking service overrides its discounted total with `reviewedTotalMinor` and clamps (`guest-booking.service.ts:240-244,487,536-537,744`), making promos inert and negative totals unreachable via UI. **Verify whether `bookings.service.ts` (authed `POST /bookings`) has the same override+clamp** before finalizing UA-8's "promo silently dropped" assertion for logged-in users.
|
||||
8. **`data-testid` addition** — Section 6 requires source edits to both web apps (including the `DobPickerModal` internals for child-fare rows). Approve adding testids (small, low-risk) vs authoring against fragile role/text selectors. **Strongly recommend adding testids first.**
|
||||
9. **Two field-name mismatches to verify at runtime** (each may be its own finding): (a) Promos UI sends `discountType/discountValue/isActive` but DTO expects `percentOff/amountOffMinor/active` → possibly inert promos (PB-7). (b) Seat-class base written as `basePrice` (Tariff Rates, PB-2) vs `baseFareMinor` (Classes page, PB-6), across two endpoints (`/seat-classes` vs `/fleet/classes`) — confirm which the live `fare-engine` reads before asserting PB-2/PB-6.
|
||||
10. **Portal station operational filter** (PB-5) — portal `GET /stations` passes no `operational` filter; whether a disabled station disappears depends on the server default. Verify before writing the disable-propagation assertion.
|
||||
11. **Currency-controller collision** — two `@Controller('currencies')` register the same base path (`currencies.controller.ts` + `currency.controller.ts`) with different guards/bodies; confirm which one the backoffice `/currencies` page hits before asserting PB-1/PB-10 write semantics.
|
||||
12. **Seat-hold TTL number** (PB-9) — the matrix draft said "20-min cron"; the seed map says `expiresAt = now + 15min` (`seats.service.ts:~272`). **Reconcile the actual fixed TTL** before asserting that the hold ignores the config value.
|
||||
13. **On-select fare divergence** (UA-1b) — confirm that the value stored on select is `Math.min(baseFareMinor)` (results:320) and not the card's `displayAmountMinor` (results:821), and pin which one downstream fare-breakdown reconciles against for non-ETB currencies.
|
||||
14. **Scope of Track A vs B** — Track A (UA-*) covers pricing integrity through the real browser (closes the Suite K gap); Track B/BC-* covers config propagation. Confirm both tracks are in Phase 2 scope, or prioritize Track A first (highest money-risk, most ✅ findings to surface in-browser).
|
||||
15. **Explicitly out-of-scope money surfaces (deferral, not omission):** loyalty redemption (C-2, no browser path), refunds (no endpoint mapped), over-100%/over-subtotal promo negative totals (H-1, API-only), transit / `ROUND_TRIP_TRANSIT` (needs a 2nd seeded route), package booking (`/packages`, `isPackageOnly` schedules, `packageTierPriceMinor × 2`), `/pay-balance/[token]` partial-payment / `returnLegStatus`, and config surfaces `/fare-management` + `/pricing`. Confirm these stay deferred so the matrix is not read as exhaustive.
|
||||
90
e2e-ui-report/index.html
Normal file
90
e2e-ui-report/index.html
Normal file
File diff suppressed because one or more lines are too long
4
e2e-ui/.gitignore
vendored
Normal file
4
e2e-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
fixtures/storage/
|
||||
test-results/
|
||||
../e2e-ui-report/
|
||||
.last-run.json
|
||||
154
e2e-ui/README.md
Normal file
154
e2e-ui/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# EDR Passenger — Playwright UI E2E
|
||||
|
||||
Browser E2E for the passenger platform. **Track A** = portal booking combinations; **Track B** =
|
||||
backoffice config → portal propagation. Scenario matrix: `docs/ui-e2e-test-matrix.md`.
|
||||
|
||||
## Status
|
||||
|
||||
**Track A + Track B implemented and green** — 27 passing specs, 1 documented skip (UA-7). Full suite
|
||||
runs deterministically in ~1.8 min (`workers:1`, one seeded DB shared serially). The whole booking
|
||||
flow is factored into `fixtures/booking-flow.ts` — `bookTrip(page, opts)` drives an arbitrary
|
||||
passenger mix, nationality, trip type, promo, and payment method end to end (search → select →
|
||||
passengers → seats → review → pay → confirmation), capturing the price at each hop; `bookOneAdult` is
|
||||
a thin back-compat wrapper.
|
||||
|
||||
### Coverage vs `docs/ui-e2e-test-matrix.md`
|
||||
|
||||
**Track A — booking combinations** (`specs/portal`, `specs/guest`):
|
||||
|
||||
| ID | Spec | What it proves |
|
||||
|----|------|----------------|
|
||||
| UA-1 | `ua1` | one-way 1A ETB WALLET — full money chain equal, CONFIRMED |
|
||||
| UA-1b | `ua1b-usd-divergence` | ✅ USD card shows the USD fare, correctly converted from the internal ETB base (coherent) |
|
||||
| UA-2 | `ua2-usd-booking` | ✅ USD booking — passenger amount in USD (display), charge basis stored coherently in ETB (currency mislabel fixed) |
|
||||
| UA-3 | `ua3-djf` | DJF booking settles via forged gateway payment |
|
||||
| UA-3w | `ua3-djf` | ✅ DJF WALLET — passenger amount in DJF, charge basis stored coherently in ETB (mislabel fixed) |
|
||||
| UA-4 | `ua4-child-free` | first child <5 free → total = one adult fare, free child not seated |
|
||||
| UA-5 | `ua5-second-child-paid` | 1A+2C → second child pays full fare (2 seats) |
|
||||
| UA-6 | `ua6-round-trip` | ✅ round-trip books both legs — reverse-leg pricing fixed (abs distance); 2 seats, total = 2× one-way (M-4-adjacent) |
|
||||
| UA-8 | `ua8-promo-drop` | ✅ valid promo now applied server-side; booking stored at the discounted total (H-13 fixed & guarded) |
|
||||
| UA-11 | `ua11-expired-promo` | expired promo ignored → full fare booked |
|
||||
| UA-13 | `ua13-forged-total` | ✅ client-forged `reviewedTotalMinor=1` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) |
|
||||
| UA-14 | `ua14-forged-seat-fare` | ✅ guest forged `seatFareMinor=0` now REJECTED 4xx, nothing stored (C-1 fixed & guarded) |
|
||||
| UA-15 | `ua15-telebirr-shortpay` | ✅ short-paid gateway settlement now REFUSED — booking stays unconfirmed (C-4 fixed & guarded) |
|
||||
| UA-16 | `ua16-family-mix` | 2A+3C → two children free, one paid (3 seats) |
|
||||
|
||||
**Track B — config → portal propagation & validation gaps** (`specs/backoffice`, `specs/propagation`):
|
||||
|
||||
| ID | Spec | What it proves |
|
||||
|----|------|----------------|
|
||||
| PB-1 | `pb-config-propagation` | FX-rate change propagates live to portal USD pricing |
|
||||
| PB-2 / PB-2b | `pb-config-propagation` | seat-class base-price change propagates live; `basePrice` field drives the fare |
|
||||
| PB-4 | `pb-config-propagation` | station added in backoffice appears in the portal station list |
|
||||
| PB-7 | `config-validation` | 🔴 promo created with backoffice UI field names is inert (field-name mismatch) |
|
||||
| PB-10 | `pb-config-propagation` | ✅ deleting an FX rate now FAILS CLOSED (no priced fare) instead of a silent 1.0 collapse (M-5/H-2 fixed & guarded) |
|
||||
| BC-7 | `pb-config-propagation` | ✅ negative seat-class base price now REJECTED (400, DTO `@Min(0)`) (M-1 fixed & guarded) |
|
||||
| BC-8 | `config-validation` | ✅ promo over 100% now REJECTED (400, DTO `@Max(100)`) (M-2 fixed & guarded) |
|
||||
| BC-9 | `config-validation` | ✅ negative seat-hold duration now REJECTED (400, whitelisted typed `/config` DTO) (M-3 fixed & guarded) |
|
||||
| BC-10 | `config-validation` | ✅ schedule with a past departure now REJECTED (400); future schedules still create (M-4 fixed & guarded) |
|
||||
| BC-11 | `pb-config-propagation` | ✅ non-admin passenger now FORBIDDEN (403) from FX writes; admin still allowed (C-8 fixed & guarded) |
|
||||
|
||||
### Deferred (documented, not silently omitted)
|
||||
|
||||
- **UA-7** (round-trip berth) — `specs/portal/ua7-berth.spec.ts` is `test.skip`: still needs a bed
|
||||
CoachType seed. (Reverse-leg pricing is no longer a blocker — fixed under UA-6.)
|
||||
- **UA-9 / UA-10 / UA-12 / UA-17** — the matrix moves these to the API-level harness (over-100% /
|
||||
over-subtotal promos, loyalty over-redeem, DJF×promo negative total): no reachable browser path
|
||||
(server clamps `reviewedTotalMinor` ≥ 0; the portal never calls the loyalty/fare-quote path).
|
||||
- **PB-3/5/6/8/9, BC-1…BC-6** — additional config surfaces and delete-referenced/mid-flight/staleness
|
||||
variations of the finding classes already covered above; the matrix marks several as deferrable.
|
||||
|
||||
**Gateway settlement:** the real telebirr gateway is unreachable in the test env (`/payments/initiate`
|
||||
502s), so gateway rows (UA-3, UA-15) create the booking through the real browser flow and then inject
|
||||
settlement via `POST /internal/payments/mark-paid` — exactly the matrix's settlement-injection plan.
|
||||
|
||||
Portal testids used: `result-select-btn`, `coach-option`, `continue-passenger-details`,
|
||||
`pay-method-{TYPE}`. Everything else (passengers form + DOB modal, seats auto-assign, review, payment)
|
||||
is driven via name/placeholder/role selectors — no further source edits were needed.
|
||||
|
||||
**Seed note:** `Passenger.id` is set EQUAL to the IAM user id — see the comment in `seed-ui.ts`
|
||||
(`UI_IDS.passenger`) and the SUSPECTED FINDING below. Each coach seeds 48 seats so a full serial run
|
||||
never exhausts availability across specs.
|
||||
|
||||
## Suspected finding (surfaced while building UA-1)
|
||||
|
||||
`POST /bookings` (authenticated) overrides `passengerId` with the JWT user id
|
||||
(`bookings.controller.ts:528-532`, "never trust the request body"). The service only resolves an
|
||||
iamUserId → Passenger when it is **non-UUID** (`bookings.service.ts:773`). IAM user ids are UUIDs, so
|
||||
the resolver never fires and `booking.create` uses the iamUserId directly as `passengerId` → FK
|
||||
violation unless `Passenger.id == iamUserId`. This is why the seed aligns them. **Verify against a
|
||||
real IAM-authenticated booking** — if `req.user.id` is genuinely the iamUserId in production,
|
||||
authenticated portal bookings may be broken (guest path unaffected). Candidate for `docs/ISSUES.md`.
|
||||
|
||||
## Prerequisites — the running stack
|
||||
|
||||
The suite drives a live stack. `global-setup.ts` seeds + mints auth, but assumes the apps are
|
||||
already up. Bring them up once (leave running across test runs):
|
||||
|
||||
```bash
|
||||
# 1. Infra: test Postgres (5544) + RabbitMQ (5672, payment vhost)
|
||||
bash e2e/prepare.sh # postgres + migrations
|
||||
docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e
|
||||
|
||||
# 2. Build the shared types package (nest build needs the dist)
|
||||
pnpm --filter @edr/types build
|
||||
|
||||
# 3. passenger-api on :4000 against the 5544 DB, with org+staff seeding on
|
||||
# (apps/edr-passenger-api/.env sets DATABASE_URL=…5544, PORT=4000,
|
||||
# RABBITMQ_ENABLED=false, FAYDA_ENABLED=false, SEED_EDR_PASSENGER_ORG=true,
|
||||
# SEED_PASSENGER_STAFF=true, DEFAULT_PASSWORD=Test@1234)
|
||||
( cd apps/edr-passenger-api && pnpm dev ) # background
|
||||
|
||||
# 4. Web apps (each has .env.local → NEXT_PUBLIC_API_URL=http://localhost:4000)
|
||||
( cd apps/edr-passenger-web/portal && pnpm dev ) # :5174, background
|
||||
( cd apps/edr-passenger-web/backoffice && pnpm dev ) # :5184, background
|
||||
```
|
||||
|
||||
> `playwright.config.ts` now declares a `webServer` block that auto-boots api/portal/backoffice and
|
||||
> **reuses** them if already running, so steps 3–4 are optional in local dev. Gateway rows settle via
|
||||
> the internal `mark-paid` endpoint, so `apps/edr-payment-api` (:3003) is **not** required.
|
||||
|
||||
## Run
|
||||
|
||||
One command (infra → build → boot → seed+auth → run → open report):
|
||||
|
||||
```bash
|
||||
bash e2e-ui/run.sh # all projects; args pass through to playwright
|
||||
bash e2e-ui/run.sh --headed # watch it in a real browser
|
||||
SLOWMO=500 bash e2e-ui/run.sh --headed # slow every action by 500ms
|
||||
bash e2e-ui/run.sh --project=portal ua4 # one project / filter by title
|
||||
```
|
||||
|
||||
Or, against an already-running stack:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:ui # all projects
|
||||
pnpm test:e2e:ui -- --project=guest --project=backoffice # smoke only
|
||||
```
|
||||
|
||||
HTML report → `e2e-ui-report/index.html`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
e2e-ui/
|
||||
playwright.config.ts projects: portal (passenger auth), guest (none),
|
||||
backoffice (staff auth), propagation (cross-app)
|
||||
global-setup.ts seeds test DB (seed-ui.ts) + mints staff.json via real /login
|
||||
fixtures/
|
||||
data.ts station IDs, sample depart date, results deep-link helper
|
||||
storage/staff.json generated staff storageState (gitignored)
|
||||
specs/{guest,portal,backoffice,propagation}/*.spec.ts
|
||||
```
|
||||
|
||||
Seed lives with the API harness: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (extends
|
||||
`seed-core.ts` with a bookable Train/Schedule/Coach/Seats, enabled PaymentMethods WALLET+TELEBIRR,
|
||||
promos, funded wallet). Run standalone: `npx ts-node test/fixtures/seed-ui.ts`.
|
||||
|
||||
## Auth model (grounded in the app)
|
||||
|
||||
- **Portal (passenger)**: `localStorage.auth_token` only, no server gate. (passenger storageState is
|
||||
a Phase 3 item — smoke uses the `guest` project.)
|
||||
- **Backoffice (staff)**: middleware requires the `auth_token` **cookie**; API guards check the
|
||||
session's permissions. `global-setup` logs in as the seeded `passenger.admin@edr.local` through
|
||||
the real `/login` UI and snapshots both. No hand-crafted tokens.
|
||||
367
e2e-ui/fixtures/booking-flow.ts
Normal file
367
e2e-ui/fixtures/booking-flow.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { expect, type Locator, type Page, type Route } from "@playwright/test";
|
||||
import { API_URL, CURRENCY_BY_NATIONALITY, resultsUrl } from "./data";
|
||||
|
||||
export type Nationality = "Ethiopian" | "Djiboutian" | "Other";
|
||||
|
||||
export interface PaxSpec {
|
||||
category: "ADULT" | "CHILD";
|
||||
name: string;
|
||||
gender: "Male" | "Female";
|
||||
/** Date of birth. Adults: age 6–110. Children: age < 5 (to be free-eligible). */
|
||||
dob: { d: number; m: number; y: number };
|
||||
/** Adults only. */
|
||||
phone?: string;
|
||||
/** Non-Ethiopian adults only. */
|
||||
passport?: { number: string; country: string; issue: string; expiry: string };
|
||||
}
|
||||
|
||||
export interface TripOptions {
|
||||
nationality?: Nationality;
|
||||
tripType?: "ONE_WAY" | "ROUND_TRIP";
|
||||
/** If `passengers` is omitted, N adults + M children are generated. */
|
||||
adults?: number;
|
||||
children?: number;
|
||||
passengers?: PaxSpec[];
|
||||
/** Promo code injected via the results URL (`?promoCode=`) — the portal has no promo input. */
|
||||
promoCode?: string;
|
||||
/** Mutate the outgoing POST /bookings(/guest) body (e.g. forge reviewedTotalMinor). */
|
||||
mutateBookingBody?: (body: any) => any;
|
||||
/**
|
||||
* When the POST /bookings(/guest) is expected to be rejected (e.g. a forged total the server
|
||||
* must refuse): don't assert a bookingId and return early with `bookingStatus` set, instead of
|
||||
* driving on to payment. Lets a spec assert the server refused the booking.
|
||||
*/
|
||||
tolerateBookingError?: boolean;
|
||||
paymentMethod?: "WALLET" | "TELEBIRR";
|
||||
/**
|
||||
* For TELEBIRR: after initiate, abort the external gateway redirect and forge settlement via the
|
||||
* internal mark-paid endpoint. `amountMinor` lets a test short-pay (settle for the wrong amount).
|
||||
* Defaults to settling for the real booking total.
|
||||
*/
|
||||
forgeSettlement?: { amountMinor?: number };
|
||||
}
|
||||
|
||||
export interface BookingResult {
|
||||
/** displayAmountMinor on the results card (what the passenger sees — passenger currency). */
|
||||
cardDisplayMinor: number;
|
||||
/** baseFareMinor on the results card (internal ETB fare; diverges from display for USD/DJF). */
|
||||
cardBaseFareMinor: number;
|
||||
/** The search response's displayCurrency (ETB/USD/DJF). */
|
||||
displayCurrency: string;
|
||||
/** reviewedTotalMinor the browser actually sent to POST /bookings. */
|
||||
reviewedTotalMinor: number;
|
||||
/** HTTP status the POST /bookings(/guest) returned (2xx on success, 4xx when the server rejects). */
|
||||
bookingStatus: number;
|
||||
bookingId: string;
|
||||
/** Whether the flow used /bookings/guest. */
|
||||
guest: boolean;
|
||||
initiateStatus: number;
|
||||
/** merchantOrderId returned by POST /payments/initiate (gateway methods). */
|
||||
merchantOrderId?: string;
|
||||
/** true once /booking/confirmation is reached. */
|
||||
confirmed: boolean;
|
||||
/** The GET /search/fare-breakdown payload seen on the review page (per-pax fares + discount). */
|
||||
fareBreakdown: any;
|
||||
}
|
||||
|
||||
const PHONE_BY_NATIONALITY: Record<Nationality, string> = {
|
||||
Ethiopian: "912345678",
|
||||
Djiboutian: "77123456",
|
||||
Other: "14155552671",
|
||||
};
|
||||
|
||||
const PASSPORT_COUNTRY: Record<Nationality, string> = {
|
||||
Ethiopian: "",
|
||||
Djiboutian: "Djibouti",
|
||||
Other: "Canada",
|
||||
};
|
||||
|
||||
/** Build a default passenger list: adults first, then children (matches form index → category). */
|
||||
export function makePassengers(adults: number, children: number, nationality: Nationality): PaxSpec[] {
|
||||
const list: PaxSpec[] = [];
|
||||
for (let i = 0; i < adults; i++) {
|
||||
list.push({
|
||||
category: "ADULT",
|
||||
name: `Adult ${i + 1}`,
|
||||
gender: i % 2 === 0 ? "Male" : "Female",
|
||||
dob: { d: 15, m: 6, y: 1990 },
|
||||
phone: PHONE_BY_NATIONALITY[nationality],
|
||||
passport:
|
||||
nationality === "Ethiopian"
|
||||
? undefined
|
||||
: { number: "P1234567", country: PASSPORT_COUNTRY[nationality], issue: "2020-01-01", expiry: "2032-01-01" },
|
||||
});
|
||||
}
|
||||
for (let j = 0; j < children; j++) {
|
||||
// Age ~3 as of 2026 → strictly under 5, so isChild() and the free-child policy apply.
|
||||
list.push({ category: "CHILD", name: `Child ${j + 1}`, gender: "Female", dob: { d: 10, m: 3, y: 2023 } });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Passenger card locator (scoped by the "Passenger N" heading; N is 1-based). */
|
||||
function card(page: Page, i: number): Locator {
|
||||
return page.locator("div.card").filter({ hasText: new RegExp(`Passenger ${i + 1}\\b`) });
|
||||
}
|
||||
|
||||
/** Open the DOB modal for a passenger card, enter the date manually, and confirm. */
|
||||
async function fillDob(page: Page, c: Locator, dob: { d: number; m: number; y: number }) {
|
||||
await c.getByRole("button", { name: /select date of birth/i }).click();
|
||||
await page.getByRole("button", { name: /enter manually/i }).click();
|
||||
await page.getByPlaceholder("DD").fill(String(dob.d));
|
||||
await page.getByPlaceholder("MM").fill(String(dob.m));
|
||||
await page.getByPlaceholder("YYYY").fill(String(dob.y));
|
||||
await page.getByRole("button", { name: /^confirm/i }).click();
|
||||
}
|
||||
|
||||
/** Fill one passenger card (adult or child), revealing the manual form if it's gated. */
|
||||
async function fillPassenger(page: Page, i: number, spec: PaxSpec) {
|
||||
const c = card(page, i);
|
||||
const nameInput = page.locator(`input[name="passengers.${i}.name"]`);
|
||||
// Adults may sit behind a Fayda gate that must be toggled open. Wait for whichever appears first —
|
||||
// the name field (already expanded) or the reveal button — so we never toggle an open form closed.
|
||||
const reveal = c.getByRole("button", { name: /enter details manually|skip for now/i }).first();
|
||||
await Promise.race([
|
||||
nameInput.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}),
|
||||
reveal.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}),
|
||||
]);
|
||||
if (!(await nameInput.isVisible().catch(() => false)) && (await reveal.isVisible().catch(() => false))) {
|
||||
await reveal.click();
|
||||
}
|
||||
await nameInput.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
await nameInput.fill(spec.name);
|
||||
await page.locator(`select[name="passengers.${i}.gender"]`).selectOption(spec.gender);
|
||||
if (spec.category === "ADULT" && spec.phone) {
|
||||
await c.locator('input[type="tel"]').first().fill(spec.phone);
|
||||
}
|
||||
if (spec.passport) {
|
||||
await page.locator(`input[name="passengers.${i}.passportNumber"]`).fill(spec.passport.number);
|
||||
await page.locator(`select[name="passengers.${i}.passportCountry"]`).selectOption(spec.passport.country);
|
||||
await page.locator(`input[name="passengers.${i}.passportIssueDate"]`).fill(spec.passport.issue);
|
||||
await page.locator(`input[name="passengers.${i}.passportExpiryDate"]`).fill(spec.passport.expiry);
|
||||
}
|
||||
await fillDob(page, c, spec.dob);
|
||||
}
|
||||
|
||||
/** Select a coach + continue, once for a one-way leg or twice for a round trip. */
|
||||
async function selectResultsAndContinue(page: Page, roundTrip: boolean) {
|
||||
const pickCoach = async (scope: Locator | Page) => {
|
||||
await (scope as Page).getByTestId("result-select-btn").first().click();
|
||||
await page.getByTestId("coach-option").first().click();
|
||||
await page.getByTestId("continue-passenger-details").first().click();
|
||||
};
|
||||
await pickCoach(page); // outbound (advances to the inbound step for a round trip)
|
||||
if (roundTrip) {
|
||||
// The inbound step re-renders result cards; scope to the inbound section if present.
|
||||
const inbound = page.locator("#inbound-section");
|
||||
const scope = (await inbound.count()) > 0 ? inbound : page;
|
||||
await scope.getByTestId("result-select-btn").first().click();
|
||||
await page.getByTestId("coach-option").first().click();
|
||||
await page.getByTestId("continue-passenger-details").first().click();
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-assign seats (fills all passengers at once and auto-continues). Twice for a round trip. */
|
||||
async function assignSeatsAndContinue(page: Page, roundTrip: boolean) {
|
||||
const autoAssign = () => page.getByRole("button", { name: /auto assign seats/i }).first().click();
|
||||
await autoAssign(); // outbound
|
||||
if (roundTrip) {
|
||||
// After the outbound hold, the page switches to the return-seat map.
|
||||
await page.getByRole("heading", { name: /return seats/i }).waitFor({ timeout: 20_000 });
|
||||
await autoAssign(); // inbound
|
||||
}
|
||||
await page.waitForURL(/\/booking\/review/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the real portal booking flow end to end for an arbitrary passenger mix, nationality,
|
||||
* trip type, promo, and payment method. Captures the price at each hop for DB cross-checks.
|
||||
* Runs as a guest when the page context has no auth token (the `guest` Playwright project).
|
||||
*/
|
||||
export async function bookTrip(page: Page, opts: TripOptions = {}): Promise<BookingResult> {
|
||||
const nationality = opts.nationality ?? "Ethiopian";
|
||||
const tripType = opts.tripType ?? "ONE_WAY";
|
||||
const roundTrip = tripType === "ROUND_TRIP";
|
||||
const passengers =
|
||||
opts.passengers ?? makePassengers(opts.adults ?? 1, opts.children ?? 0, nationality);
|
||||
const adults = passengers.filter((p) => p.category === "ADULT").length;
|
||||
const children = passengers.filter((p) => p.category === "CHILD").length;
|
||||
|
||||
// Optional: forge the POST /bookings body before it leaves the browser.
|
||||
if (opts.mutateBookingBody) {
|
||||
await page.route(/\/bookings(\/guest)?(\?|$)/, async (route: Route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
const body = route.request().postDataJSON();
|
||||
await route.continue({ postData: JSON.stringify(opts.mutateBookingBody!(body)) });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search / results ────────────────────────────────────────────────────────
|
||||
const searchDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
const base = resultsUrl({ nationality, adults, children, tripType });
|
||||
await page.goto(opts.promoCode ? `${base}&promoCode=${encodeURIComponent(opts.promoCode)}` : base);
|
||||
const search = await searchDone;
|
||||
const out = (await search.json())?.data?.outbound?.[0];
|
||||
const cardCls = out?.faresByClass?.[0];
|
||||
const cardBaseFareMinor = cardCls?.baseFareMinor;
|
||||
const cardDisplayMinor = cardCls?.displayAmountMinor ?? cardBaseFareMinor;
|
||||
const displayCurrency = out?.displayCurrency ?? CURRENCY_BY_NATIONALITY[nationality];
|
||||
expect(cardBaseFareMinor).toBeGreaterThan(0);
|
||||
|
||||
await selectResultsAndContinue(page, roundTrip);
|
||||
// Both authenticated users and guests may pass through the auth-check interstitial: authenticated
|
||||
// users auto-forward to passengers, guests must click "Continue as guest". Handle whichever wins.
|
||||
await page.waitForURL(/\/booking\/(passengers|auth-check)/, { timeout: 30_000 });
|
||||
if (/\/booking\/auth-check/.test(page.url())) {
|
||||
await Promise.race([
|
||||
page.waitForURL(/\/booking\/passengers/, { timeout: 15_000 }).catch(() => {}),
|
||||
page
|
||||
.getByRole("button", { name: /continue as guest/i })
|
||||
.click({ timeout: 15_000 })
|
||||
.catch(() => {}),
|
||||
]);
|
||||
await page.waitForURL(/\/booking\/passengers/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
// ── Passenger form ────────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < passengers.length; i++) await fillPassenger(page, i, passengers[i]);
|
||||
await page.getByRole("button", { name: /continue to seat selection/i }).click();
|
||||
await page.waitForURL(/\/booking\/seats/, { timeout: 30_000 });
|
||||
|
||||
// ── Seats: auto-assign → hold → review ────────────────────────────────────────
|
||||
const fbDone = page
|
||||
.waitForResponse((r) => r.url().includes("/search/fare-breakdown"), { timeout: 25_000 })
|
||||
.catch(() => null);
|
||||
await assignSeatsAndContinue(page, roundTrip);
|
||||
const fbRes = await fbDone;
|
||||
const fbJson = fbRes ? await fbRes.json() : null;
|
||||
const fareBreakdown = fbJson?.data ?? fbJson;
|
||||
|
||||
// ── Review: confirm → POST /bookings(/guest) ──────────────────────────────────
|
||||
const bookingDone = page.waitForResponse(
|
||||
(r) => /\/bookings(\/guest)?(\?|$)/.test(r.url()) && r.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: /^confirm/i }).first().click();
|
||||
const bookingRes = await bookingDone;
|
||||
const bookingStatus = bookingRes.status();
|
||||
const guest = bookingRes.url().includes("/bookings/guest");
|
||||
const reviewedTotalMinor = bookingRes.request().postDataJSON()?.reviewedTotalMinor;
|
||||
|
||||
// Expected-rejection path: the server refused the booking (e.g. a forged total). Return early
|
||||
// with the status so the caller can assert the refusal; there is no booking to drive to payment.
|
||||
if (opts.tolerateBookingError && !bookingRes.ok()) {
|
||||
return {
|
||||
cardDisplayMinor,
|
||||
cardBaseFareMinor,
|
||||
displayCurrency,
|
||||
reviewedTotalMinor,
|
||||
bookingStatus,
|
||||
bookingId: "",
|
||||
guest,
|
||||
initiateStatus: 0,
|
||||
confirmed: false,
|
||||
fareBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
const bookingData = (await bookingRes.json())?.data ?? {};
|
||||
const bookingId = bookingData.id ?? bookingData.bookingId;
|
||||
expect(bookingId).toBeTruthy();
|
||||
await page.waitForURL(/\/booking\/(payment|confirmation)/, { timeout: 30_000 });
|
||||
|
||||
const result: BookingResult = {
|
||||
cardDisplayMinor,
|
||||
cardBaseFareMinor,
|
||||
displayCurrency,
|
||||
reviewedTotalMinor,
|
||||
bookingStatus,
|
||||
bookingId,
|
||||
guest,
|
||||
initiateStatus: 0,
|
||||
confirmed: false,
|
||||
fareBreakdown,
|
||||
};
|
||||
|
||||
// A zero-total booking skips payment and lands straight on confirmation.
|
||||
if (/\/booking\/confirmation/.test(page.url())) {
|
||||
result.confirmed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Payment ─────────────────────────────────────────────────────────────────
|
||||
const method = opts.paymentMethod ?? "WALLET";
|
||||
|
||||
if (method === "WALLET") {
|
||||
// WALLET settles fully server-side, synchronously → straight to /booking/confirmation.
|
||||
const initiateDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/payments/initiate") && r.request().method() === "POST",
|
||||
);
|
||||
await page.getByTestId("pay-method-WALLET").first().click();
|
||||
await page.getByRole("button", { name: /^pay\b/i }).first().click();
|
||||
result.initiateStatus = (await initiateDone).status();
|
||||
result.confirmed = await page
|
||||
.waitForURL(/\/booking\/confirmation/, { timeout: 25_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Gateway (TELEBIRR): the real provider is unreachable in the test env (initiate 502s), so we do
|
||||
// what the matrix prescribes — inject settlement. The booking is already created through the real
|
||||
// browser flow and sits in PENDING_PAYMENT; we forge the payment.succeeded event to the internal
|
||||
// mark-paid endpoint (ungated when SERVICE_AUTH_TOKEN is unset), then let the confirmation page's
|
||||
// poll flip to CONFIRMED. `forgeSettlement.amountMinor` lets a test short-pay (settle wrong amount).
|
||||
const amountMinor = opts.forgeSettlement?.amountMinor ?? reviewedTotalMinor;
|
||||
// mark-paid sits behind the global JwtGuard (any valid token passes; ServiceAuthGuard is a no-op
|
||||
// when SERVICE_AUTH_TOKEN is unset). Reuse the logged-in passenger's token from localStorage.
|
||||
const authToken = await page.evaluate(() => localStorage.getItem("auth_token"));
|
||||
const markPaid = await page.request.post(`${API_URL}/internal/payments/mark-paid`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
data: {
|
||||
version: 1,
|
||||
eventId: crypto.randomUUID(), // @IsUUID
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: new Date().toISOString(),
|
||||
service: "PASSENGER",
|
||||
intentId: crypto.randomUUID(), // @IsUUID
|
||||
referenceType: "BOOKING",
|
||||
referenceId: bookingId,
|
||||
merchantOrderId: `e2e-${bookingId}`,
|
||||
provider: "TELEBIRR",
|
||||
amountMinor,
|
||||
currency: "ETB",
|
||||
providerTxnId: `e2e-txn-${bookingId}`,
|
||||
paidAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
result.initiateStatus = markPaid.status();
|
||||
// mark-paid finalizes synchronously; confirm authoritatively via the booking status API (the
|
||||
// confirmation page's DOM depends on the client store, which a direct navigation may not carry).
|
||||
await page.goto("/booking/confirmation");
|
||||
for (let attempt = 0; attempt < 10 && !result.confirmed; attempt++) {
|
||||
const res = await page.request.get(`${API_URL}/bookings/${bookingId}`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
const status = ((await res.json().catch(() => ({})))?.data ?? {})?.status;
|
||||
if (status === "CONFIRMED") result.confirmed = true;
|
||||
else await page.waitForTimeout(500);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Back-compat wrapper: one-way single adult (used by the original UA-1/8/13 specs). */
|
||||
export interface BookingOptions {
|
||||
nationality?: Nationality;
|
||||
promoCode?: string;
|
||||
mutateBookingBody?: (body: any) => any;
|
||||
tolerateBookingError?: boolean;
|
||||
paymentMethod?: "WALLET" | "TELEBIRR";
|
||||
}
|
||||
export async function bookOneAdult(page: Page, opts: BookingOptions = {}) {
|
||||
const r = await bookTrip(page, { ...opts, adults: 1, children: 0, tripType: "ONE_WAY" });
|
||||
// Preserve the original field name used by the existing specs.
|
||||
return { ...r, cardFareMinor: r.cardBaseFareMinor };
|
||||
}
|
||||
92
e2e-ui/fixtures/data.ts
Normal file
92
e2e-ui/fixtures/data.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/** Shared constants mirroring apps/edr-passenger-api/test/fixtures/{seed-core,seed-ui}.ts. */
|
||||
export const STATIONS = {
|
||||
A: "00000000-0000-4000-8000-000000000020", // Alpha / AAA
|
||||
B: "00000000-0000-4000-8000-000000000021", // Bravo / BBB
|
||||
C: "00000000-0000-4000-8000-000000000022", // Charlie / CCC
|
||||
} as const;
|
||||
|
||||
export const SCHEDULE_ID = "00000000-0000-4000-8000-000000000101";
|
||||
export const RETURN_SCHEDULE_ID = "00000000-0000-4000-8000-000000000201";
|
||||
export const SEAT_CLASS_LOCAL = "00000000-0000-4000-8000-000000000010";
|
||||
export const SEAT_CLASS_INTL = "00000000-0000-4000-8000-000000000011";
|
||||
export const COACH_TYPE_ID = "00000000-0000-4000-8000-000000000001";
|
||||
export const ROUTE_ID = "00000000-0000-4000-8000-000000000030";
|
||||
export const PROMO_VALID = "PROMO10";
|
||||
export const PROMO_EXPIRED = "EXPIRED50";
|
||||
|
||||
export const API_URL = process.env.API_URL ?? "http://localhost:4000";
|
||||
|
||||
/** Friendly nationality name → the enum the portal/search expects. */
|
||||
export const NATIONALITY_ENUM = {
|
||||
Ethiopian: "ETHIOPIAN",
|
||||
Djiboutian: "DJIBOUTIAN",
|
||||
Other: "OTHER",
|
||||
} as const;
|
||||
export type Nationality = keyof typeof NATIONALITY_ENUM;
|
||||
|
||||
/** Display currency the search returns per nationality (asserted by the currency specs). */
|
||||
export const CURRENCY_BY_NATIONALITY = {
|
||||
Ethiopian: "ETB",
|
||||
Djiboutian: "DJF",
|
||||
Other: "USD",
|
||||
} as const;
|
||||
|
||||
function tokenFrom(file: string): string {
|
||||
const fs = require("node:fs") as typeof import("node:fs");
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, "storage", file), "utf8"));
|
||||
for (const origin of raw.origins ?? []) {
|
||||
for (const item of origin.localStorage ?? []) {
|
||||
if (item.name === "auth_token") return item.value as string;
|
||||
}
|
||||
}
|
||||
throw new Error(`auth_token not found in ${file} — did global-setup run?`);
|
||||
}
|
||||
|
||||
/** Staff/admin auth token minted by global-setup (backoffice storageState). */
|
||||
export function staffToken(): string {
|
||||
return tokenFrom("staff.json");
|
||||
}
|
||||
|
||||
/** Regular passenger (non-admin) auth token minted by global-setup (portal storageState). */
|
||||
export function passengerToken(): string {
|
||||
return tokenFrom("passenger.json");
|
||||
}
|
||||
|
||||
/** Must match seed-ui.sampleDepartAt(): now + 2 days at 06:00Z. */
|
||||
export function sampleDepartDate(): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + 2);
|
||||
d.setUTCHours(6, 0, 0, 0);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-link to the results page (bypasses the search form, which cannot emit tripType/returnDate).
|
||||
* `nationality` accepts the friendly name ("Ethiopian"|"Djiboutian"|"Other") and is emitted as the
|
||||
* enum the results page expects. For a round trip, pass tripType "ROUND_TRIP" — returnDate defaults
|
||||
* to the same calendar day (the seeded return leg departs 8h after the outbound).
|
||||
*/
|
||||
export function resultsUrl(opts?: {
|
||||
adults?: number;
|
||||
children?: number;
|
||||
nationality?: string;
|
||||
tripType?: "ONE_WAY" | "ROUND_TRIP";
|
||||
returnDate?: string;
|
||||
}) {
|
||||
const nat = opts?.nationality ?? "Ethiopian";
|
||||
const enumNat = (NATIONALITY_ENUM as Record<string, string>)[nat] ?? nat.toUpperCase();
|
||||
const p = new URLSearchParams({
|
||||
origin: STATIONS.A,
|
||||
destination: STATIONS.C,
|
||||
date: sampleDepartDate(),
|
||||
tripType: opts?.tripType ?? "ONE_WAY",
|
||||
adults: String(opts?.adults ?? 1),
|
||||
children: String(opts?.children ?? 0),
|
||||
nationality: enumNat,
|
||||
});
|
||||
if ((opts?.tripType ?? "ONE_WAY") === "ROUND_TRIP") {
|
||||
p.set("returnDate", opts?.returnDate ?? sampleDepartDate());
|
||||
}
|
||||
return `/booking/results?${p.toString()}`;
|
||||
}
|
||||
74
e2e-ui/global-setup.ts
Normal file
74
e2e-ui/global-setup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { seedUi } from "../apps/edr-passenger-api/test/fixtures/seed-ui";
|
||||
import { seedPassengerSession } from "../apps/edr-passenger-api/test/fixtures/seed-passenger-session";
|
||||
|
||||
/**
|
||||
* Playwright global-setup for the UI E2E suite.
|
||||
* 1. Seeds the 5544 test DB with the bookable trip + payment methods + promos (seed-ui.ts).
|
||||
* 2. Mints a passenger IAM session + token → passenger.json storageState (localStorage).
|
||||
* 3. Logs in as the seeded backoffice admin via the REAL /login UI → staff.json storageState.
|
||||
*
|
||||
* Assumes the stack is already running (api :4000, portal :5174, backoffice :5184).
|
||||
*/
|
||||
const STORAGE_DIR = path.join(__dirname, "fixtures", "storage");
|
||||
const API = process.env.API_URL ?? "http://localhost:4000";
|
||||
const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174";
|
||||
const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184";
|
||||
const DB_URL =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger";
|
||||
const STAFF = { email: "passenger.admin@edr.local", password: process.env.DEFAULT_PASSWORD ?? "Test@1234" };
|
||||
|
||||
export default async function globalSetup(_config: FullConfig) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
process.env.DATABASE_URL = DB_URL;
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
console.log("[global-setup] seeding test DB…");
|
||||
await seedUi(prisma);
|
||||
|
||||
console.log("[global-setup] minting passenger session…");
|
||||
const { token } = await seedPassengerSession(prisma);
|
||||
const profileRes = await fetch(`${API}/auth/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!profileRes.ok) throw new Error(`/auth/profile failed: HTTP ${profileRes.status}`);
|
||||
const profile = (await profileRes.json())?.data ?? {};
|
||||
|
||||
const passengerState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: PORTAL,
|
||||
localStorage: [
|
||||
{ name: "auth_token", value: token },
|
||||
{ name: "auth_user", value: JSON.stringify(profile) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(path.join(STORAGE_DIR, "passenger.json"), JSON.stringify(passengerState));
|
||||
console.log("[global-setup] passenger.json written");
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
console.log("[global-setup] minting staff storageState via real login…");
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BACKOFFICE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator('input[type="email"]').fill(STAFF.email);
|
||||
await page.locator('input[type="password"]').fill(STAFF.password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }),
|
||||
page.locator('button[type="submit"]').click(),
|
||||
]);
|
||||
await ctx.storageState({ path: path.join(STORAGE_DIR, "staff.json") });
|
||||
console.log("[global-setup] staff.json written");
|
||||
await browser.close();
|
||||
}
|
||||
94
e2e-ui/playwright.config.ts
Normal file
94
e2e-ui/playwright.config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import * as path from "node:path";
|
||||
|
||||
// Defaults so `pnpm test:e2e:ui` runs standalone. Must match apps/edr-passenger-api/.env.
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://edr:edr_secret@localhost:5544/edr_database?schema=passenger";
|
||||
process.env.JWT_ACCESS_TOKEN_SECRET ??= "test-access-secret-0000000000000000000000";
|
||||
process.env.DEFAULT_PASSWORD ??= "Test@1234";
|
||||
|
||||
/**
|
||||
* Playwright UI E2E for the EDR passenger platform.
|
||||
* Track A (portal booking combinations) + Track B (backoffice config → portal propagation).
|
||||
* See docs/ui-e2e-test-matrix.md. global-setup boots/awaits the stack, seeds the 5544 test DB,
|
||||
* and mints the passenger + staff storageStates.
|
||||
*/
|
||||
const PORTAL = process.env.PORTAL_URL ?? "http://localhost:5174";
|
||||
const BACKOFFICE = process.env.BACKOFFICE_URL ?? "http://localhost:5184";
|
||||
const STORAGE = path.join(__dirname, "fixtures", "storage");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.join(__dirname, "specs"),
|
||||
fullyParallel: false, // shared seeded DB — serialize to keep assertions deterministic
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
globalSetup: path.join(__dirname, "global-setup.ts"),
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: path.join(__dirname, "..", "e2e-ui-report"), open: "never" }],
|
||||
],
|
||||
// Boot the app tier automatically; reuse it if it's already running (dev). Infra (Postgres 5544,
|
||||
// RabbitMQ, migrations, @edr/types build) is handled by e2e-ui/run.sh BEFORE Playwright starts.
|
||||
webServer: [
|
||||
{
|
||||
command: "pnpm --filter @edr/passenger-api dev",
|
||||
url: "http://localhost:4000/stations",
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: true,
|
||||
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/passenger-portal dev",
|
||||
url: PORTAL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: true,
|
||||
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
|
||||
},
|
||||
{
|
||||
command: "pnpm --filter @edr/passenger-backoffice dev",
|
||||
url: `${BACKOFFICE}/login`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: true,
|
||||
env: { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy" },
|
||||
},
|
||||
],
|
||||
use: {
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: 15_000,
|
||||
// SLOWMO=500 bash e2e-ui/run.sh --headed → pause 500ms between each browser action
|
||||
launchOptions: { slowMo: Number(process.env.SLOWMO ?? 0) },
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "portal", // Track A — logged-in passenger
|
||||
testMatch: /specs\/portal\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: PORTAL,
|
||||
storageState: path.join(STORAGE, "passenger.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "guest", // Track A — guest bookings (no auth)
|
||||
testMatch: /specs\/guest\/.*\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: PORTAL },
|
||||
},
|
||||
{
|
||||
name: "backoffice", // Track B — staff/admin
|
||||
testMatch: /specs\/backoffice\/.*\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: BACKOFFICE,
|
||||
storageState: path.join(STORAGE, "staff.json"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propagation", // cross-app: staff writes config via API → passenger portal reads
|
||||
testMatch: /specs\/propagation\/.*\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"], baseURL: PORTAL },
|
||||
},
|
||||
],
|
||||
});
|
||||
40
e2e-ui/run.sh
Executable file
40
e2e-ui/run.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-command UI E2E: infra → build → boot app tier (via Playwright webServer) → seed+auth → run →
|
||||
# open the HTML report. Idempotent; reuses an already-running stack. Any args pass through to
|
||||
# playwright (e.g. `bash e2e-ui/run.sh --project=portal ua1`).
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$HERE/.."
|
||||
API="$ROOT/apps/edr-passenger-api"
|
||||
export GITHUB_PACKAGE_TOKEN="${GITHUB_PACKAGE_TOKEN:-dummy}"
|
||||
|
||||
echo "==> 1/4 Infra: Postgres (5544) + RabbitMQ (5672) + migrations"
|
||||
bash "$ROOT/e2e/prepare.sh"
|
||||
echo " waiting for RabbitMQ healthy"
|
||||
for _ in $(seq 1 30); do
|
||||
s="$(docker inspect --format '{{.State.Health.Status}}' edr-passenger-e2e-rmq 2>/dev/null || echo none)"
|
||||
[ "$s" = "healthy" ] && break; sleep 2
|
||||
done
|
||||
|
||||
echo "==> 2/4 Build shared types (@edr/types dist — nest build needs it)"
|
||||
pnpm --filter @edr/types build >/dev/null
|
||||
|
||||
echo "==> 3/4 Ensure passenger-api dev env (test DB 5544, port 4000, brokers/Fayda off, seeding on)"
|
||||
if [ ! -f "$API/.env" ]; then
|
||||
sed -e 's/^PORT=.*/PORT=4000/' \
|
||||
-e 's/^SEED_EDR_PASSENGER_ORG=.*/SEED_EDR_PASSENGER_ORG=true/' \
|
||||
-e 's/^SEED_PASSENGER_STAFF=.*/SEED_PASSENGER_STAFF=true/' \
|
||||
"$API/.env.test" > "$API/.env"
|
||||
echo " created $API/.env"
|
||||
fi
|
||||
|
||||
echo "==> 4/4 Playwright (boots api/portal/backoffice if not already up, seeds + mints auth, runs)"
|
||||
npx playwright test -c "$HERE/playwright.config.ts" "$@" || TEST_EXIT=$?
|
||||
|
||||
REPORT="$ROOT/e2e-ui-report/index.html"
|
||||
if [ -f "$REPORT" ]; then
|
||||
echo "==> Report: $REPORT"
|
||||
open "$REPORT" 2>/dev/null || true
|
||||
fi
|
||||
exit "${TEST_EXIT:-0}"
|
||||
79
e2e-ui/specs/backoffice/config-validation.spec.ts
Normal file
79
e2e-ui/specs/backoffice/config-validation.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { API_URL, ROUTE_ID, staffToken } from "../../fixtures/data";
|
||||
import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui";
|
||||
|
||||
/**
|
||||
* Track B — server-side validation gaps and the promo field-name mismatch. Each test calls the same
|
||||
* passenger-api endpoints the backoffice forms hit, proving the client-side guards are the ONLY guard
|
||||
* (the API accepts values the forms block) or that a UI/DTO field-name split silently breaks a config.
|
||||
*/
|
||||
function auth() {
|
||||
return { Authorization: `Bearer ${staffToken()}` };
|
||||
}
|
||||
|
||||
test("BC-8 ✅ a promo over 100% is rejected by the API (max validation, M-2)", async ({ request }) => {
|
||||
// percentOff must be bounded 0..100 at the DTO layer (the backoffice form has no such check).
|
||||
const res = await request.post(`${API_URL}/promos`, {
|
||||
headers: auth(),
|
||||
data: { code: `E2E_OVER100_${Date.now()}`, title: "over", percentOff: 200, validUntil: "2030-01-01T00:00:00Z", active: true },
|
||||
});
|
||||
expect(res.status()).toBe(400); // ✅ 200% discount rejected
|
||||
|
||||
// A valid promo (≤100%) still succeeds.
|
||||
const okRes = await request.post(`${API_URL}/promos`, {
|
||||
headers: auth(),
|
||||
data: { code: `E2E_OK_${Date.now()}`, title: "ok", percentOff: 50, validUntil: "2030-01-01T00:00:00Z", active: true },
|
||||
});
|
||||
expect(okRes.ok()).toBeTruthy();
|
||||
const promo = (await okRes.json())?.data ?? {};
|
||||
await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {});
|
||||
});
|
||||
|
||||
test("PB-7 🔴 a promo created with the backoffice UI field names is inert (field-name mismatch)", async ({ request }) => {
|
||||
// The backoffice /promos form sends discountType/discountValue/isActive, but the DTO reads
|
||||
// percentOff/amountOffMinor/active — so the sent discount is dropped and the promo saves at 0.
|
||||
const res = await request.post(`${API_URL}/promos`, {
|
||||
headers: auth(),
|
||||
data: { code: `E2E_UIFIELDS_${Date.now()}`, title: "uifields", discountType: "PERCENTAGE", discountValue: 25, isActive: true, validUntil: "2030-01-01T00:00:00Z" },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const promo = (await res.json())?.data ?? {};
|
||||
expect(promo.discountValue).toBe(0); // 🔴 the 25% the UI "set" was silently dropped
|
||||
await request.delete(`${API_URL}/promos/${promo.id}`, { headers: auth() }).catch(() => {});
|
||||
});
|
||||
|
||||
test("BC-9 ✅ a negative seat-hold duration is rejected by /config (DTO validation, M-3)", async ({ request }) => {
|
||||
// The settings form has min=1 max=60; the API must now enforce the same at the DTO layer.
|
||||
const res = await request.patch(`${API_URL}/config`, {
|
||||
headers: auth(),
|
||||
data: { seat_hold_duration_minutes: "-1" },
|
||||
});
|
||||
expect(res.status()).toBe(400); // ✅ negative duration rejected
|
||||
|
||||
// A sane value in range still succeeds and is stored.
|
||||
const ok = await request.patch(`${API_URL}/config`, { headers: auth(), data: { seat_hold_duration_minutes: "15" } });
|
||||
expect(ok.ok()).toBeTruthy();
|
||||
expect(((await ok.json())?.data ?? {}).seat_hold_duration_minutes).toBe("15");
|
||||
});
|
||||
|
||||
test("BC-10 ✅ a schedule with a past departure is rejected by the API (past-date block, M-4)", async ({ request }) => {
|
||||
// The schedules form only checks arrival > departure — the API must ALSO reject a past departure.
|
||||
const past = new Date("2020-01-02T06:00:00.000Z");
|
||||
const arrive = new Date("2020-01-02T10:00:00.000Z");
|
||||
const res = await request.post(`${API_URL}/schedules`, {
|
||||
headers: auth(),
|
||||
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: past.toISOString(), arrivalAt: arrive.toISOString() },
|
||||
});
|
||||
expect(res.status()).toBe(400); // ✅ past-dated schedule rejected
|
||||
|
||||
// A future schedule (a different day than the seeded one) is still accepted.
|
||||
const dep = new Date(Date.now() + 30 * 864e5); dep.setUTCHours(6, 0, 0, 0);
|
||||
const arr = new Date(dep.getTime() + 4 * 3600e3);
|
||||
const okRes = await request.post(`${API_URL}/schedules`, {
|
||||
headers: auth(),
|
||||
data: { trainId: UI_IDS.train, routeId: ROUTE_ID, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
|
||||
});
|
||||
expect(okRes.ok()).toBeTruthy();
|
||||
const sched = (await okRes.json())?.data ?? {};
|
||||
await request.delete(`${API_URL}/schedules/${sched.id}`, { headers: auth() }).catch(() => {});
|
||||
});
|
||||
19
e2e-ui/specs/backoffice/currencies.smoke.spec.ts
Normal file
19
e2e-ui/specs/backoffice/currencies.smoke.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Backoffice smoke (Track B foundation): the staff storageState authenticates past the middleware
|
||||
* cookie gate, /currencies loads its list from the API, and the add-rate control is reachable.
|
||||
* Proves staff auth (cookie + localStorage + API token) is fully wired.
|
||||
*/
|
||||
test("backoffice: staff can load /currencies and reach the add-rate control", async ({ page }) => {
|
||||
await page.goto("/currencies", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Not bounced to /login (middleware cookie gate passed).
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
|
||||
// The page rendered a currencies view with a seeded currency and an add control.
|
||||
await expect(page.getByText(/ETB|USD|DJF/).first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
page.getByRole("button", { name: /add/i }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
24
e2e-ui/specs/guest/search.smoke.spec.ts
Normal file
24
e2e-ui/specs/guest/search.smoke.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { resultsUrl, SCHEDULE_ID } from "../../fixtures/data";
|
||||
|
||||
/**
|
||||
* Portal smoke (Track A foundation): deep-link to results → POST /search fires → a priced result
|
||||
* card for the seeded trip renders. Proves stack + seed + search + currency formatting are wired.
|
||||
*/
|
||||
test("portal: seeded trip appears in search results with a price", async ({ page }) => {
|
||||
const searchResponse = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
|
||||
await page.goto(resultsUrl());
|
||||
|
||||
const res = await searchResponse;
|
||||
expect([200, 201]).toContain(res.status());
|
||||
const body = await res.json();
|
||||
const outbound = body?.data?.outbound ?? [];
|
||||
expect(outbound.some((t: any) => t.scheduleId === SCHEDULE_ID)).toBe(true);
|
||||
|
||||
// The seeded train + a formatted ETB price render in the DOM.
|
||||
await expect(page.getByText("UI Test Express").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/ETB\s*[\d,]+/).first()).toBeVisible();
|
||||
});
|
||||
37
e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts
Normal file
37
e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-14 ✅ — a GUEST (unauthenticated) booking with forged per-passenger seat fares (ISSUES C-1),
|
||||
* guarded. We intercept POST /bookings/guest and rewrite every seatFareMinor (and reviewedTotalMinor)
|
||||
* to 0. The server must recompute the authoritative fare and REJECT the underpayment with a 4xx —
|
||||
* no free ride, nothing persisted.
|
||||
*/
|
||||
test("UA-14: server rejects a guest booking with forged seatFareMinor=0 (C-1)", async ({ page }) => {
|
||||
const r = await bookTrip(page, {
|
||||
paymentMethod: "WALLET",
|
||||
tolerateBookingError: true,
|
||||
mutateBookingBody: (body) => ({
|
||||
...body,
|
||||
reviewedTotalMinor: 0,
|
||||
passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 0 })),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(r.guest).toBe(true); // proves the /bookings/guest path was used
|
||||
expect(r.cardBaseFareMinor).toBeGreaterThan(1000);
|
||||
|
||||
// The server must REFUSE the forged 0-fare booking with a 4xx…
|
||||
expect(r.bookingStatus).toBeGreaterThanOrEqual(400);
|
||||
expect(r.bookingStatus).toBeLessThan(500);
|
||||
// …return no booking id and persist no free (0-minor) booking.
|
||||
expect(r.bookingId).toBeFalsy();
|
||||
const forged = await prisma.booking.findFirst({ where: { totalMinor: 0 } });
|
||||
expect(forged).toBeNull();
|
||||
});
|
||||
31
e2e-ui/specs/portal/ua1.spec.ts
Normal file
31
e2e-ui/specs/portal/ua1.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookOneAdult } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-1 — one-way, 1 adult, ETB, WALLET. The full real-browser booking flow, asserting the money
|
||||
* chain: card fare > 0, and reviewedTotalMinor == Booking.totalMinor == displayTotalMinor ==
|
||||
* PaymentIntent.amountMinor == wallet DEBIT, booking CONFIRMED.
|
||||
*/
|
||||
test("UA-1: one-way WALLET booking, price cross-check holds end to end", async ({ page }) => {
|
||||
const r = await bookOneAdult(page, { nationality: "Ethiopian", paymentMethod: "WALLET" });
|
||||
expect(r.confirmed).toBe(true);
|
||||
expect([200, 201]).toContain(r.initiateStatus);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } });
|
||||
const debit = await prisma.walletLedgerEntry.findFirst({
|
||||
where: { relatedBookingId: r.bookingId, type: "DEBIT" },
|
||||
});
|
||||
|
||||
expect(booking.totalMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(intent.amountMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(debit?.amountMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
});
|
||||
25
e2e-ui/specs/portal/ua11-expired-promo.spec.ts
Normal file
25
e2e-ui/specs/portal/ua11-expired-promo.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
import { PROMO_EXPIRED } from "../../fixtures/data";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-11 — one-way, ETB, an EXPIRED promo injected via ?promoCode=. The expired code must not discount
|
||||
* anything: the fare breakdown reports no discount and the booked total is the full fare (consistent
|
||||
* with the UA-8 promo-drop behaviour, but here the promo is correctly rejected as expired).
|
||||
*/
|
||||
test("UA-11: an expired promo code is ignored — full fare is booked", async ({ page }) => {
|
||||
const r = await bookTrip(page, { paymentMethod: "WALLET", promoCode: PROMO_EXPIRED });
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
// No discount from the expired code.
|
||||
if (r.fareBreakdown) expect(r.fareBreakdown.discountMinor ?? 0).toBe(0);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // full fare, no discount applied
|
||||
});
|
||||
40
e2e-ui/specs/portal/ua13-forged-total.spec.ts
Normal file
40
e2e-ui/specs/portal/ua13-forged-total.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookOneAdult } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-13 ✅ — client-forged booking total (matrix A1 / ISSUES C-1), guarded through the REAL browser.
|
||||
* We intercept the outgoing POST /bookings and rewrite reviewedTotalMinor (and every per-seat
|
||||
* seatFareMinor) to 1. The server has two trust branches — sum-of-seatFareMinor when all are present,
|
||||
* else reviewedTotalMinor — so the forge targets both. The server must recompute the authoritative
|
||||
* fare and REJECT the mismatched client amount with a 4xx, persisting nothing.
|
||||
*/
|
||||
test("UA-13: server rejects a client-forged reviewedTotalMinor=1 (C-1)", async ({ page }) => {
|
||||
const r = await bookOneAdult(page, {
|
||||
nationality: "Ethiopian",
|
||||
paymentMethod: "WALLET",
|
||||
tolerateBookingError: true,
|
||||
// Forge both the per-seat fares and the reviewed total → 1.
|
||||
mutateBookingBody: (body) => ({
|
||||
...body,
|
||||
reviewedTotalMinor: 1,
|
||||
passengers: (body.passengers ?? []).map((p: any) => ({ ...p, seatFareMinor: 1 })),
|
||||
}),
|
||||
});
|
||||
|
||||
// The real fare the engine computed is far above 1…
|
||||
expect(r.cardFareMinor).toBeGreaterThan(1000);
|
||||
// …the browser forced reviewedTotalMinor=1, and the server must REFUSE it with a 4xx.
|
||||
expect(r.reviewedTotalMinor).toBe(1);
|
||||
expect(r.bookingStatus).toBeGreaterThanOrEqual(400);
|
||||
expect(r.bookingStatus).toBeLessThan(500);
|
||||
// No booking id was returned, and no 1-minor booking was persisted.
|
||||
expect(r.bookingId).toBeFalsy();
|
||||
const forged = await prisma.booking.findFirst({ where: { totalMinor: 1 } });
|
||||
expect(forged).toBeNull();
|
||||
});
|
||||
27
e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts
Normal file
27
e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-15 ✅ — forged gateway SHORT-PAY (ISSUES C-4), guarded. A booking with a real fare in the
|
||||
* thousands is settled by a forged payment.succeeded event carrying amountMinor = 1. The server must
|
||||
* compare the settled amount against what the passenger was quoted (the booking's display total) and
|
||||
* REFUSE to confirm a short payment — the booking stays unconfirmed and no ticket is issued.
|
||||
*/
|
||||
test("UA-15: a short-paid gateway settlement does NOT confirm the booking (C-4)", async ({ page }) => {
|
||||
const r = await bookTrip(page, {
|
||||
paymentMethod: "TELEBIRR",
|
||||
forgeSettlement: { amountMinor: 1 }, // settle for 1 minor against a multi-thousand fare
|
||||
});
|
||||
|
||||
expect(r.cardBaseFareMinor).toBeGreaterThan(1000);
|
||||
expect(r.confirmed).toBe(false); // short-pay must NOT confirm the booking
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.status).not.toBe("CONFIRMED");
|
||||
});
|
||||
27
e2e-ui/specs/portal/ua16-family-mix.spec.ts
Normal file
27
e2e-ui/specs/portal/ua16-family-mix.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-16 — one-way, 2 adults + 3 children under 5, ETB, WALLET (max passenger spread). One free child
|
||||
* per adult → 2 free children, 1 paid. Total = 3 fares (2 adults + 1 paid child); 3 seats booked.
|
||||
* Stresses the free-child reduce + multi-passenger seat assignment through the real browser.
|
||||
*/
|
||||
test("UA-16: 2 adults + 3 children — two children free, one paid", async ({ page }) => {
|
||||
const r = await bookTrip(page, { adults: 2, children: 3, paymentMethod: "WALLET" });
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const fare = r.cardBaseFareMinor;
|
||||
expect(r.reviewedTotalMinor).toBe(fare * 3);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.totalMinor).toBe(fare * 3);
|
||||
|
||||
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
|
||||
expect(seats.length).toBe(3);
|
||||
});
|
||||
31
e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts
Normal file
31
e2e-ui/specs/portal/ua1b-usd-divergence.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { resultsUrl } from "../../fixtures/data";
|
||||
|
||||
/**
|
||||
* UA-1b ✅ — for a non-Ethiopian (USD) search the results card shows the USD-converted
|
||||
* `displayAmountMinor`, and the internal `baseFareMinor` is the ETB source it was converted from
|
||||
* (exactly the USD→ETB rate apart — a correct conversion, not a mislabel). The passenger sees and
|
||||
* carries forward the USD value; the ETB basis is stored honestly on the booking as `currency: ETB`
|
||||
* (proven end-to-end by UA-2). This pins the display layer so a regression that shows the raw ETB
|
||||
* number, or drops the conversion, is caught.
|
||||
*/
|
||||
test("UA-1b: USD card shows the USD fare, correctly converted from the internal ETB base", async ({ page }) => {
|
||||
const searchDone = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
await page.goto(resultsUrl({ nationality: "Other" }));
|
||||
const out = (await (await searchDone).json())?.data?.outbound?.[0];
|
||||
const cls = out?.faresByClass?.[0];
|
||||
|
||||
expect(out.displayCurrency).toBe("USD");
|
||||
// The USD display fare is the ETB base converted at the USD→ETB rate (100×), not a parity mislabel.
|
||||
expect(cls.displayAmountMinor).toBeGreaterThan(0);
|
||||
expect(cls.displayAmountMinor).toBeLessThan(cls.baseFareMinor);
|
||||
expect(cls.baseFareMinor).toBe(cls.displayAmountMinor * 100);
|
||||
|
||||
// The DOM shows the USD value the passenger pays (formatFare divides by 100, 2dp) — e.g. "USD 12.50".
|
||||
const usdMajor = (cls.displayAmountMinor / 100).toFixed(2);
|
||||
await expect(page.getByText(new RegExp(`USD\\s*${usdMajor.replace(".", "\\.")}`)).first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
37
e2e-ui/specs/portal/ua2-usd-booking.spec.ts
Normal file
37
e2e-ui/specs/portal/ua2-usd-booking.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-2 ✅ — full one-way USD booking (Other nationality, INTERNATIONAL class, WALLET). The money chain
|
||||
* is now COHERENT: the passenger sees and agrees to a USD amount (displayCurrency/displayTotalMinor),
|
||||
* while the stored charge basis is honestly labeled ETB (currency/totalMinor). The two are the same
|
||||
* fare at the USD→ETB rate — no longer a mislabeled 100× divergence.
|
||||
*/
|
||||
test("UA-2: USD booking — passenger amount in USD, charge basis stored coherently in ETB", async ({ page }) => {
|
||||
const r = await bookTrip(page, { nationality: "Other", paymentMethod: "WALLET" });
|
||||
expect(r.displayCurrency).toBe("USD");
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } });
|
||||
|
||||
// Passenger-facing: the USD amount they saw and agreed to (what the browser reviewed).
|
||||
expect(booking.displayCurrency).toBe("USD");
|
||||
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
|
||||
|
||||
// Stored charge basis: ETB, coherently labeled (no more USD mislabel).
|
||||
expect(booking.currency).toBe("ETB");
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor); // the ETB fare
|
||||
expect(booking.totalMinor).toBe(r.reviewedTotalMinor * 100); // ETB == USD display × rate
|
||||
|
||||
// The charge/intent moves the ETB amount; booking is confirmed.
|
||||
expect(intent.amountMinor).toBe(booking.totalMinor);
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
});
|
||||
45
e2e-ui/specs/portal/ua3-djf.spec.ts
Normal file
45
e2e-ui/specs/portal/ua3-djf.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-3w ✅ — Djiboutian/DJF, WALLET. The money chain is now COHERENT: the passenger sees and agrees
|
||||
* to a DJF amount (displayCurrency/displayTotalMinor), while the stored charge basis is honestly
|
||||
* labeled ETB (currency/totalMinor). Same fare, two correctly-labeled currencies — no mislabel.
|
||||
*/
|
||||
test("UA-3w: DJF WALLET booking — passenger amount in DJF, charge basis stored coherently in ETB", async ({ page }) => {
|
||||
const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "WALLET" });
|
||||
expect(r.displayCurrency).toBe("DJF");
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
// Passenger-facing: the DJF amount they saw and agreed to.
|
||||
expect(booking.displayCurrency).toBe("DJF");
|
||||
expect(booking.displayTotalMinor).toBe(r.reviewedTotalMinor);
|
||||
expect(r.reviewedTotalMinor).toBe(r.cardDisplayMinor);
|
||||
// Stored charge basis: ETB, coherently labeled (no more DJF mislabel).
|
||||
expect(booking.currency).toBe("ETB");
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-3 — Djiboutian/DJF paid via a forged gateway settlement. The real telebirr gateway is
|
||||
* unreachable in the test env, so (per the matrix's settlement-injection plan) the booking is created
|
||||
* through the real browser flow and settled by forging the payment.succeeded event. Proves the DJF
|
||||
* booking reaches a CONFIRMED, ticketed state through the gateway (non-WALLET) path.
|
||||
*/
|
||||
test("UA-3: DJF booking settles through a forged gateway payment", async ({ page }) => {
|
||||
const r = await bookTrip(page, { nationality: "Djiboutian", paymentMethod: "TELEBIRR" });
|
||||
expect(r.displayCurrency).toBe("DJF");
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.displayCurrency).toBe("DJF");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
});
|
||||
27
e2e-ui/specs/portal/ua4-child-free.spec.ts
Normal file
27
e2e-ui/specs/portal/ua4-child-free.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-4 — one-way, 1 adult + 1 child under 5, ETB, WALLET. The "first child per adult" policy makes
|
||||
* the child free: the booked total is exactly one adult fare and the free child is not seated.
|
||||
*/
|
||||
test("UA-4: first child under 5 travels free, total = one adult fare", async ({ page }) => {
|
||||
const r = await bookTrip(page, { adults: 1, children: 1, paymentMethod: "WALLET" });
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
// The child is free → the browser sent one adult fare as the reviewed total.
|
||||
expect(r.reviewedTotalMinor).toBe(r.cardBaseFareMinor);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor);
|
||||
|
||||
// The free first-child is filtered out of the booked passengers → only the adult is seated.
|
||||
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
|
||||
expect(seats.length).toBe(1);
|
||||
});
|
||||
28
e2e-ui/specs/portal/ua5-second-child-paid.spec.ts
Normal file
28
e2e-ui/specs/portal/ua5-second-child-paid.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-5 — one-way, 1 adult + 2 children under 5, ETB, WALLET. One free child per adult: the first
|
||||
* child is free, the second is charged a full adult fare. Total = 2 fares; 2 passengers are seated.
|
||||
*/
|
||||
test("UA-5: with 1 adult + 2 children, the second child pays full fare", async ({ page }) => {
|
||||
const r = await bookTrip(page, { adults: 1, children: 2, paymentMethod: "WALLET" });
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const fare = r.cardBaseFareMinor;
|
||||
// adult (paid) + first child (free) + second child (paid) = 2 fares.
|
||||
expect(r.reviewedTotalMinor).toBe(fare * 2);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.totalMinor).toBe(fare * 2);
|
||||
|
||||
// Only the free first-child is dropped → adult + paid second child are seated.
|
||||
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
|
||||
expect(seats.length).toBe(2);
|
||||
});
|
||||
35
e2e-ui/specs/portal/ua6-round-trip.spec.ts
Normal file
35
e2e-ui/specs/portal/ua6-round-trip.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookTrip } from "../../fixtures/booking-flow";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-6 ✅ — round-trip books BOTH legs. The return leg (C→A) traverses the seeded route high→low; the
|
||||
* fare engine now prices the reverse direction by absolute distance (previously it threw "origin must
|
||||
* come before destination" and dropped every class, leaving the inbound leg with seats but no priced
|
||||
* coach — unbookable). The full two-leg wizard now completes: outbound + return seat, and a total of
|
||||
* 2× the one-way fare.
|
||||
*/
|
||||
test("UA-6: round-trip books both legs — return leg priced, one seat per leg, total = 2× one-way fare", async ({
|
||||
page,
|
||||
}) => {
|
||||
const r = await bookTrip(page, { tripType: "ROUND_TRIP", paymentMethod: "WALLET" });
|
||||
expect(r.confirmed).toBe(true);
|
||||
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.bookingType).toBe("ROUND_TRIP");
|
||||
expect(booking.status).toBe("CONFIRMED");
|
||||
|
||||
// One seat per leg (leg 1 outbound + leg 2 return) for a single passenger.
|
||||
const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } });
|
||||
expect(seats.length).toBe(2);
|
||||
expect(new Set(seats.map((s) => s.leg)).size).toBe(2);
|
||||
|
||||
// Both legs cover the same A↔C distance, so the round-trip total is 2× the one-way base fare (ETB).
|
||||
expect(r.cardBaseFareMinor).toBeGreaterThan(0);
|
||||
expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 2);
|
||||
});
|
||||
17
e2e-ui/specs/portal/ua7-berth.spec.ts
Normal file
17
e2e-ui/specs/portal/ua7-berth.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* UA-7 — round trip, berth/bed class, INTERNATIONAL/USD. DEFERRED (documented, not silently omitted).
|
||||
*
|
||||
* A berth booking needs a bed coach type whose seat classes carry a bedPosition the fare engine can
|
||||
* price. The current seed has only regular (bedPosition=null) classes, and UA-6 already shows the
|
||||
* reverse-leg (round-trip) pricing returns empty coach types on this route. Enabling UA-7 requires
|
||||
* two backend/seed prerequisites that are out of scope here:
|
||||
* 1. A bed CoachType + LOCAL/INTL SeatClasses with bedPosition IN (UPPER,MIDDLE,LOWER) + a bed
|
||||
* Coach with lowercase-bedPosition Seats (matrix §5.4), priced by the fare engine.
|
||||
* 2. Reverse-direction (return-leg) fare resolution, currently unsupported (see UA-6).
|
||||
*
|
||||
* Once both exist, drive: bookTrip with a bed seat class + tripType ROUND_TRIP, asserting the berth
|
||||
* surcharge is applied consistently on both legs.
|
||||
*/
|
||||
test.skip("UA-7: round-trip berth booking (needs bed coach-type seed + reverse-leg pricing)", () => {});
|
||||
37
e2e-ui/specs/portal/ua8-promo-drop.spec.ts
Normal file
37
e2e-ui/specs/portal/ua8-promo-drop.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bookOneAdult } from "../../fixtures/booking-flow";
|
||||
import { PROMO_VALID } from "../../fixtures/data";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
test.afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
/**
|
||||
* UA-8 ✅ — a VALID promo is applied server-side even though the browser drops it (H-13). The portal
|
||||
* still sums UNDISCOUNTED per-passenger fares into reviewedTotalMinor, but the server recomputes the
|
||||
* authoritative fare (promo included, via the promoCode it forwards) and books the DISCOUNTED total —
|
||||
* so the customer is charged the promo price, not full price.
|
||||
*/
|
||||
test("UA-8: valid promo is applied server-side to the booked total (H-13)", async ({ page }) => {
|
||||
const r = await bookOneAdult(page, {
|
||||
nationality: "Ethiopian",
|
||||
paymentMethod: "WALLET",
|
||||
promoCode: PROMO_VALID,
|
||||
});
|
||||
|
||||
const fb = r.fareBreakdown;
|
||||
expect(fb).toBeTruthy();
|
||||
|
||||
// The breakdown recognized the promo and computed a discount…
|
||||
expect(fb.discountMinor).toBeGreaterThan(0);
|
||||
expect(fb.totalMinor).toBeLessThan(fb.subtotalMinor);
|
||||
|
||||
// The browser still sends the UNDISCOUNTED subtotal (the frontend drops the promo)…
|
||||
expect(r.reviewedTotalMinor).toBe(fb.subtotalMinor);
|
||||
// …but the SERVER now applies the promo: the booking is stored at the discounted total.
|
||||
const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } });
|
||||
expect(booking.totalMinor).toBeLessThan(fb.subtotalMinor); // ✅ discount honored
|
||||
expect(booking.totalMinor).toBe(fb.subtotalMinor - fb.discountMinor);
|
||||
});
|
||||
200
e2e-ui/specs/propagation/pb-config-propagation.spec.ts
Normal file
200
e2e-ui/specs/propagation/pb-config-propagation.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { API_URL, SEAT_CLASS_LOCAL, STATIONS, resultsUrl, sampleDepartDate, staffToken, passengerToken } from "../../fixtures/data";
|
||||
|
||||
/**
|
||||
* Track B — backoffice config → portal propagation. A staff user changes config via the same API the
|
||||
* backoffice calls; the passenger portal is then observed. Each test restores what it changed so the
|
||||
* shared seeded DB stays consistent for other specs.
|
||||
*/
|
||||
|
||||
/** The "starting from" fare the portal shows for the seeded ETB trip (captured from POST /search). */
|
||||
async function portalCardFareMinor(page: Page): Promise<number> {
|
||||
const done = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
await page.goto(resultsUrl(), { waitUntil: "domcontentloaded" });
|
||||
const body = await (await done).json();
|
||||
return body?.data?.outbound?.[0]?.faresByClass?.[0]?.baseFareMinor;
|
||||
}
|
||||
|
||||
/** The USD display fare the portal shows for a non-Ethiopian (Other) search. */
|
||||
async function portalUsdFare(page: Page): Promise<number> {
|
||||
const done = page.waitForResponse(
|
||||
(r) => r.url().includes("/search") && r.request().method() === "POST",
|
||||
);
|
||||
await page.goto(resultsUrl({ nationality: "Other" }), { waitUntil: "domcontentloaded" });
|
||||
const body = await (await done).json();
|
||||
return body?.data?.outbound?.[0]?.faresByClass?.[0]?.displayAmountMinor;
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return { Authorization: `Bearer ${staffToken()}` };
|
||||
}
|
||||
|
||||
/** Find a CurrencyExchangeRate row id by its currency pair. */
|
||||
async function rateId(request: APIRequestContext, from: string, to: string): Promise<string> {
|
||||
const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? [];
|
||||
const row = rows.find((r: any) => r.fromCurrency === from && r.toCurrency === to);
|
||||
if (!row) throw new Error(`no ${from}->${to} currency rate`);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
test("PB-2: a backoffice seat-class base-price change propagates LIVE to portal search", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const before = await portalCardFareMinor(page);
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
// Staff doubles the base price via the API the backoffice tariff-rates form uses.
|
||||
const patched = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
|
||||
headers: authHeader(),
|
||||
data: { basePrice: 600 }, // seed-core seeds 300
|
||||
});
|
||||
expect(patched.ok()).toBeTruthy();
|
||||
|
||||
try {
|
||||
const after = await portalCardFareMinor(page);
|
||||
// No server-side config cache → the new price shows on the very next search.
|
||||
expect(after).toBe(before * 2);
|
||||
} finally {
|
||||
await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
|
||||
headers: authHeader(),
|
||||
data: { basePrice: 300 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("BC-11 ✅ a non-admin PASSENGER is forbidden from rewriting exchange rates (C-8)", async ({
|
||||
request,
|
||||
}) => {
|
||||
// A global JwtGuard (SharedAuthModule) means anonymous requests get 401 — so this is NOT an
|
||||
// unauthenticated hole. The PUT/PATCH handlers must ALSO carry @PassengerAdmin (as DELETE does) so
|
||||
// a regular authenticated passenger cannot rewrite FX rates.
|
||||
const anon = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
|
||||
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999 },
|
||||
});
|
||||
expect(anon.status()).toBe(401); // authentication IS required
|
||||
|
||||
const asPassenger = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
|
||||
headers: { Authorization: `Bearer ${passengerToken()}` },
|
||||
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 999, source: "E2E" },
|
||||
});
|
||||
expect(asPassenger.status()).toBe(403); // ✅ a regular passenger is forbidden (admin-only)
|
||||
|
||||
// The PATCH-by-id handler must be equally protected.
|
||||
const patchAsPassenger = await request.patch(`${API_URL}/fare-engine/exchange-rates/${crypto.randomUUID()}`, {
|
||||
headers: { Authorization: `Bearer ${passengerToken()}` },
|
||||
data: { rate: 999 },
|
||||
});
|
||||
expect(patchAsPassenger.status()).toBe(403);
|
||||
|
||||
// A staff admin can still write (proves the endpoint isn't simply broken).
|
||||
const asStaff = await request.put(`${API_URL}/fare-engine/exchange-rates`, {
|
||||
headers: { Authorization: `Bearer ${staffToken()}` },
|
||||
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100, source: "E2E" },
|
||||
});
|
||||
expect(asStaff.ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("BC-7 ✅ negative seat-class base price is rejected by the live API (M-1)", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
|
||||
headers: authHeader(),
|
||||
data: { basePrice: -500 }, // the API must now reject this (DTO @Min(0)), like the backoffice form
|
||||
});
|
||||
expect(res.status()).toBe(400); // ✅ negative fare rejected at the DTO layer
|
||||
|
||||
// The stored fare is unchanged — a valid write still succeeds and returns the seeded 300.
|
||||
const restore = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
|
||||
headers: authHeader(),
|
||||
data: { basePrice: 300 },
|
||||
});
|
||||
expect(restore.ok()).toBeTruthy();
|
||||
expect(((await restore.json())?.data ?? {}).baseFareMinor).toBe(300);
|
||||
});
|
||||
|
||||
test("PB-2b: base-price field-name — /seat-classes accepts `basePrice` and it drives the fare", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Documents which field the live seat-class endpoint reads (basePrice → baseFareMinor). If a future
|
||||
// change renames it, this fails loudly (the tariff-rates vs /fleet/classes split, matrix §7 Q9).
|
||||
const res = await request.patch(`${API_URL}/seat-classes/${SEAT_CLASS_LOCAL}`, {
|
||||
headers: authHeader(),
|
||||
data: { basePrice: 300 }, // no-op value, just asserts the field is accepted
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
const updated = json?.data ?? json;
|
||||
expect(updated.baseFareMinor ?? updated.basePrice).toBe(300);
|
||||
});
|
||||
|
||||
test("PB-1: a backoffice FX-rate change propagates LIVE to portal USD pricing", async ({ page, request }) => {
|
||||
const id = await rateId(request, "USD", "ETB");
|
||||
const before = await portalUsdFare(page);
|
||||
expect(before).toBeGreaterThan(0);
|
||||
try {
|
||||
// Doubling the USD→ETB rate doubles the fare-engine's ETB fare and therefore the USD display fare.
|
||||
const patched = await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 200 } });
|
||||
expect(patched.ok()).toBeTruthy();
|
||||
const after = await portalUsdFare(page);
|
||||
expect(after).toBe(before * 2); // search has no cache → the new rate shows immediately
|
||||
} finally {
|
||||
await request.patch(`${API_URL}/currencies/${id}`, { headers: authHeader(), data: { rate: 100 } });
|
||||
}
|
||||
});
|
||||
|
||||
test("PB-4: a station added in the backoffice appears in the portal station list", async ({ request }) => {
|
||||
const code = `E2E${Date.now() % 100000}`;
|
||||
const created = await request.post(`${API_URL}/stations`, {
|
||||
headers: authHeader(),
|
||||
data: { code, name: `E2E Station ${code}`, city: "Testville", countryCode: "ET", sequence: 99, isOperational: true },
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const id = ((await created.json())?.data ?? {}).id;
|
||||
try {
|
||||
const rows = (await (await request.get(`${API_URL}/stations`)).json())?.data ?? [];
|
||||
expect(rows.some((s: any) => s.code === code)).toBe(true); // portal SearchWidget reads this list
|
||||
} finally {
|
||||
await request.delete(`${API_URL}/stations/${id}?cascade=true`, { headers: authHeader() }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("PB-10 ✅ deleting an FX rate makes pricing FAIL CLOSED, not a silent 1.0 fallback (M-5/H-2)", async ({ request }) => {
|
||||
const searchBody = {
|
||||
originStationId: STATIONS.A,
|
||||
destinationStationId: STATIONS.C,
|
||||
date: sampleDepartDate(),
|
||||
adultCount: 1,
|
||||
nationality: "OTHER", // USD — the fare engine needs the USD↔ETB rate to price
|
||||
};
|
||||
const usdFaresByClass = async (): Promise<any[]> => {
|
||||
const res = await request.post(`${API_URL}/search`, { headers: authHeader(), data: searchBody });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json())?.data?.outbound?.[0]?.faresByClass ?? [];
|
||||
};
|
||||
// Control: with the USD→ETB rate present, the USD search returns a real priced fare.
|
||||
const before = await usdFaresByClass();
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
expect(before[0].displayAmountMinor).toBeGreaterThan(0);
|
||||
|
||||
try {
|
||||
// Remove EVERY USD→ETB rate row (an earlier spec may have left a duplicate) so the pair is truly gone.
|
||||
const rows = (await (await request.get(`${API_URL}/currencies`, { headers: authHeader() })).json())?.data ?? [];
|
||||
for (const r of rows.filter((x: any) => x.fromCurrency === "USD" && x.toCurrency === "ETB")) {
|
||||
await request.delete(`${API_URL}/currencies/${r.id}`, { headers: authHeader() });
|
||||
}
|
||||
// With the USD→ETB pair gone, the fare engine must NOT silently substitute rate 1.0 (~100×
|
||||
// underpricing). It fails closed — no priced class is returned for the USD trip, instead of a
|
||||
// bogus parity-priced fare. (A booking attempt would likewise be rejected, not swallowed.)
|
||||
const after = await usdFaresByClass();
|
||||
expect(after.length).toBe(0); // ✅ no silent underpricing — no bogus fare offered
|
||||
} finally {
|
||||
// Restore the pair so later specs price correctly.
|
||||
await request.post(`${API_URL}/currencies`, {
|
||||
headers: authHeader(),
|
||||
data: { fromCurrency: "USD", toCurrency: "ETB", rate: 100 },
|
||||
});
|
||||
}
|
||||
});
|
||||
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`.
|
||||
41
e2e/docker-compose.yml
Normal file
41
e2e/docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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
|
||||
|
||||
# Broker for the passenger-api payment-events consumer (golevelup RabbitMQ). The API blocks boot
|
||||
# until this connects. Pre-creates the `payment` vhost that PAYMENT_RABBITMQ_URL points at.
|
||||
rabbitmq-e2e:
|
||||
image: rabbitmq:3-management
|
||||
container_name: edr-passenger-e2e-rmq
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: edr
|
||||
RABBITMQ_DEFAULT_PASS: edr_secret
|
||||
RABBITMQ_DEFAULT_VHOST: payment
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
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 (5544) + RabbitMQ (5672)"
|
||||
docker compose -f "$HERE/docker-compose.yml" up -d
|
||||
|
||||
echo "==> Waiting for Postgres 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,9 @@
|
||||
"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",
|
||||
"test": "turbo run test",
|
||||
"test:e2e:passenger": "bash e2e/run.sh",
|
||||
"test:e2e:ui": "bash e2e-ui/run.sh",
|
||||
"test:e2e:ui:only": "playwright test -c e2e-ui/playwright.config.ts",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||
@@ -33,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"husky": "^9.1.6",
|
||||
"lint-staged": "^15.2.10",
|
||||
"prettier": "^3.3.3",
|
||||
|
||||
87
pnpm-lock.yaml
generated
87
pnpm-lock.yaml
generated
@@ -18,6 +18,9 @@ importers:
|
||||
'@commitlint/config-conventional':
|
||||
specifier: ^19.5.0
|
||||
version: 19.8.1
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
husky:
|
||||
specifier: ^9.1.6
|
||||
version: 9.1.7
|
||||
@@ -914,6 +917,9 @@ importers:
|
||||
jest:
|
||||
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))
|
||||
jest-html-reporters:
|
||||
specifier: ^3.1.7
|
||||
version: 3.1.7
|
||||
prisma:
|
||||
specifier: ^6.19.3
|
||||
version: 6.19.3(typescript@5.9.3)
|
||||
@@ -955,7 +961,7 @@ importers:
|
||||
version: 0.446.0(react@18.3.1)
|
||||
next:
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
@@ -1040,7 +1046,7 @@ importers:
|
||||
version: 0.446.0(react@18.3.1)
|
||||
next:
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -3168,6 +3174,11 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@popperjs/core@2.11.8':
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
|
||||
@@ -6539,6 +6550,10 @@ packages:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
define-lazy-prop@2.0.0:
|
||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -7402,6 +7417,11 @@ packages:
|
||||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -7947,6 +7967,11 @@ packages:
|
||||
resolution: {integrity: sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-docker@2.2.1:
|
||||
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -8164,6 +8189,10 @@ packages:
|
||||
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -8292,6 +8321,9 @@ packages:
|
||||
resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -9363,6 +9395,10 @@ packages:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
open@8.4.2:
|
||||
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -9632,6 +9668,16 @@ packages:
|
||||
resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
playwright-core@1.61.1:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.1:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -14039,6 +14085,10 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@posthog/core@1.41.1':
|
||||
@@ -18758,6 +18808,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
define-lazy-prop@2.0.0: {}
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
define-properties@1.2.1:
|
||||
@@ -19913,6 +19965,9 @@ snapshots:
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -20501,6 +20556,8 @@ snapshots:
|
||||
is-accessor-descriptor: 1.0.2
|
||||
is-data-descriptor: 1.0.1
|
||||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-even@1.0.0:
|
||||
@@ -20670,6 +20727,10 @@ snapshots:
|
||||
|
||||
is-windows@1.0.2: {}
|
||||
|
||||
is-wsl@2.2.0:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
@@ -20888,6 +20949,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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:
|
||||
dependencies:
|
||||
jest-get-type: 29.6.3
|
||||
@@ -21884,7 +21950,7 @@ snapshots:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
next@14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
next@14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@next/env': 14.2.35
|
||||
'@swc/helpers': 0.5.5
|
||||
@@ -21905,6 +21971,7 @@ snapshots:
|
||||
'@next/swc-win32-arm64-msvc': 14.2.33
|
||||
'@next/swc-win32-ia32-msvc': 14.2.33
|
||||
'@next/swc-win32-x64-msvc': 14.2.33
|
||||
'@playwright/test': 1.61.1
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
@@ -22080,6 +22147,12 @@ snapshots:
|
||||
powershell-utils: 0.1.0
|
||||
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:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -22343,6 +22416,14 @@ snapshots:
|
||||
|
||||
pkginfo@0.4.1: {}
|
||||
|
||||
playwright-core@1.61.1: {}
|
||||
|
||||
playwright@1.61.1:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
png-js@2.0.0:
|
||||
|
||||
Reference in New Issue
Block a user