Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-08-05 10:39:35 +03:00
948 changed files with 99715 additions and 11357 deletions

View File

@@ -1,15 +1,12 @@
/**
* 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.
* C-8 ✅ FIXED (was 🔴 "The exchange-rate write routes (PUT upsert, PATCH update) carry no
* METHOD-LEVEL guard, so they get only the global JwtGuard, not @PassengerAdmin
* unlike DELETE, which was admin-gated. Net effect: any authenticated user incl. a
* passenger could rewrite live FX rates."): currency.controller.ts now decorates
* upsert/update/remove all with @PassengerAdmin() — confirmed by reading the source.
* Updated below to assert all three routes are admin-gated, not just DELETE.
*/
import "reflect-metadata";
import { CurrencyController } from "../src/modules/fare-engine/currency.controller";
@@ -20,15 +17,15 @@ function guardsOn(handler: unknown): 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 PUT upsert exchange-rate IS admin-gated", () => {
expect(guardsOn(CurrencyController.prototype.upsert).length).toBeGreaterThan(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 PATCH update exchange-rate IS admin-gated", () => {
expect(guardsOn(CurrencyController.prototype.update).length).toBeGreaterThan(0);
});
it("C-8 control: DELETE exchange-rate IS admin-gated — proving writes should be too", () => {
it("C-8 control: DELETE exchange-rate IS admin-gated too — all three writes consistently guarded", () => {
expect(guardsOn(CurrencyController.prototype.remove).length).toBeGreaterThan(0);
});
});

View File

@@ -82,6 +82,7 @@ describe("Authenticated booking passengerId resolution (regression)", () => {
prisma as any,
{ query: async () => [] } as any, // dataSource (resolveIamContact raw SQL → [])
asyncStub(), // seatsService
asyncStub(), // ticketsService — constructor gained this param since this test was written
{ emit: () => true } as any,
asyncStub(), // verifaydaService (PASSPORT skips)
asyncStub(), // currencyService (ETB skips)

View File

@@ -0,0 +1,308 @@
/**
* `/search/available-dates` — the data behind the search form's date picker.
*
* The portal disables the date control entirely when `routeExists` is false, and grays out
* individual days that report `available: false`. Both behaviours are only as correct as this
* endpoint, so this suite pins:
*
* 1. routeExists=false (with an empty `dates` array) for a station pair no active route
* connects in that direction — the case that disables the whole control.
* 2. routeExists=true plus a per-date availability list when a route does connect them.
* 3. Direction matters: seed-core's route runs A→B→C, so C→A is NOT a route even though
* both stations sit on it. This is the exact regression the UI relies on — a reverse
* pair must not be treated as bookable.
* 4. A date only counts as available when a bookable schedule actually departs that day.
* 5. The range is clamped server-side and never reports dates in the past.
*
* Uses the slim harness (real Nest DI) for schedule creation so the real interpolation runs,
* then instantiates SearchService directly with a real Prisma — SearchModule is not in the
* slim harness's DOMAIN_MODULES (it pulls in NotificationsModule → RabbitMQ), mirroring the
* Tier-2 pattern in stop-based-booking-segment.e2e-spec.ts.
*/
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SearchService } from "../src/modules/search/search.service";
import { SegmentsService } from "../src/modules/segments/segments.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
const ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** Calendar date in Africa/Addis_Ababa (fixed UTC+3) — matches the service's own conversion. */
function addisDateStr(d: Date): string {
return new Date(d.getTime() + ADDIS_OFFSET_MS).toISOString().slice(0, 10);
}
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * ONE_DAY_MS);
}
describe("GET /search/available-dates", () => {
let harness: ServiceHarness;
let searchService: SearchService;
let schedulesService: SchedulesService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const segmentsService = new SegmentsService(harness.prisma as any);
searchService = new SearchService(
harness.prisma as any,
currencyService,
fareEngine,
segmentsService,
);
});
afterAll(async () => {
await harness.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
});
/** Creates a bookable schedule departing `days` from now on the seeded A→B→C route. */
async function createBookableSchedule(days: number, trainNumber: string) {
const departureAt = daysFromNow(days);
departureAt.setUTCHours(6, 0, 0, 0);
const arrivalAt = new Date(departureAt.getTime() + 6 * 60 * 60 * 1000);
const train = await harness.prisma.train.create({
data: { number: trainNumber, name: `Test ${trainNumber}` },
});
const coach = await harness.prisma.coach.create({
data: {
coachTypeId: IDS.coachType,
number: `${trainNumber}-C1`,
capacity: 2,
sequence: 1,
status: "ACTIVE",
},
});
await Promise.all(
["1A", "1B"].map((seatNumber, i) =>
harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber, row: 1, col: String.fromCharCode(65 + i) },
}),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, departureDate: addisDateStr(departureAt) };
}
function range(days = 30) {
return { from: addisDateStr(new Date()), to: addisDateStr(daysFromNow(days)) };
}
it("reports routeExists=false and no dates when no route connects the pair", async () => {
// seed-core's only route runs A→B→C and no schedule exists yet, so nothing connects C→A.
// Every date is unbookable, and the portal disables the date control outright rather than
// graying out each day individually.
const result = await searchService.getAvailableDates({
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
...range(),
} as any);
expect(result.routeExists).toBe(false);
expect(result.dates).toEqual([]);
});
/**
* Regression: `routeExists` must agree with what the search can actually sell.
*
* A return leg reuses the outbound Route but lays its TripStopTimes in the opposite order.
* routeExistsForPair originally consulted only RouteStop ordering, so it answered "no route"
* for C→A while searchTrips happily returned a bookable trip for that same pair. The portal
* disables its date picker on this flag, so the stale answer would have blocked a real,
* sellable journey.
*/
it("reports routeExists=true for a reverse pair a real schedule connects", async () => {
const departureAt = daysFromNow(3);
departureAt.setUTCHours(6, 0, 0, 0);
const train = await harness.prisma.train.create({
data: { number: "AD-REV", name: "Reverse leg" },
});
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: "AD-REV-C1", capacity: 1, sequence: 1, status: "ACTIVE" },
});
await harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "A" },
});
// Return leg: same Route, but stop times run C → A.
const schedule = await harness.prisma.trainSchedule.create({
data: {
trainId: train.id,
routeId: IDS.route,
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
departureAt,
arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000),
durationMinutes: 360,
status: "SCHEDULED",
},
});
await harness.prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: departureAt, status: "OPEN" },
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 2, plannedArrivalAt: new Date(departureAt.getTime() + 6 * 3600_000), status: "OPEN" },
],
});
await harness.prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
...range(),
} as any);
expect(result.routeExists).toBe(true);
expect(result.dates.filter((d) => d.available).map((d) => d.date)).toContain(
addisDateStr(departureAt),
);
});
it("reports routeExists=false for a station pair with no route at all", async () => {
const orphan = await harness.prisma.station.create({
data: { code: "ZZZ", name: "Orphan", city: "Nowhere" },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: orphan.id,
...range(),
} as any);
expect(result.routeExists).toBe(false);
expect(result.dates).toEqual([]);
});
it("reports routeExists=true with a per-date list for a connected pair", async () => {
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
expect(result.routeExists).toBe(true);
expect(result.dates.length).toBeGreaterThan(0);
for (const d of result.dates) {
expect(d).toEqual({ date: expect.any(String), available: expect.any(Boolean) });
}
});
it("marks only the days a bookable schedule departs as available", async () => {
const { departureDate } = await createBookableSchedule(3, "AD-1");
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
expect(result.routeExists).toBe(true);
const available = result.dates.filter((d) => d.available).map((d) => d.date);
expect(available).toContain(departureDate);
// Every other day in the window has no schedule, so it must be reported unavailable —
// this is what grays out individual days on the picker.
expect(available).toEqual([departureDate]);
});
it("treats a mid-route segment as its own pair (A→B available, C→B never)", async () => {
const { departureDate } = await createBookableSchedule(4, "AD-2");
const forward = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
...range(),
} as any);
expect(forward.routeExists).toBe(true);
expect(forward.dates.filter((d) => d.available).map((d) => d.date)).toContain(departureDate);
// C sits after B on the route, so C→B is backwards — no route, whatever schedules exist.
const backward = await searchService.getAvailableDates({
originStationId: IDS.stationC,
destinationStationId: IDS.stationB,
...range(),
} as any);
expect(backward.routeExists).toBe(false);
expect(backward.dates).toEqual([]);
});
it("never reports dates before today, even when asked for a past range", async () => {
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
from: addisDateStr(daysFromNow(-30)),
to: addisDateStr(daysFromNow(5)),
} as any);
const today = addisDateStr(new Date());
expect(result.routeExists).toBe(true);
for (const d of result.dates) expect(d.date >= today).toBe(true);
});
it("clamps an over-long range to the 90-day server maximum", async () => {
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
from: addisDateStr(new Date()),
to: addisDateStr(daysFromNow(400)),
} as any);
expect(result.routeExists).toBe(true);
// Inclusive of both ends, 90 days spans at most 91 calendar dates.
expect(result.dates.length).toBeLessThanOrEqual(91);
expect(result.to <= addisDateStr(daysFromNow(91))).toBe(true);
});
it("does not mark a package-only schedule's day as available", async () => {
const { schedule, departureDate } = await createBookableSchedule(5, "AD-3");
await harness.prisma.trainSchedule.update({
where: { id: schedule.id },
data: { isPackageOnly: true },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
const available = result.dates.filter((d) => d.available).map((d) => d.date);
expect(available).not.toContain(departureDate);
});
it("does not mark a cancelled schedule's day as available", async () => {
const { schedule, departureDate } = await createBookableSchedule(6, "AD-4");
await harness.prisma.trainSchedule.update({
where: { id: schedule.id },
data: { status: "CANCELLED" },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
const available = result.dates.filter((d) => d.available).map((d) => d.date);
expect(available).not.toContain(departureDate);
});
});

View File

@@ -0,0 +1,365 @@
/**
* Booking-type coverage — ROUND_TRIP, TRANSIT, ROUND_TRIP_TRANSIT end-to-end through
* GuestBookingService.createGuestBooking(). ONE_WAY is already covered by
* stop-based-booking-segment.e2e-spec.ts / checkin-cutoff.e2e-spec.ts and is not repeated
* here.
*
* TRANSIT/ROUND_TRIP_TRANSIT use seed-core's single A(seq1)->B(seq2)->C(seq3) route for the
* outbound/leg2 direction (transit at B). ROUND_TRIP/ROUND_TRIP_TRANSIT additionally need a
* genuine return direction — createGuestRoundTripBooking does NOT validate stop-sequence
* ordering between origin/destination (confirmed by reading the method), so it would silently
* accept a "return" leg on the same forward route, but that's not what a real round trip is.
* A local reverse route (C(seq1)->B(seq2)->A(seq3)) is created per-test instead, keeping
* seed-core.ts itself untouched (this reverse route isn't a general-purpose fixture need).
*
* Uses the same harness/Tier-2 pattern as reserve-seat-issue-booking.e2e-spec.ts and
* stop-based-booking-segment.e2e-spec.ts.
*/
import { IdDocumentType, PassengerCategory } from "@prisma/client";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core";
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
describe("Booking types — ROUND_TRIP / TRANSIT / ROUND_TRIP_TRANSIT", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let guestBookingService: GuestBookingService;
let reverseRouteId: string;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const systemConfig = new SystemConfigService(harness.prisma as any);
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
guestBookingService = new GuestBookingService(
harness.prisma as any,
seatsService,
asyncStub(), // verifaydaService — test passengers use PASSPORT, not NATIONAL_ID
currencyService,
asyncStub(), // passengerAuthService — no createAccount in these DTOs
fareEngine,
{ emit: () => true } as any, // eventEmitter
asyncStub(), // paymentsService — not reached by createGuestBooking
asyncStub(), // auditService
asyncStub(), // smsClient
);
});
afterAll(async () => {
await harness?.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
reverseRouteId = await createReverseRoute();
});
/** Mirrors seed-core's A->B->C route but reversed (C->B->A), for a genuine return leg. */
async function createReverseRoute(): Promise<string> {
const route = await harness.prisma.route.create({
data: {
code: `RT-REV-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: "Reverse Line",
effectiveFrom: new Date("2020-01-01T00:00:00.000Z"),
active: true,
stops: {
create: [
{ stationId: IDS.stationC, sequence: 1, distanceKm: 0 },
{ stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.C - DISTANCE.B },
{ stationId: IDS.stationA, sequence: 3, distanceKm: DISTANCE.C - DISTANCE.A },
],
},
},
});
return route.id;
}
/** Coach assigned via coachIds at creation time — createSchedule rejects zero coaches. */
async function createTestSchedule(opts: { trainNumber: string; routeId: string; departureAt: Date; arrivalAt: Date }) {
const train = await harness.prisma.train.create({
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
});
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 6, sequence: 1, status: "ACTIVE" },
});
const seats = await Promise.all(
["1A", "1B", "1C", "1D", "1E", "1F"].map((seatNumber, i) =>
harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
}),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: opts.routeId,
departureAt: opts.departureAt.toISOString(),
arrivalAt: opts.arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}
async function hold(scheduleId: string, originStationId: string, destinationStationId: string, seatId: string, passengerId: string) {
return seatsService.holdSeats({
scheduleId,
originStationId,
destinationStationId,
passengers: [{ passengerId, seatId }],
} as any);
}
/** Holds several seats on the SAME leg in one SeatHold row (one holdId covers them all) —
* required when a single booking carries multiple travelers on one leg: holdSeats rejects a
* second call for the same passengerId+leg as a conflict, so multi-passenger holds must be
* one call with distinct passengerIds, not N sequential single-seat calls. */
async function holdMany(scheduleId: string, originStationId: string, destinationStationId: string, seatIds: string[]) {
return seatsService.holdSeats({
scheduleId,
originStationId,
destinationStationId,
passengers: seatIds.map((seatId, i) => ({ passengerId: `44444444-4444-4444-8444-40000000000${i}`, seatId })),
} as any);
}
function passenger(overrides: Partial<Record<string, any>> = {}) {
return {
passengerName: "Test Traveler",
dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT,
passportNumber: "X123456",
passportCountry: "Djibouti",
nationality: "Djiboutian",
...overrides,
};
}
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
describe("ROUND_TRIP", () => {
it("creates a booking with correct fields for both legs", async () => {
const dep = future(180);
const arr = future(280);
const { schedule: outbound, seats: outSeats } = await createTestSchedule({ trainNumber: `RT-OB-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: arr });
const { schedule: ret, seats: retSeats } = await createTestSchedule({ trainNumber: `RT-RET-${Date.now()}`, routeId: reverseRouteId, departureAt: future(400), arrivalAt: future(480) });
const outHold = await hold(outbound.id, IDS.stationA, IDS.stationC, outSeats[0].id, "11111111-1111-4111-8111-100000000001");
const retHold = await hold(ret.id, IDS.stationC, IDS.stationA, retSeats[0].id, "11111111-1111-4111-8111-100000000001");
const result: any = await guestBookingService.createGuestBooking({
bookingType: "ROUND_TRIP",
scheduleId: outbound.id,
holdId: (outHold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
returnScheduleId: ret.id,
returnHoldId: (retHold as any).holdId,
returnOriginStationId: IDS.stationC,
returnDestinationStationId: IDS.stationA,
passengers: [passenger({ seatId: outSeats[0].id, returnSeatId: retSeats[0].id })],
} as any);
expect(result.bookingType).toBe("ROUND_TRIP");
expect(result.returnScheduleId).toBe(ret.id);
expect(result.returnOriginStationId).toBe(IDS.stationC);
expect(result.returnDestinationStationId).toBe(IDS.stationA);
expect(result.totalMinor).toBeGreaterThan(0);
const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
expect(seatRows).toHaveLength(2); // one BookingSeat per leg
expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([outbound.id, ret.id].sort());
});
it("rejects a ROUND_TRIP missing return fields with the exact message", async () => {
const { schedule: outbound, seats } = await createTestSchedule({ trainNumber: `RT-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(280) });
const outHold = await hold(outbound.id, IDS.stationA, IDS.stationC, seats[0].id, "11111111-1111-4111-8111-100000000002");
await expect(
guestBookingService.createGuestBooking({
bookingType: "ROUND_TRIP",
scheduleId: outbound.id,
holdId: (outHold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [passenger({ seatId: seats[0].id })],
} as any),
).rejects.toThrow(/returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required/i);
});
// NOTE: a "first child travels free" test was attempted here and dropped — the
// fareMinor-by-seatId-presence branch (`p.seatId ? childUnitFare : 0`, commented "Free
// children have no seat (frontend excludes them from the DTO)") is UNREACHABLE in
// practice: every passenger row unconditionally goes through
// `seat: { connect: { id: p.seatId } } }` when the booking is created (confirmed in
// ONE_WAY at line ~367, ROUND_TRIP at ~874, TRANSIT at ~1075) — a passenger with no
// `seatId` makes that Prisma `connect` throw, so a truly seat-less "free child" can never
// reach the booking-creation step at all. FLAGGED, not fixed — see the final report.
it("two adults on the same booking are each charged the full per-leg fare", async () => {
const dep = future(180);
const { schedule: outbound, seats: outSeats } = await createTestSchedule({ trainNumber: `RT-2ADULT-OB-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: future(280) });
const { schedule: ret, seats: retSeats } = await createTestSchedule({ trainNumber: `RT-2ADULT-RET-${Date.now()}`, routeId: reverseRouteId, departureAt: future(400), arrivalAt: future(480) });
const outHold = await holdMany(outbound.id, IDS.stationA, IDS.stationC, [outSeats[0].id, outSeats[1].id]);
const retHold = await holdMany(ret.id, IDS.stationC, IDS.stationA, [retSeats[0].id, retSeats[1].id]);
const result: any = await guestBookingService.createGuestBooking({
bookingType: "ROUND_TRIP",
scheduleId: outbound.id,
holdId: (outHold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
returnScheduleId: ret.id,
returnHoldId: (retHold as any).holdId,
returnOriginStationId: IDS.stationC,
returnDestinationStationId: IDS.stationA,
passengers: [
passenger({ seatId: outSeats[0].id, returnSeatId: retSeats[0].id, passengerName: "Adult One" }),
passenger({ seatId: outSeats[1].id, returnSeatId: retSeats[1].id, passengerName: "Adult Two" }),
],
} as any);
expect(result.adultCount).toBe(2);
expect(result.childCount).toBe(0);
const seatFares = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
expect(seatFares).toHaveLength(4); // 2 passengers x 2 legs
const fareMinors = new Set(seatFares.map((s) => s.fareMinor));
expect(fareMinors.size).toBe(1); // every seat on every leg is the same base fare
expect([...fareMinors][0]).toBeGreaterThan(0);
const total = seatFares.reduce((sum, s) => sum + s.fareMinor, 0);
expect(result.totalMinor).toBe(total);
});
});
describe("TRANSIT", () => {
it("creates a single booking spanning leg-1 and leg-2 via the transit station", async () => {
const dep = future(180);
const { schedule: leg1, seats: leg1Seats } = await createTestSchedule({ trainNumber: `TR-L1-${Date.now()}`, routeId: IDS.route, departureAt: dep, arrivalAt: future(220) });
const { schedule: leg2, seats: leg2Seats } = await createTestSchedule({ trainNumber: `TR-L2-${Date.now()}`, routeId: IDS.route, departureAt: future(260), arrivalAt: future(320) });
const leg1Hold = await hold(leg1.id, IDS.stationA, IDS.stationB, leg1Seats[0].id, "22222222-2222-4222-8222-200000000001");
const leg2Hold = await hold(leg2.id, IDS.stationB, IDS.stationC, leg2Seats[0].id, "22222222-2222-4222-8222-200000000001");
const result: any = await guestBookingService.createGuestBooking({
bookingType: "TRANSIT",
scheduleId: leg1.id,
holdId: (leg1Hold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
leg2ScheduleId: leg2.id,
leg2HoldId: (leg2Hold as any).holdId,
transitStationId: IDS.stationB,
leg2DestinationStationId: IDS.stationC,
passengers: [passenger({ seatId: leg1Seats[0].id, leg2SeatId: leg2Seats[0].id })],
} as any);
expect(result.bookingType).toBe("TRANSIT");
expect(result.originStationId).toBe(IDS.stationA);
expect(result.destinationStationId).toBe(IDS.stationC); // full journey span, not just leg-1
expect(result.leg2ScheduleId).toBe(leg2.id);
expect(result.leg2OriginStationId).toBe(IDS.stationB);
expect(result.leg2DestinationStationId).toBe(IDS.stationC);
const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
expect(seatRows).toHaveLength(2);
expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([leg1.id, leg2.id].sort());
});
it("rejects a TRANSIT missing leg-2 fields with the exact message", async () => {
const { schedule: leg1, seats } = await createTestSchedule({ trainNumber: `TR-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) });
const leg1Hold = await hold(leg1.id, IDS.stationA, IDS.stationB, seats[0].id, "22222222-2222-4222-8222-200000000002");
await expect(
guestBookingService.createGuestBooking({
bookingType: "TRANSIT",
scheduleId: leg1.id,
holdId: (leg1Hold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
passengers: [passenger({ seatId: seats[0].id })],
} as any),
).rejects.toThrow(/leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required/i);
});
});
describe("ROUND_TRIP_TRANSIT", () => {
it("creates a single booking spanning all 4 legs (outbound x2, return x2)", async () => {
const { schedule: obL1, seats: obL1Seats } = await createTestSchedule({ trainNumber: `RTT-OB1-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) });
const { schedule: obL2, seats: obL2Seats } = await createTestSchedule({ trainNumber: `RTT-OB2-${Date.now()}`, routeId: IDS.route, departureAt: future(260), arrivalAt: future(320) });
const { schedule: retL1, seats: retL1Seats } = await createTestSchedule({ trainNumber: `RTT-RET1-${Date.now()}`, routeId: reverseRouteId, departureAt: future(500), arrivalAt: future(560) });
const { schedule: retL2, seats: retL2Seats } = await createTestSchedule({ trainNumber: `RTT-RET2-${Date.now()}`, routeId: reverseRouteId, departureAt: future(600), arrivalAt: future(660) });
const pid = "33333333-3333-4333-8333-300000000001";
const obL1Hold = await hold(obL1.id, IDS.stationA, IDS.stationB, obL1Seats[0].id, pid);
const obL2Hold = await hold(obL2.id, IDS.stationB, IDS.stationC, obL2Seats[0].id, pid);
const retL1Hold = await hold(retL1.id, IDS.stationC, IDS.stationB, retL1Seats[0].id, pid);
const retL2Hold = await hold(retL2.id, IDS.stationB, IDS.stationA, retL2Seats[0].id, pid);
const result: any = await guestBookingService.createGuestBooking({
bookingType: "ROUND_TRIP_TRANSIT",
scheduleId: obL1.id,
holdId: (obL1Hold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
leg2ScheduleId: obL2.id,
leg2HoldId: (obL2Hold as any).holdId,
transitStationId: IDS.stationB,
leg2DestinationStationId: IDS.stationC,
returnScheduleId: retL1.id,
returnHoldId: (retL1Hold as any).holdId,
returnOriginStationId: IDS.stationC,
returnDestinationStationId: IDS.stationB,
returnLeg2ScheduleId: retL2.id,
returnLeg2HoldId: (retL2Hold as any).holdId,
returnTransitStationId: IDS.stationB,
returnLeg2DestinationStationId: IDS.stationA,
passengers: [passenger({
seatId: obL1Seats[0].id,
leg2SeatId: obL2Seats[0].id,
returnSeatId: retL1Seats[0].id,
returnLeg2SeatId: retL2Seats[0].id,
})],
} as any);
expect(result.bookingType).toBe("ROUND_TRIP_TRANSIT");
const seatRows = await harness.prisma.bookingSeat.findMany({ where: { bookingId: result.id } });
expect(seatRows).toHaveLength(4);
expect(seatRows.map((s) => s.scheduleId).sort()).toEqual([obL1.id, obL2.id, retL1.id, retL2.id].sort());
});
it("rejects a ROUND_TRIP_TRANSIT missing any leg's fields with the exact message", async () => {
const { schedule: obL1, seats } = await createTestSchedule({ trainNumber: `RTT-MISS-${Date.now()}`, routeId: IDS.route, departureAt: future(180), arrivalAt: future(220) });
const obL1Hold = await hold(obL1.id, IDS.stationA, IDS.stationB, seats[0].id, "33333333-3333-4333-8333-300000000002");
await expect(
guestBookingService.createGuestBooking({
bookingType: "ROUND_TRIP_TRANSIT",
scheduleId: obL1.id,
holdId: (obL1Hold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
passengers: [passenger({ seatId: seats[0].id })],
} as any),
).rejects.toThrow(/ROUND_TRIP_TRANSIT requires all 4 holds and all transit\/return station fields/i);
});
});
});

View File

@@ -3,10 +3,13 @@
* 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.
* CreateSegmentFareDto.baseFareMinor has @Min(0) (inconsistent). Still unfixed.
* H2 ✅ FIXED (was 🔴 "CreateSeatClassDto.basePrice accepts negative/zero, no @Min"):
* seat-classes.dto.ts now has @Min(0) on basePrice. Test updated to assert the fix.
* H4 ✅ FIXED (was 🔴 "CreatePromotionDto.percentOff accepts 200, no @Max(100)"):
* promos.dto.ts now has @Min(0) @Max(100) on percentOff. Test updated to assert the fix.
* H5 🔴 CreatePromotionDto.validUntil is @IsString (not @IsDateString) → accepts non-dates.
* Still unfixed.
*/
import "reflect-metadata";
import { plainToInstance } from "class-transformer";
@@ -44,23 +47,23 @@ describe("Backoffice config validation (Suite H)", () => {
expect(await erroredProps(dto)).toContain("baseFareMinor");
});
it("H2 🔴 CreateSeatClassDto accepts a negative basePrice (no @Min)", async () => {
it("H2 CreateSeatClassDto rejects a negative basePrice (@Min(0))", async () => {
const dto = plainToInstance(CreateSeatClassDto, {
coachTypeId: "ct-1",
name: "Economy",
basePrice: -5000,
});
expect(await erroredProps(dto)).not.toContain("basePrice");
expect(await erroredProps(dto)).toContain("basePrice");
});
it("H4 🔴 CreatePromotionDto accepts percentOff = 200 (no @Max(100))", async () => {
it("H4 CreatePromotionDto rejects percentOff = 200 (@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");
expect(await erroredProps(dto)).toContain("percentOff");
});
it("H5 🔴 CreatePromotionDto.validUntil accepts a non-date string (@IsString, not @IsDateString)", async () => {

View File

@@ -2,8 +2,10 @@
* 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-1 ✅ FIXED (was 🔴 "BookingsService trusts client reviewedTotalMinor: a booking is
* stored with totalMinor=1 while the server fare engine computed ~30000") —
* createOneWayBooking now runs assertTotalNotUnderAuthoritative before persisting;
* a forged low total is rejected, not stored. Test below updated to assert this.
* 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
@@ -164,14 +166,16 @@ describe("Critical reproducers (Tier-2)", () => {
],
};
const result: any = await (bookings as any).createOneWayBooking(dto);
// FIXED (was 🔴): createOneWayBooking now runs the forged reviewedTotalMinor through
// assertTotalNotUnderAuthoritative before ever writing a Booking row — a client total
// below the server-computed fare (minus a 1% rounding tolerance) is rejected outright,
// not silently persisted. Confirmed by reading bookings.service.ts's C-1 guard comment.
await expect((bookings as any).createOneWayBooking(dto)).rejects.toThrow(
/Booking total does not match the authoritative fare/i,
);
// 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);
const stored = await prisma.booking.findFirst({ where: { scheduleId: schedule.id, passengerId: passenger.id } });
expect(stored).toBeNull(); // no under-priced booking left behind
});
// ── C-4 ──────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,146 @@
/**
* Currency conversion through a REAL booking — CurrencyService/FareEngineService are only
* exercised in isolation elsewhere (pricing-currency.e2e-spec.ts, pricing-fare-engine.e2e-spec.ts);
* nothing previously drove an actual GuestBookingService.createGuestBooking() call in a
* non-ETB displayCurrency and asserted the converted amount.
*
* Also covers the consequence of the C2 fix documented in pricing-currency.e2e-spec.ts:
* CurrencyService.getExchangeRate() now fails closed (throws) on a missing rate instead of
* silently pricing at parity — confirms that failure mode actually propagates out of booking
* creation as a real rejection, not a silently wrong charge.
*/
import { IdDocumentType, Currency } from "@prisma/client";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore, USD_TO_ETB, ETB_TO_DJF } from "./fixtures/seed-core";
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
describe("Currency conversion through a real booking", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let guestBookingService: GuestBookingService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const systemConfig = new SystemConfigService(harness.prisma as any);
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
guestBookingService = new GuestBookingService(
harness.prisma as any,
seatsService,
asyncStub(),
currencyService,
asyncStub(),
fareEngine,
{ emit: () => true } as any,
asyncStub(),
asyncStub(),
asyncStub(),
);
});
afterAll(async () => {
await harness?.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
});
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
async function createTestSchedule(trainNumber: string) {
const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } });
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
});
const seats = await Promise.all(
["1A", "1B", "1C"].map((seatNumber, i) =>
harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id, routeId: IDS.route,
departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}
async function bookInCurrency(scheduleId: string, seatId: string, displayCurrency: Currency, passengerId: string, nationality: "Djiboutian" | "Ethiopian" = "Djiboutian") {
const hold = await seatsService.holdSeats({
scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB,
passengers: [{ passengerId, seatId }],
} as any);
return guestBookingService.createGuestBooking({
scheduleId,
holdId: (hold as any).holdId,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
displayCurrency,
passengers: [{
seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality,
}],
} as any) as Promise<any>;
}
it("charges the SAME ETB amount regardless of displayCurrency, but displays it converted by the seeded rate", async () => {
const { schedule, seats } = await createTestSchedule(`CUR-${Date.now()}`);
const etbBooking = await bookInCurrency(schedule.id, seats[0].id, Currency.ETB, "66666666-6666-4666-8666-600000000001");
const usdBooking = await bookInCurrency(schedule.id, seats[1].id, Currency.USD, "66666666-6666-4666-8666-600000000002");
const djfBooking = await bookInCurrency(schedule.id, seats[2].id, Currency.DJF, "66666666-6666-4666-8666-600000000003");
// ETB charge basis (totalMinor) is identical across all three — displayCurrency only
// affects what's SHOWN to the passenger, never the authoritative ETB amount collected.
expect(usdBooking.totalMinor).toBe(etbBooking.totalMinor);
expect(djfBooking.totalMinor).toBe(etbBooking.totalMinor);
expect(etbBooking.currency).toBe("ETB");
expect(usdBooking.currency).toBe("ETB");
expect(djfBooking.currency).toBe("ETB");
// displayTotalMinor derived from the exact seeded rates (see seed-core.ts).
expect(etbBooking.displayTotalMinor).toBe(etbBooking.totalMinor);
expect(usdBooking.displayTotalMinor).toBe(etbBooking.totalMinor * (1 / USD_TO_ETB));
expect(djfBooking.displayTotalMinor).toBe(etbBooking.totalMinor * ETB_TO_DJF);
expect(etbBooking.displayCurrency).toBe("ETB");
expect(usdBooking.displayCurrency).toBe("USD");
expect(djfBooking.displayCurrency).toBe("DJF");
});
it("rejects booking creation when the requested displayCurrency has no configured FX rate (fails closed, doesn't price at parity)", async () => {
const { schedule, seats } = await createTestSchedule(`CUR-NORATE-${Date.now()}`);
// Remove BOTH directions of the ETB<->DJF rate. Nationality is Ethiopian (not Djiboutian)
// specifically so FareEngineService's OWN internal billing-currency conversion (tied to
// nationality, resolves to ETB for an Ethiopian passenger — a same-currency no-op that
// never touches the DB) doesn't also need this rate; only the OUTER
// GuestBookingService.createGuestOneWayBooking's displayCurrency conversion does. This
// isolates the failure to that one call rather than fare calculation itself.
await harness.prisma.currencyExchangeRate.deleteMany({
where: { OR: [{ fromCurrency: "ETB", toCurrency: "DJF" }, { fromCurrency: "DJF", toCurrency: "ETB" }] },
});
await expect(
bookInCurrency(schedule.id, seats[0].id, Currency.DJF, "66666666-6666-4666-8666-600000000004", "Ethiopian"),
).rejects.toThrow(/No exchange rate configured/i);
// No half-created booking left behind by the failed conversion.
const orphan = await harness.prisma.booking.findFirst({ where: { scheduleId: schedule.id, displayCurrency: "DJF" } });
expect(orphan).toBeNull();
});
});

View File

@@ -85,6 +85,7 @@ describe("Money integrity (Tier-2 direct instantiation)", () => {
prisma as any,
asyncStub(), // dataSource
asyncStub(), // seatsService
asyncStub(), // ticketsService — constructor gained this param since this test was written
{ emit: () => true } as any, // eventEmitter
asyncStub(), // verifaydaService
asyncStub(), // currencyService

View File

@@ -0,0 +1,249 @@
/**
* Payments webhook + non-wallet provider flow — PaymentsService.handlePaymentEvent() (the
* real "webhook" entry point, invoked by both the RabbitMQ consumer and
* InternalPaymentsController — see payment-events.consumer.ts /
* internal-payments.controller.ts) had zero e2e coverage before this suite. Also exercises
* initiatePayment() for a non-wallet (provider-routed) method, mocking PaymentClientService at
* the boundary — no real payment provider is contacted.
*
* Refund disbursement is deliberately NOT re-tested here: money-integrity.e2e-spec.ts already
* covers `cancel()` computing an 80% refund that's never actually disbursed (no PaymentRefund
* row, no wallet credit) in detail — re-run that suite rather than duplicating it. Confirmed
* still true as of this session (unrelated to the module changed here).
*
* NOTE: apps/edr-passenger-api/src/modules/payments/payments.e2e-spec.ts (an existing "E2E"
* suite that boots the full AppModule over HTTP) is never actually executed by any script —
* it lives outside test/jest-e2e.json's rootDir (`test/`) and doesn't match the plain `test`
* script's `.spec.ts$` regex either (the filename ends `...e2e-spec.ts`, not `...spec.ts`
* immediately preceded by a dot). Flagging as an orphaned test file, not fixed here.
*/
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
import { PaymentService as PaymentServiceEnum, PaymentReferenceType, ProviderMethod, ProviderPaymentStatus } from "@edr/types";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { TicketsService } from "../src/modules/tickets/tickets.service";
import { PaymentsService } from "../src/modules/payments/payments.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
describe("Payments — webhook idempotency and non-wallet initiate flow", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let ticketsService: TicketsService;
let guestBookingService: GuestBookingService;
let paymentClient: { initiate: jest.Mock };
let paymentsService: PaymentsService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const systemConfig = new SystemConfigService(harness.prisma as any);
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
paymentClient = { initiate: jest.fn() };
paymentsService = new PaymentsService(
harness.prisma as any,
seatsService,
ticketsService,
{ emit: () => true } as any,
paymentClient as any,
currencyService,
asyncStub(),
);
guestBookingService = new GuestBookingService(
harness.prisma as any,
seatsService,
asyncStub(),
currencyService,
asyncStub(),
fareEngine,
{ emit: () => true } as any,
paymentsService,
asyncStub(),
asyncStub(),
);
});
afterAll(async () => {
await harness?.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
paymentClient.initiate.mockReset();
});
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
async function createTestSchedule(trainNumber: string) {
const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } });
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
});
const seats = await Promise.all(
["1A", "1B"].map((seatNumber, i) =>
harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 } }),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id, routeId: IDS.route,
departureAt: future(180).toISOString(), arrivalAt: future(220).toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}
async function createOneWayBooking(scheduleId: string, seatId: string, passengerId: string) {
const hold = await seatsService.holdSeats({
scheduleId, originStationId: IDS.stationA, destinationStationId: IDS.stationB,
passengers: [{ passengerId, seatId }],
} as any);
return guestBookingService.createGuestBooking({
scheduleId, holdId: (hold as any).holdId,
originStationId: IDS.stationA, destinationStationId: IDS.stationB,
seatClassId: IDS.seatClassLocal,
passengers: [{
seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality: "Djiboutian",
}],
} as any) as Promise<any>;
}
function webhookEvent(booking: any, overrides: Partial<Record<string, any>> = {}) {
return {
version: 1 as const,
eventId: `evt-${Math.random().toString(36).slice(2)}`,
eventType: "payment.succeeded" as const,
occurredAt: new Date().toISOString(),
service: PaymentServiceEnum.PASSENGER,
intentId: `remote-intent-${Math.random().toString(36).slice(2)}`,
referenceType: PaymentReferenceType.BOOKING,
referenceId: booking.id,
merchantOrderId: booking.bookingRef,
provider: ProviderMethod.TELEBIRR,
amountMinor: booking.displayTotalMinor ?? booking.totalMinor,
currency: booking.displayCurrency ?? "ETB",
...overrides,
};
}
describe("handlePaymentEvent() — webhook", () => {
it("delivering the same success event twice confirms the booking once, not twice", async () => {
const { schedule, seats } = await createTestSchedule(`WH-DUP-${Date.now()}`);
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000001");
const first = await paymentsService.handlePaymentEvent(webhookEvent(booking) as any);
expect(first.processed).toBe(true);
const second = await paymentsService.handlePaymentEvent(webhookEvent(booking, { eventId: "evt-redelivered" }) as any);
expect(second.processed).toBe(true);
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
expect(refreshedBooking?.status).toBe("CONFIRMED");
const intents = await harness.prisma.paymentIntent.findMany({ where: { bookingId: booking.id } });
expect(intents).toHaveLength(1);
expect(intents[0].status).toBe(PaymentIntentStatus.SUCCEEDED);
const tickets = await harness.prisma.ticket.findMany({ where: { bookingId: booking.id } });
expect(tickets).toHaveLength(1); // NOT duplicated on redelivery
});
it("refuses to confirm on a short (underpaid) settlement", async () => {
const { schedule, seats } = await createTestSchedule(`WH-SHORT-${Date.now()}`);
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000002");
const expected = booking.displayTotalMinor ?? booking.totalMinor;
const result = await paymentsService.handlePaymentEvent(
webhookEvent(booking, { amountMinor: Math.round(expected * 0.5) }) as any,
);
expect(result.processed).toBe(false);
expect((result as any).reason).toBe("amount-mismatch");
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
expect(refreshedBooking?.status).toBe("PENDING_PAYMENT");
const succeededIntents = await harness.prisma.paymentIntent.count({ where: { bookingId: booking.id, status: PaymentIntentStatus.SUCCEEDED } });
expect(succeededIntents).toBe(0);
});
it("payment.failed marks the intent FAILED without confirming the booking", async () => {
const { schedule, seats } = await createTestSchedule(`WH-FAILED-${Date.now()}`);
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000003");
await harness.prisma.paymentIntent.create({
data: { bookingId: booking.id, amountMinor: booking.totalMinor, currency: "ETB", method: PaymentMethodType.TELEBIRR, status: PaymentIntentStatus.PROCESSING },
});
const result = await paymentsService.handlePaymentEvent(
webhookEvent(booking, { eventType: "payment.failed", failureCode: "INSUFFICIENT_FUNDS", failureMessage: "Declined" }) as any,
);
expect(result.processed).toBe(true);
const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: booking.id } });
expect(intent?.status).toBe(PaymentIntentStatus.FAILED);
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
expect(refreshedBooking?.status).toBe("PENDING_PAYMENT");
});
});
describe("initiatePayment() — non-wallet (provider-routed) method", () => {
it("an instantly-settled provider response (e.g. TELEBIRR) converges the booking immediately, same as a webhook would", async () => {
const { schedule, seats } = await createTestSchedule(`INIT-NONWALLET-${Date.now()}`);
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000004");
paymentClient.initiate.mockResolvedValue({
intentId: "remote-intent-1",
status: ProviderPaymentStatus.SUCCEEDED,
provider: ProviderMethod.TELEBIRR,
merchantOrderId: booking.bookingRef,
amountMinor: booking.totalMinor / 100,
currency: "ETB",
providerTxnId: "TXN-123",
paidAt: new Date().toISOString(),
});
const response = await paymentsService.initiatePayment({ bookingId: booking.id, method: "TELEBIRR", platform: "web" } as any);
expect(paymentClient.initiate).toHaveBeenCalledTimes(1);
expect(response.status).toBe(PaymentIntentStatus.SUCCEEDED);
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
expect(refreshedBooking?.status).toBe("CONFIRMED");
const tickets = await harness.prisma.ticket.findMany({ where: { bookingId: booking.id } });
expect(tickets).toHaveLength(1);
});
it("a REQUIRES_ACTION provider response leaves the booking PENDING_PAYMENT and surfaces the clientAction", async () => {
const { schedule, seats } = await createTestSchedule(`INIT-PENDING-${Date.now()}`);
const booking = await createOneWayBooking(schedule.id, seats[0].id, "77777777-7777-4777-8777-700000000005");
paymentClient.initiate.mockResolvedValue({
intentId: "remote-intent-2",
status: ProviderPaymentStatus.REQUIRES_ACTION,
provider: ProviderMethod.TELEBIRR,
merchantOrderId: booking.bookingRef,
amountMinor: booking.totalMinor / 100,
currency: "ETB",
clientAction: { type: "REDIRECT", url: "https://provider.example/pay/abc" },
});
const response = await paymentsService.initiatePayment({ bookingId: booking.id, method: "TELEBIRR", platform: "web" } as any);
expect(response.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect((response as any).clientAction?.type).toBe("REDIRECT");
const refreshedBooking = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
expect(refreshedBooking?.status).toBe("PENDING_PAYMENT");
});
});
});

View File

@@ -1,7 +1,12 @@
/**
* 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).
* C2 ✅ FIXED (was 🔴): getExchangeRate() used to silently return 1.0 on a missing rate
* now fails closed with the same BadRequestException getRateOrThrow() always threw
* (see the "H-2: fail closed" comment in currency.service.ts). C2/C2b below were
* found still asserting the OLD buggy behavior (`resolves.toBe(1.0)`) and were
* themselves failing as a result — updated to assert the current, correct behavior.
* Do not revert getExchangeRate to silently return 1.0 to make an old version of
* this test pass; that would reintroduce a real underpricing bug.
* 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
@@ -26,23 +31,25 @@ describe("Pricing — CurrencyService (Suite C)", () => {
await resetAndSeedCore(harness.prisma);
});
it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => {
it("C2 ✅ getExchangeRate now fails closed on a missing DIRECT rate, same as getRateOrThrow (no more silent 1.0)", async () => {
// Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays.
// getExchangeRate has no inverse-rate fallback at all (unlike getRateOrThrow, which does)
// — so it correctly throws here even though a usable inverse rate technically exists.
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);
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i);
// Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct).
// getRateOrThrow DOES fall back to the inverse → 1 / 0.01 = 100 (correct) — this divergence
// (one method has an inverse fallback, the other doesn't) is a separate, real inconsistency
// from the old "silent 1.0" bug; flagging it, not fixing it here.
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 () => {
it("C2b truly-missing pair: both getExchangeRate and getRateOrThrow fail closed", async () => {
await harness.prisma.currencyExchangeRate.deleteMany({
where: {
OR: [
@@ -51,7 +58,7 @@ describe("Pricing — CurrencyService (Suite C)", () => {
],
},
});
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0);
await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).rejects.toThrow(/No exchange rate configured/i);
await expect(
currency.getRateOrThrow("USD" as any, "ETB" as any),
).rejects.toThrow(/No exchange rate/i);

View File

@@ -1,8 +1,13 @@
/**
* Reference pricing suite — proves the slim harness boots and exercises FareEngineService directly.
* Also confirms two matrix findings against the running engine:
* Also confirms 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).
* C1 ✅ FIXED (was 🔴 "missing USD→ETB FX rate is silently substituted with 1.0, fares
* collapse ~100x"): CurrencyService.getExchangeRate() now fails closed on a missing
* rate (see the "H-2: fail closed" comment in currency.service.ts, and
* pricing-currency.e2e-spec.ts's C2/C2b) — FareEngineService.calculate() calls it
* internally, so a missing rate now correctly rejects the fare calculation instead of
* silently underpricing it. Updated below to assert the current, correct behavior.
*/
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import {
@@ -114,18 +119,15 @@ describe("Pricing — FareEngineService (slim harness)", () => {
expect(withPromo.totalMinor).toBe(base.totalMinor - 5000);
});
it("C1 🔴 missing USD→ETB rate silently falls back to 1.0 (fare collapses ~100x)", async () => {
it("C1 ✅ a missing USD→ETB rate now rejects the fare calculation instead of silently pricing at parity", async () => {
const withRate = await fareEngine.calculate(baseDto() as any);
expect(withRate.totalMinor).toBeGreaterThan(0);
// 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);
await expect(fareEngine.calculate(baseDto() as any)).rejects.toThrow(/No exchange rate configured/i);
});
});

View File

@@ -0,0 +1,556 @@
/**
* Reserve seat → release | issue booking — coverage for GuestBookingService.
* issueBookingFromReservation(), BookingsService.getByPayToken(), and the
* SeatsService.unblockSeat() reuse that releases the reservation.
*
* STAFF path: fee-waived, finalized immediately via PaymentsService.finalizePaymentSuccess()
* (the same all-in-one finalizer every real payment webhook uses) — proves a Ticket is
* issued, the booking is CONFIRMED, and the reservation's own SeatBlock is released (not
* left stale) before TicketsService.generate() marks the now-ticketed seat BOOKED with its
* own fresh block — the correct end state once a real ticket exists, distinct from an
* operational reservation that never converts into a booking.
*
* PASSENGER path: booking stays PENDING_PAYMENT with a payToken texted to the traveler —
* proves no PaymentIntent is created yet (the passenger creates one themselves later via the
* already-public /payments/initiate, same as a normal guest checkout) and that
* BookingsService.getByPayToken() resolves it for the portal's pay-by-link page.
*
* Uses the slim harness (real Nest DI) for SchedulesService/CurrencyService/FareEngineService,
* same as stop-based-booking-segment.e2e-spec.ts. SeatsService/TicketsService/PaymentsService/
* GuestBookingService/BookingsService are instantiated directly with a real Prisma +
* stubbed collaborators (Tier-2 pattern, money-integrity.e2e-spec.ts) — PaymentsModule/
* BookingsModule pull in NotificationsModule → RabbitMQ, which the slim harness avoids.
*/
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
import { validateSync } from "class-validator";
import { plainToInstance } from "class-transformer";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { SegmentsService } from "../src/modules/segments/segments.service";
import { TicketsService } from "../src/modules/tickets/tickets.service";
import { PaymentsService } from "../src/modules/payments/payments.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { BookingsService } from "../src/modules/bookings/bookings.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto";
import { NotificationsService } from "../src/modules/notifications/notifications.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } 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("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let guestBookingService: GuestBookingService;
let bookingsService: BookingsService;
let notificationsService: NotificationsService;
let smsClient: { sendSms: jest.Mock };
let emailClient: { sendEmail: jest.Mock };
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const systemConfig = new SystemConfigService(harness.prisma as any);
// Real SegmentsService (not asyncStub) — the getSeatMap test below exercises
// resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs
// an actual Map back, not asyncStub's `async () => undefined`.
const segmentsService = new SegmentsService(harness.prisma as any);
seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub());
const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
const paymentsService = new PaymentsService(
harness.prisma as any,
seatsService,
ticketsService,
{ emit: () => true } as any, // eventEmitter
asyncStub(), // paymentClient — never reached: finalizePaymentSuccess doesn't call it
currencyService,
asyncStub(), // auditService
);
smsClient = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
guestBookingService = new GuestBookingService(
harness.prisma as any,
seatsService,
asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID
currencyService,
asyncStub(), // passengerAuthService — never reached: no createAccount in this flow
fareEngine,
{ emit: () => true } as any, // eventEmitter
paymentsService,
asyncStub(), // auditService
smsClient,
);
bookingsService = new BookingsService(
harness.prisma as any,
asyncStub(), // dataSource
seatsService,
ticketsService,
{ emit: () => true } as any, // eventEmitter
asyncStub(), // verifaydaService
currencyService,
fareEngine,
asyncStub(), // auditService
);
emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
// Same smsClient instance guestBookingService uses — lets the notification-suppression
// test assert on ONE shared call count across both services, proving the reservation
// flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking.
notificationsService = new NotificationsService(
harness.prisma as any,
asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups,
// never hit by these guest-passenger-id-keyed test bookings
emailClient as any,
smsClient as any,
asyncStub(), // pushAdapter
);
});
afterAll(async () => {
await harness?.close();
});
beforeEach(() => {
smsClient.sendSms.mockClear();
emailClient.sendEmail.mockClear();
});
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) {
const train = await harness.prisma.train.create({
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
});
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
});
const seats = await Promise.all(
["1A", "1B"].map((seatNumber, i) =>
harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
}),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: opts.departureAt.toISOString(),
arrivalAt: opts.arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}
function baseDto(overrides: Partial<Record<string, any>> = {}) {
return {
scheduleId: "", // filled per-test
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
// No seatClassId — the seat's class is resolved server-side from its coach type +
// nationality tier (Djiboutian -> LOCAL, matching seed-core's seatClassLocal).
bookingKind: ReservationBookingKind.STAFF,
passengerName: "Test Traveler",
dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT,
passportNumber: "X123456",
nationality: "Djiboutian",
...overrides,
};
}
it("404s when the seat has no active reservation", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await expect(
guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id }) as any,
null,
),
).rejects.toThrow(/not reserved/i);
});
it("resolves the seat class (and therefore fare) from nationality — LOCAL for Ethiopian/Djiboutian, INTERNATIONAL for Other", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
// A->B is 100km (seed-core DISTANCE). LOCAL seatClass is 3.00 ETB/km, INTERNATIONAL is
// 5.00 ETB/km, USD_TO_ETB=100 — same formula pricing-fare-engine.e2e-spec.ts verifies.
// Both use PASSENGER kind so the computed fare survives unwaived (STAFF always zeroes
// totalMinor/fareMinor regardless of the resolved seat class).
const localSchedule = await createTestSchedule({ trainNumber: `RES-FARE-LOCAL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(localSchedule.seats[0].id, "Reserved", localSchedule.schedule.id);
const localBooking: any = await guestBookingService.issueBookingFromReservation(
localSchedule.seats[0].id,
baseDto({ scheduleId: localSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+251911234567", nationality: "Ethiopian" }) as any,
null,
);
const localSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: localBooking.booking.id } });
expect(localSeat?.fareMinor).toBe(30_000);
const intlSchedule = await createTestSchedule({ trainNumber: `RES-FARE-INTL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(intlSchedule.seats[0].id, "Reserved", intlSchedule.schedule.id);
const intlBooking: any = await guestBookingService.issueBookingFromReservation(
intlSchedule.seats[0].id,
baseDto({ scheduleId: intlSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567", nationality: "Other" }) as any,
null,
);
expect(intlBooking.booking.totalMinor).toBe(50_000);
});
it("rejects a nationality outside Ethiopian/Djiboutian/Other at the DTO level", () => {
const dto = plainToInstance(IssueReservationBookingDto, baseDto({ scheduleId: "irrelevant", nationality: "French" }));
const errors = validateSync(dto);
expect(errors.some((e) => e.property === "nationality")).toBe(true);
});
it("STAFF + a GLOBAL block: booking is CONFIRMED, fee-waived, ticket issued, and the old global block is replaced (not left stale)", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-GLOBAL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "VIP hold — mayor's office");
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
"staff-user-1",
);
expect(result.booking.status).toBe("CONFIRMED");
expect(result.payUrl).toBeUndefined();
const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } });
expect(intent?.amountMinor).toBe(0);
expect(intent?.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(intent?.method).toBe(PaymentMethodType.WALLET);
const ticketCount = await harness.prisma.ticket.count({ where: { bookingId: result.booking.id } });
expect(ticketCount).toBeGreaterThan(0);
// TicketsService.generate() itself creates a fresh global SeatBlock ("Booked in
// tickets ...") once a ticket is actually issued — a ticketed seat SHOULD show as
// unavailable. The meaningful assertion is that the ORIGINAL reservation's block is
// gone (proving unblockSeat ran), replaced by exactly this one new booked-marker block —
// not that the seat is left globally BLOCKED under the old reservation's reason forever.
const blocks = await harness.prisma.seatBlock.findMany({ where: { seatId: seats[0].id } });
expect(blocks).toHaveLength(1);
expect(blocks[0].reason).not.toMatch(/VIP hold/);
expect(blocks[0].reason).toMatch(/Booked in tickets/);
const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
expect(seat?.status).toBe("BOOKED");
});
it("STAFF + a SCHEDULE-SCOPED block: same release path, no leftover reservation block", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-SCOPED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved for inspection", schedule.id);
const seatBefore = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
expect(seatBefore?.status).not.toBe("BLOCKED"); // schedule-scoped block never touches Seat.status
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
"staff-user-2",
);
expect(result.booking.status).toBe("CONFIRMED");
// The original schedule-scoped reservation block is gone; only generate()'s own
// booked-marker block remains (see the global-block test above for why).
const originalScopedBlock = await harness.prisma.seatBlock.count({
where: { seatId: seats[0].id, scheduleId: schedule.id, reason: "Reserved for inspection" },
});
expect(originalScopedBlock).toBe(0);
// Same as the global-block test — generate() marks the now-ticketed seat BOOKED.
const seatAfter = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
expect(seatAfter?.status).toBe("BOOKED");
});
it("PASSENGER path: booking stays PENDING_PAYMENT, payToken is set, no PaymentIntent yet, SMS sent with the pay link", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-3",
);
expect(result.booking.status).toBe("PENDING_PAYMENT");
expect(result.booking.payToken).toBeTruthy();
expect(result.payUrl).toContain(result.booking.payToken);
const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } });
expect(intent).toBeNull();
expect(smsClient.sendSms).toHaveBeenCalledTimes(1);
const smsArgs = smsClient.sendSms.mock.calls[0][0];
expect(smsArgs.to).toBe("+253771234567");
expect(smsArgs.message).toContain(result.booking.payToken);
// BookingsService.getByPayToken — the portal pay-by-link page's data source.
const byToken: any = await bookingsService.getByPayToken(result.booking.payToken);
expect(byToken.id).toBe(result.booking.id);
expect(byToken.status).toBe("PENDING_PAYMENT");
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
});
it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => {
// Regression for: the customer got TWO conflicting messages for one reservation —
// the reservation-specific /reserve/pay/<payToken> SMS from issueBookingFromReservation,
// AND a second, generic booking.created notification pointing at /booking/detail?ref=,
// a page that doesn't work for a traveler with no portal session.
await resetAndSeedCore(harness.prisma);
await harness.prisma.notificationTemplate.upsert({
where: { code: "booking.created" },
update: { active: true },
create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true },
});
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-5",
);
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS
// Directly invoke the event handler (the test harness's eventEmitter is a stub, so the
// real 'booking.created' emit from issueBookingFromReservation never reaches it) — this
// is what NotificationsService would have done had it received that event.
await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } });
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped
expect(emailClient.sendEmail).not.toHaveBeenCalled();
// Control: a normal (non-reservation) booking must still get the generic notification.
const passenger = await harness.prisma.passenger.create({ data: {} });
const normalBooking = await harness.prisma.booking.create({
data: {
bookingRef: `WEB-CTRL-${Date.now()}`,
passengerId: passenger.id,
scheduleId: schedule.id,
status: "PENDING_PAYMENT",
totalMinor: 10_000,
contactPhone: "+251911234567",
source: "WEB",
},
});
await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } });
expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings
});
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
// no JourneySegment (only created on payment success) the instant the payment link went
// out — fully bookable by the general public before the traveler had even paid.
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-4",
);
expect(result.booking.status).toBe("PENDING_PAYMENT");
// A member of the public trying to hold the exact same seat/leg must be rejected —
// proves the seat is covered by a real SeatHold (or equivalent), not silently free.
await expect(
seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
} as any),
).rejects.toThrow(/already (held|booked)/i);
});
it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-7",
);
const payToken = result.booking.payToken;
const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7");
expect(cancelResult.cancelled).toBe(true);
expect(cancelResult.bookingRef).toBe(result.booking.bookingRef);
const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } });
expect(cancelledBooking?.status).toBe("CANCELLED");
// The seat is genuinely free — a member of the public can now hold it.
await expect(
seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
} as any),
).resolves.toBeTruthy();
// The old payment link no longer works.
await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i);
});
it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await expect(
bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"),
).rejects.toThrow(/no pending reservation/i);
});
it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
// Seat 0: PASSENGER reservation — still PENDING_PAYMENT.
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
const pending: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
"staff-user-6",
);
// Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately.
await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id);
const staffResult: any = await guestBookingService.issueBookingFromReservation(
seats[1].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
"staff-user-6",
);
const seatMap: any = await seatsService.getSeatMap(schedule.id);
const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []);
const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id);
const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id);
expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef);
expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT");
expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock
expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef);
expect(ticketedSeat.reservationStatus).toBe("CONFIRMED");
});
it("requires a phone number for a PASSENGER booking", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-NOPHONE-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
await expect(
guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: undefined }) as any,
null,
),
).rejects.toThrow(/phone number is required/i);
});
it("rejects NATIONAL_ID for a non-Ethiopian nationality", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NATID-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
await expect(
guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({
scheduleId: schedule.id,
nationality: "Djiboutian",
idDocumentType: IdDocumentType.NATIONAL_ID,
idDocumentNumber: "ET123456789",
}) as any,
null,
),
).rejects.toThrow(/national id is only valid for ethiopian/i);
});
it("still enforces the check-in cutoff — rejects once the boarding stop is too close to departure", async () => {
await resetAndSeedCore(harness.prisma);
const dep = new Date(Date.now() + 5 * 60_000); // 5min out — inside the default 30-min cutoff
const arr = new Date(dep.getTime() + 50 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CUTOFF-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
await expect(
guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, originStationId: IDS.stationA, destinationStationId: IDS.stationB }) as any,
null,
),
).rejects.toThrow(/not accepted within/i);
});
it("getByPayToken 404s for an unknown token and 400s once expired", async () => {
await resetAndSeedCore(harness.prisma);
await expect(bookingsService.getByPayToken("no-such-token")).rejects.toThrow(/not found/i);
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-EXPIRED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
const result: any = await guestBookingService.issueBookingFromReservation(
seats[0].id,
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
null,
);
await harness.prisma.booking.update({
where: { id: result.booking.id },
data: { payTokenExpiresAt: new Date(Date.now() - 60_000) },
});
await expect(bookingsService.getByPayToken(result.booking.payToken)).rejects.toThrow(/expired/i);
});
});

View File

@@ -0,0 +1,206 @@
/**
* Schedule lifecycle coverage — bulkGenerateSchedules, createSchedule's "must have at least
* one coach assigned" guard (added recently — the exact validation whose ordering broke
* checkin-cutoff.e2e-spec.ts's fixture helper earlier this session), and
* TasksService.syncScheduleStatuses' full schedule-level state machine
* (SCHEDULED -> BOARDING -> EN_ROUTE -> ARRIVED). checkin-cutoff.e2e-spec.ts already covers
* the per-stop CHECKIN_CLOSED/OPEN half of syncScheduleStatuses — not repeated here.
*/
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { TasksService } from "../src/modules/tasks/tasks.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
describe("Schedule lifecycle — bulk generate, coach guard, status transitions", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let tasksService: TasksService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub());
});
afterAll(async () => {
await harness?.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
});
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
async function createCoach(trainNumber: string) {
return harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
});
}
describe("createSchedule coach-assignment guard", () => {
it("rejects a schedule with no coachIds and no route coach template", async () => {
const train = await harness.prisma.train.create({ data: { number: `SCH-NOCOACH-${Date.now()}`, name: "Test" } });
await expect(
schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: future(180).toISOString(),
arrivalAt: future(280).toISOString(),
} as any),
).rejects.toThrow(/must have at least one coach assigned/i);
const orphan = await harness.prisma.trainSchedule.findFirst({ where: { trainId: train.id } });
expect(orphan).toBeNull(); // no dead schedule left behind
});
it("auto-applies the route's coach template when coachIds are omitted", async () => {
const train = await harness.prisma.train.create({ data: { number: `SCH-TEMPLATE-${Date.now()}`, name: "Test" } });
const coach = await createCoach(train.number);
await harness.prisma.routeCoachTemplate.create({
data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 },
});
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: future(180).toISOString(),
arrivalAt: future(280).toISOString(),
} as any);
const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } });
expect(assignments).toHaveLength(1);
expect(assignments[0].coachId).toBe(coach.id);
});
it("explicit coachIds override the route's coach template", async () => {
const train = await harness.prisma.train.create({ data: { number: `SCH-OVERRIDE-${Date.now()}`, name: "Test" } });
const templateCoach = await createCoach(`${train.number}-tmpl`);
const explicitCoach = await createCoach(`${train.number}-explicit`);
await harness.prisma.routeCoachTemplate.create({
data: { routeId: IDS.route, coachId: templateCoach.id, positionNumber: 1 },
});
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: future(180).toISOString(),
arrivalAt: future(280).toISOString(),
coachIds: [explicitCoach.id],
} as any);
const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } });
expect(assignments).toHaveLength(1);
expect(assignments[0].coachId).toBe(explicitCoach.id);
});
});
describe("bulkGenerateSchedules", () => {
it("generates one schedule per repeat interval across the date range", async () => {
const train = await harness.prisma.train.create({ data: { number: `BULK-${Date.now()}`, name: "Test" } });
const coach = await createCoach(train.number);
await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } });
const start = future(2 * 24 * 60); // 2 days out, clear of "today" edge cases
const result = await schedulesService.bulkGenerateSchedules({
trainId: train.id,
routeId: IDS.route,
startDateTime: start.toISOString(),
forNextDays: 5,
repeatEveryDays: 2,
durationHours: 3,
} as any);
// while(currentDate < endDate) stepping by 2 days over a 5-day window: day 0, 2, 4.
expect(result.schedulesCreated).toBe(3);
expect(result.errors).toHaveLength(0);
expect(result.scheduleIds).toHaveLength(3);
const schedules = await harness.prisma.trainSchedule.findMany({ where: { trainId: train.id }, orderBy: { departureAt: "asc" } });
expect(schedules).toHaveLength(3);
const daysBetween = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / (24 * 60 * 60 * 1000));
expect(daysBetween(schedules[0].departureAt, schedules[1].departureAt)).toBe(2);
expect(daysBetween(schedules[1].departureAt, schedules[2].departureAt)).toBe(2);
});
it("collects per-day errors without aborting the rest of the run", async () => {
const train = await harness.prisma.train.create({ data: { number: `BULK-ERR-${Date.now()}`, name: "Test" } });
const coach = await createCoach(train.number);
await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } });
const start = future(2 * 24 * 60);
const dto = {
trainId: train.id,
routeId: IDS.route,
startDateTime: start.toISOString(),
forNextDays: 4,
repeatEveryDays: 1,
durationHours: 3,
} as any;
const first = await schedulesService.bulkGenerateSchedules(dto);
expect(first.schedulesCreated).toBe(4);
expect(first.errors).toHaveLength(0);
// Re-running the exact same range collides with EVERY day just created (same
// train+route+date already exists) — proves errors are collected per-day, not thrown,
// and the count/errors array accurately reflect zero successes this time.
const second = await schedulesService.bulkGenerateSchedules(dto);
expect(second.schedulesCreated).toBe(0);
expect(second.errors).toHaveLength(4);
expect(second.errors[0]).toMatch(/already exists/i);
});
});
describe("syncScheduleStatuses — schedule-level state machine", () => {
async function scheduleWithCoach(trainNumber: string, departureAt: Date, arrivalAt: Date) {
const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } });
const coach = await createCoach(trainNumber);
return schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
}
it("SCHEDULED -> BOARDING once within the 30-min cutoff window", async () => {
const schedule = await scheduleWithCoach(`SYNC-BOARD-${Date.now()}`, future(20), future(80));
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("SCHEDULED");
await tasksService.syncScheduleStatuses();
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("BOARDING");
});
it("BOARDING -> EN_ROUTE once departure has passed", async () => {
const schedule = await scheduleWithCoach(`SYNC-ENROUTE-${Date.now()}`, future(20), future(80));
await tasksService.syncScheduleStatuses();
await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: new Date(Date.now() - 60_000) } });
await tasksService.syncScheduleStatuses();
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("EN_ROUTE");
});
it("EN_ROUTE -> ARRIVED once arrival has passed", async () => {
const schedule = await scheduleWithCoach(`SYNC-ARRIVED-${Date.now()}`, future(20), future(80));
await tasksService.syncScheduleStatuses();
await harness.prisma.trainSchedule.update({
where: { id: schedule.id },
data: { departureAt: new Date(Date.now() - 120_000), arrivalAt: new Date(Date.now() - 60_000) },
});
await tasksService.syncScheduleStatuses();
await tasksService.syncScheduleStatuses(); // BOARDING->EN_ROUTE and EN_ROUTE->ARRIVED are separate updateMany calls in one pass — one call suffices, second call is a no-op idempotency check
expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("ARRIVED");
});
});
});

View File

@@ -95,6 +95,9 @@ describe("Stop-based booking — segment time & cutoff correctness", () => {
asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs
fareEngine,
{ emit: () => true } as any, // eventEmitter
asyncStub(), // paymentsService — never reached: these tests use createGuestBooking, not issueBookingFromReservation
asyncStub(), // auditService
asyncStub(), // smsClient
);
});

View File

@@ -0,0 +1,282 @@
/**
* Ticketing/boarding coverage — TicketsService.generate()/scanAndBoard()/validate() and the
* smart-seat-reassignment fallback, none of which had any e2e coverage before this suite
* (colocated tickets.service.spec.ts only covers offline batch validation, a different path).
*
* IMPORTANT: scanAndBoard() catches every internal error and returns
* `{ success: false, error, errorCode }` rather than throwing — assertions against it check
* the return value, not `.rejects.toThrow()`.
*
* Same Tier-2 direct-instantiation pattern as reserve-seat-issue-booking.e2e-spec.ts /
* booking-types.e2e-spec.ts.
*/
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { TicketsService } from "../src/modules/tickets/tickets.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
function asyncStub(): any {
return new Proxy({}, { get: () => async () => undefined });
}
describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let ticketsService: TicketsService;
let guestBookingService: GuestBookingService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const systemConfig = new SystemConfigService(harness.prisma as any);
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
guestBookingService = new GuestBookingService(
harness.prisma as any,
seatsService,
asyncStub(),
currencyService,
asyncStub(),
fareEngine,
{ emit: () => true } as any,
asyncStub(), // paymentsService — not used; tests drive generate()/scanAndBoard() directly
asyncStub(),
asyncStub(),
);
});
afterAll(async () => {
await harness?.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
});
async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date; seatCount?: number }) {
const train = await harness.prisma.train.create({
data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
});
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: opts.seatCount ?? 4, sequence: 1, status: "ACTIVE" },
});
const seatCount = opts.seatCount ?? 4;
const seatNumbers = Array.from({ length: seatCount }, (_, i) => `1${String.fromCharCode(65 + i)}`);
const seats = await Promise.all(
seatNumbers.map((seatNumber, i) =>
harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
}),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
departureAt: opts.departureAt.toISOString(),
arrivalAt: opts.arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, seats };
}
function passengerDto(seatId: string, overrides: Partial<Record<string, any>> = {}) {
return {
seatId,
passengerName: "Test Traveler",
dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT,
passportNumber: "X123456",
passportCountry: "Djibouti",
nationality: "Djiboutian",
...overrides,
};
}
const future = (mins: number) => new Date(Date.now() + mins * 60_000);
/** Creates a real PENDING_PAYMENT ONE_WAY booking via the guest flow. */
async function createOneWayBooking(scheduleId: string, seatId: string, originStationId = IDS.stationA, destinationStationId = IDS.stationB) {
const hold = await seatsService.holdSeats({
scheduleId,
originStationId,
destinationStationId,
passengers: [{ passengerId: "55555555-5555-4555-8555-500000000001", seatId }],
} as any);
return guestBookingService.createGuestBooking({
scheduleId,
holdId: (hold as any).holdId,
originStationId,
destinationStationId,
seatClassId: IDS.seatClassLocal,
passengers: [passengerDto(seatId)],
} as any) as Promise<any>;
}
async function markSucceeded(bookingId: string) {
await harness.prisma.paymentIntent.create({
data: { bookingId, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() },
});
await harness.prisma.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED" } });
}
describe("generate()", () => {
it("issues a ticket for a CONFIRMED booking with a SUCCEEDED PaymentIntent, and marks the seat BOOKED", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-GEN-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await markSucceeded(booking.id);
const result = await ticketsService.generate(booking.id);
expect(result.totalTickets).toBe(1);
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
expect(ticket).toBeTruthy();
expect(ticket?.qrPayload).toBeTruthy();
expect(ticket?.barcodePayload).toBeTruthy();
const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
expect(seat?.status).toBe("BOOKED");
});
it("rejects with 'Payment not completed' when there's no PaymentIntent at all", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-NOPI-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i);
});
it("rejects with 'Payment not completed' when the PaymentIntent hasn't succeeded", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-PENDING-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await harness.prisma.paymentIntent.create({
data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.PROCESSING },
});
await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i);
});
it("auto-confirms a PENDING_PAYMENT booking whose PaymentIntent already SUCCEEDED (missed webhook)", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-HEAL-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
expect(booking.status).toBe("PENDING_PAYMENT");
await harness.prisma.paymentIntent.create({
data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() },
});
await ticketsService.generate(booking.id);
const refreshed = await harness.prisma.booking.findUnique({ where: { id: booking.id } });
expect(refreshed?.status).toBe("CONFIRMED");
});
});
describe("smart seat reassignment", () => {
it("generate() throws ConflictException on a genuinely overlapping confirmed seat; smartAssignAndGenerate() recovers onto a free seat", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-CONFLICT-${Date.now()}`, departureAt: future(180), arrivalAt: future(220), seatCount: 3 });
// Booking A already CONFIRMED on seats[0], full A->B route (matches the schedule's own
// origin/destination, guaranteeing segment overlap with anything else on seats[0]).
const passengerA = await harness.prisma.passenger.create({ data: {} });
const bookingA = await harness.prisma.booking.create({
data: {
bookingRef: `CONFA${Date.now()}`, passengerId: passengerA.id, scheduleId: schedule.id,
originStationId: IDS.stationA, destinationStationId: IDS.stationB,
status: "CONFIRMED", totalMinor: 30_000, displayCurrency: "ETB",
seats: { create: [{ seatId: seats[0].id, scheduleId: schedule.id, passengerName: "Passenger A", passengerCategory: "ADULT", fareMinor: 30_000, displayCurrency: "ETB" }] },
},
});
void bookingA;
// Booking B independently references the SAME seat (simulating however the conflict
// arose — the point of this test is the recovery path, not the cause).
const bookingB = await createOneWayBooking(schedule.id, seats[0].id);
await markSucceeded(bookingB.id);
await expect(ticketsService.generate(bookingB.id)).rejects.toThrow(/already confirmed for another booking/i);
const recovered = await ticketsService.smartAssignAndGenerate(bookingB.id);
expect(recovered.totalTickets).toBe(1);
const bookingSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: bookingB.id } });
expect(bookingSeat?.seatId).not.toBe(seats[0].id); // reassigned off the conflicting seat
expect([seats[1].id, seats[2].id]).toContain(bookingSeat?.seatId);
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: bookingB.id } });
expect(ticket?.seatId).toBe(bookingSeat?.seatId);
});
});
describe("scanAndBoard()", () => {
it("boards successfully within the boarding window", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-OK-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await markSucceeded(booking.id);
await ticketsService.generate(booking.id);
const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
expect(result.success).toBe(true);
expect(result.boarding?.seat).toBe(seats[0].seatNumber);
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
expect(ticket?.validatedAt).toBeTruthy();
});
it("refuses boarding before the boarding window opens", async () => {
// Default boarding window is 4h (SystemConfigService DEFAULTS) — 6h out is still closed.
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-EARLY-${Date.now()}`, departureAt: future(360), arrivalAt: future(420) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await markSucceeded(booking.id);
await ticketsService.generate(booking.id);
const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
expect(result.success).toBe(false);
expect(result.error).toMatch(/Boarding opens \d+ hour\(s\) before departure/i);
});
it("refuses boarding once departure has passed", async () => {
// Book while departure is comfortably in the future (holdSeats itself refuses a hold
// within 30min of departure), THEN move departure into the past — both the schedule's
// own departureAt AND the origin stop's plannedDepartureAt, since scanAndBoard resolves
// the boarding time via resolveBookingSegment(), which prefers the TripStopTime over
// the raw schedule field.
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-LATE-${Date.now()}`, departureAt: future(180), arrivalAt: future(240) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await markSucceeded(booking.id);
await ticketsService.generate(booking.id);
const past = new Date(Date.now() - 60_000);
await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: past } });
await harness.prisma.tripStopTime.updateMany({ where: { scheduleId: schedule.id, stationId: IDS.stationA }, data: { plannedDepartureAt: past } });
const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
expect(result.success).toBe(false);
expect(result.error).toMatch(/Boarding is closed/i);
});
});
describe("validate()", () => {
it("ONE_WAY: a second validate() call on the same ticket is idempotent, not an error", async () => {
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-VAL-ONEWAY-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) });
const booking = await createOneWayBooking(schedule.id, seats[0].id);
await markSucceeded(booking.id);
await ticketsService.generate(booking.id);
const first = await ticketsService.validate(booking.bookingRef, "gate-validator-1");
expect(first.validated).toBe(true);
expect((first as any).alreadyValidated).toBeUndefined();
const second = await ticketsService.validate(booking.bookingRef, "gate-validator-1");
expect(second.validated).toBe(true);
expect((second as any).alreadyValidated).toBe(true);
});
});
});