Files
edr-platform/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts

557 lines
28 KiB
TypeScript

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