mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding ticket generation and booking for staff employees logic
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
365
apps/edr-passenger-api/test/booking-types.e2e-spec.ts
Normal file
365
apps/edr-passenger-api/test/booking-types.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
146
apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts
Normal file
146
apps/edr-passenger-api/test/currency-through-booking.e2e-spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
249
apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts
Normal file
249
apps/edr-passenger-api/test/payments-webhook-refund.e2e-spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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 { 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 { 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 smsClient: { sendSms: 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);
|
||||
|
||||
seatsService = new SeatsService(harness.prisma as any, asyncStub(), 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,
|
||||
{ emit: () => true } as any,
|
||||
asyncStub(), // verifaydaService
|
||||
currencyService,
|
||||
fareEngine,
|
||||
asyncStub(), // auditService
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
smsClient.sendSms.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("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);
|
||||
});
|
||||
});
|
||||
206
apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts
Normal file
206
apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
282
apps/edr-passenger-api/test/ticketing.e2e-spec.ts
Normal file
282
apps/edr-passenger-api/test/ticketing.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user