Files
edr-platform/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts
2026-07-24 09:01:27 +03:00

367 lines
19 KiB
TypeScript

/**
* Stop-based (mid-route) booking — segment correctness suite.
*
* Regression coverage for three bugs reported against live stop-based bookings:
*
* 1. Search results (SearchService.buildScheduleResult) showed the train's overall
* departure/arrival instead of the selected origin/destination stop's own time — e.g.
* searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in
* resolving the boarding/alighting STATION correctly for a mid-route segment while still
* reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also
* used by BookingsService, TicketsService, NotificationsService) — see
* src/common/utils/segment-resolver.utils.ts.
* 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin
* departure regardless of where the passenger actually boards, so a schedule whose origin
* had already departed >30min ago wrongly blocked booking a downstream segment that
* hadn't closed yet.
* 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30
* minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff
* that SeatsService.holdSeats and the search step already enforce. A passenger who passed
* the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly
* rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes
* of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff()
* utility as SeatsService.holdSeats and SearchService — see
* src/common/utils/checkin-cutoff.utils.ts.
*
* Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual
* cumulative travel-time interpolation in SchedulesService.createSchedule, same as
* checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService
* are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ),
* so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the
* Tier-2 pattern in money-integrity.e2e-spec.ts.
*/
import { IdDocumentType } from "@prisma/client";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { SegmentsService } from "../src/modules/segments/segments.service";
import { SearchService } from "../src/modules/search/search.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 { 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 });
}
/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host —
* matches search.service.ts's "+03:00" date-matching window). */
function localDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
describe("Stop-based booking — segment time & cutoff correctness", () => {
let harness: ServiceHarness;
let schedulesService: SchedulesService;
let seatsService: SeatsService;
let searchService: SearchService;
let bookingsService: BookingsService;
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 segmentsService = new SegmentsService(harness.prisma as any);
const systemConfig = new SystemConfigService(harness.prisma as any);
searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService);
// holdSeats() itself never touches segmentsService (only getSeatMap/availability-map
// callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts.
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
bookingsService = new BookingsService(
harness.prisma as any,
asyncStub(), // dataSource
seatsService,
{ emit: () => true } as any, // eventEmitter
asyncStub(), // verifaydaService
currencyService,
fareEngine,
asyncStub(), // auditService
);
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 these DTOs
fareEngine,
{ emit: () => true } as any, // eventEmitter
);
});
afterAll(async () => {
await harness?.close();
});
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation
* (createSchedule now rejects a schedule with zero coaches). */
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", "1C", "1D"].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 foreignPassenger(seatId: string) {
return {
seatId,
passengerName: "Test Passenger",
dateOfBirth: "1990-01-01",
idDocumentType: IdDocumentType.PASSPORT,
passportNumber: "X123456",
passportCountry: "Djibouti",
nationality: "Djiboutian",
};
}
describe("search results (SearchService.searchTrips)", () => {
it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const result: any = await searchService.searchTrips({
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
date: localDateStr(dep),
adultCount: 1,
} as any);
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
expect(found).toBeTruthy();
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime());
// Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback.
expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime());
});
it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival
const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const result: any = await searchService.searchTrips({
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
date: localDateStr(dep),
adultCount: 1,
} as any);
const found = result.outbound.find((o: any) => o.scheduleId === schedule.id);
expect(found).toBeTruthy();
const expectedBArrival = new Date(dep.getTime() + 60 * 60_000);
expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime());
// Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback.
expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime());
});
});
describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => {
it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the
// passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 190 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }],
} as any);
const booking: any = await guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: (hold as any).holdId,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any);
expect(booking.bookingRef).toBeTruthy();
expect(booking.originStationId).toBe(IDS.stationB);
expect(booking.destinationStationId).toBe(IDS.stationC);
});
it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created
// directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based
// cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 50 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await harness.prisma.seatHold.create({
data: {
scheduleId: schedule.id,
seatIds: [seats[0].id],
passengerId: "77777777-7777-4777-8777-777777777777",
expiresAt: new Date(Date.now() + 10 * 60_000),
},
});
await expect(
guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: hold.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any),
).rejects.toThrow(/not accepted within 30 minutes/i);
});
it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => {
// Regression for the reported bug: /booking/review still rejected a booking with
// "not accepted within 30 minutes of departure" even after the passenger passed the
// earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to
// enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route
// .checkinMinutesBefore. B's own configured cutoff here is 10 minutes.
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff,
// but outside B's own configured 10-min cutoff.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 60 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await seatsService.holdSeats({
scheduleId: schedule.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }],
} as any);
const booking: any = await guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: (hold as any).holdId,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any);
expect(booking.bookingRef).toBeTruthy();
});
it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => {
await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
// B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff
// (would have wrongly been allowed), but inside B's own configured 90-min cutoff.
const dep = new Date(Date.now() + 5 * 60_000);
const arr = new Date(dep.getTime() + 80 * 60_000);
const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const hold = await harness.prisma.seatHold.create({
data: {
scheduleId: schedule.id,
seatIds: [seats[0].id],
passengerId: "99999999-9999-4999-8999-999999999999",
expiresAt: new Date(Date.now() + 10 * 60_000),
},
});
await expect(
guestBookingService.createGuestBooking({
scheduleId: schedule.id,
holdId: hold.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
seatClassId: IDS.seatClassLocal,
passengers: [foreignPassenger(seats[0].id)],
} as any),
).rejects.toThrow(/not accepted within 90 minutes/i);
});
});
describe("booking detail & list segment resolution (BookingsService)", () => {
it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => {
await resetAndSeedCore(harness.prisma);
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } });
await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } });
const dep = new Date(Date.now() + 3 * 60 * 60_000);
const arr = new Date(dep.getTime() + 100 * 60_000);
const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
const passenger = await harness.prisma.passenger.create({ data: {} });
const booking = await harness.prisma.booking.create({
data: {
bookingRef: `SEGDET${Date.now()}`,
passengerId: passenger.id,
scheduleId: schedule.id,
originStationId: IDS.stationB,
destinationStationId: IDS.stationC,
status: "CONFIRMED",
totalMinor: 10000,
displayCurrency: "ETB",
},
});
const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000);
const detail: any = await bookingsService.getByRef(booking.bookingRef);
expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
expect(detail.schedule.origin.id).toBe(IDS.stationB);
expect(detail.schedule.destination.id).toBe(IDS.stationC);
const list: any = await bookingsService.findByPassengerId(passenger.id);
expect(list.items).toHaveLength(1);
expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime());
expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB);
});
});
});