mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
370 lines
19 KiB
TypeScript
370 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
|
|
asyncStub(), // paymentsService — never reached: these tests use createGuestBooking, not issueBookingFromReservation
|
|
asyncStub(), // auditService
|
|
asyncStub(), // smsClient
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|