mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding train delay minutes and also disabling no schedule days in the booking selection
This commit is contained in:
@@ -26,6 +26,7 @@ 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";
|
||||
@@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config.
|
||||
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";
|
||||
|
||||
@@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
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();
|
||||
@@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||
|
||||
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||
// 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,
|
||||
@@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
harness.prisma as any,
|
||||
asyncStub(), // dataSource
|
||||
seatsService,
|
||||
{ emit: () => true } as any,
|
||||
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 () => {
|
||||
@@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
smsClient.sendSms.mockClear();
|
||||
emailClient.sendEmail.mockClear();
|
||||
});
|
||||
|
||||
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
|
||||
@@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
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
|
||||
@@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
||||
).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);
|
||||
|
||||
Reference in New Issue
Block a user