Merge pull request #917 from Tria-plc/tests

Tests
This commit is contained in:
mulish77
2026-07-22 16:44:46 +03:00
committed by GitHub
71 changed files with 4993 additions and 29 deletions

View 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
View File

@@ -0,0 +1,6 @@
# E2E HTML report output
e2e-report/
# Track the E2E env TEMPLATE (real .env.test stays ignored)
!.env.test.example

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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' })

View File

@@ -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();

View File

@@ -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({

View File

@@ -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' })

View File

@@ -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 } }),

View File

@@ -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 })

View File

@@ -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);
}
}

View File

@@ -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;
}

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

View File

@@ -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);
});
});

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

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

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

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

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

View File

@@ -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
}
]
]
}

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

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

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

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

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

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

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

View File

@@ -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 ${

View File

@@ -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)
}

View File

@@ -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);