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