mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
557 lines
28 KiB
TypeScript
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);
|
|
});
|
|
});
|