Adding train delay minutes and also disabling no schedule days in the booking selection

This commit is contained in:
Muluhabt
2026-07-27 14:42:57 +03:00
parent d175514f85
commit d71f6dc1b3
19 changed files with 845 additions and 54 deletions

View File

@@ -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);