/** * 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); }); }); });