/** * Ticketing/boarding coverage — TicketsService.generate()/scanAndBoard()/validate() and the * smart-seat-reassignment fallback, none of which had any e2e coverage before this suite * (colocated tickets.service.spec.ts only covers offline batch validation, a different path). * * IMPORTANT: scanAndBoard() catches every internal error and returns * `{ success: false, error, errorCode }` rather than throwing — assertions against it check * the return value, not `.rejects.toThrow()`. * * Same Tier-2 direct-instantiation pattern as reserve-seat-issue-booking.e2e-spec.ts / * booking-types.e2e-spec.ts. */ import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client"; import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; import { TicketsService } from "../src/modules/tickets/tickets.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 { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; function asyncStub(): any { return new Proxy({}, { get: () => async () => undefined }); } describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", () => { let harness: ServiceHarness; let schedulesService: SchedulesService; let seatsService: SeatsService; let ticketsService: TicketsService; let guestBookingService: GuestBookingService; 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); seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); guestBookingService = new GuestBookingService( harness.prisma as any, seatsService, asyncStub(), currencyService, asyncStub(), fareEngine, { emit: () => true } as any, asyncStub(), // paymentsService — not used; tests drive generate()/scanAndBoard() directly asyncStub(), asyncStub(), ); }); afterAll(async () => { await harness?.close(); }); beforeEach(async () => { await resetAndSeedCore(harness.prisma); }); async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date; seatCount?: number }) { 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: opts.seatCount ?? 4, sequence: 1, status: "ACTIVE" }, }); const seatCount = opts.seatCount ?? 4; const seatNumbers = Array.from({ length: seatCount }, (_, i) => `1${String.fromCharCode(65 + i)}`); const seats = await Promise.all( seatNumbers.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 passengerDto(seatId: string, overrides: Partial> = {}) { return { seatId, passengerName: "Test Traveler", dateOfBirth: "1990-01-01", idDocumentType: IdDocumentType.PASSPORT, passportNumber: "X123456", passportCountry: "Djibouti", nationality: "Djiboutian", ...overrides, }; } const future = (mins: number) => new Date(Date.now() + mins * 60_000); /** Creates a real PENDING_PAYMENT ONE_WAY booking via the guest flow. */ async function createOneWayBooking(scheduleId: string, seatId: string, originStationId = IDS.stationA, destinationStationId = IDS.stationB) { const hold = await seatsService.holdSeats({ scheduleId, originStationId, destinationStationId, passengers: [{ passengerId: "55555555-5555-4555-8555-500000000001", seatId }], } as any); return guestBookingService.createGuestBooking({ scheduleId, holdId: (hold as any).holdId, originStationId, destinationStationId, seatClassId: IDS.seatClassLocal, passengers: [passengerDto(seatId)], } as any) as Promise; } async function markSucceeded(bookingId: string) { await harness.prisma.paymentIntent.create({ data: { bookingId, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() }, }); await harness.prisma.booking.update({ where: { id: bookingId }, data: { status: "CONFIRMED" } }); } describe("generate()", () => { it("issues a ticket for a CONFIRMED booking with a SUCCEEDED PaymentIntent, and marks the seat BOOKED", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-GEN-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await markSucceeded(booking.id); const result = await ticketsService.generate(booking.id); expect(result.totalTickets).toBe(1); const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); expect(ticket).toBeTruthy(); expect(ticket?.qrPayload).toBeTruthy(); expect(ticket?.barcodePayload).toBeTruthy(); const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } }); expect(seat?.status).toBe("BOOKED"); }); it("rejects with 'Payment not completed' when there's no PaymentIntent at all", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-NOPI-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i); }); it("rejects with 'Payment not completed' when the PaymentIntent hasn't succeeded", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-PENDING-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await harness.prisma.paymentIntent.create({ data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.PROCESSING }, }); await expect(ticketsService.generate(booking.id)).rejects.toThrow(/Payment not completed/i); }); it("auto-confirms a PENDING_PAYMENT booking whose PaymentIntent already SUCCEEDED (missed webhook)", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-HEAL-${Date.now()}`, departureAt: future(180), arrivalAt: future(220) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); expect(booking.status).toBe("PENDING_PAYMENT"); await harness.prisma.paymentIntent.create({ data: { bookingId: booking.id, amountMinor: 30_000, currency: "ETB", method: PaymentMethodType.CARD, status: PaymentIntentStatus.SUCCEEDED, paidAt: new Date() }, }); await ticketsService.generate(booking.id); const refreshed = await harness.prisma.booking.findUnique({ where: { id: booking.id } }); expect(refreshed?.status).toBe("CONFIRMED"); }); }); describe("smart seat reassignment", () => { it("generate() throws ConflictException on a genuinely overlapping confirmed seat; smartAssignAndGenerate() recovers onto a free seat", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-CONFLICT-${Date.now()}`, departureAt: future(180), arrivalAt: future(220), seatCount: 3 }); // Booking A already CONFIRMED on seats[0], full A->B route (matches the schedule's own // origin/destination, guaranteeing segment overlap with anything else on seats[0]). const passengerA = await harness.prisma.passenger.create({ data: {} }); const bookingA = await harness.prisma.booking.create({ data: { bookingRef: `CONFA${Date.now()}`, passengerId: passengerA.id, scheduleId: schedule.id, originStationId: IDS.stationA, destinationStationId: IDS.stationB, status: "CONFIRMED", totalMinor: 30_000, displayCurrency: "ETB", seats: { create: [{ seatId: seats[0].id, scheduleId: schedule.id, passengerName: "Passenger A", passengerCategory: "ADULT", fareMinor: 30_000, displayCurrency: "ETB" }] }, }, }); void bookingA; // Booking B independently references the SAME seat (simulating however the conflict // arose — the point of this test is the recovery path, not the cause). const bookingB = await createOneWayBooking(schedule.id, seats[0].id); await markSucceeded(bookingB.id); await expect(ticketsService.generate(bookingB.id)).rejects.toThrow(/already confirmed for another booking/i); const recovered = await ticketsService.smartAssignAndGenerate(bookingB.id); expect(recovered.totalTickets).toBe(1); const bookingSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: bookingB.id } }); expect(bookingSeat?.seatId).not.toBe(seats[0].id); // reassigned off the conflicting seat expect([seats[1].id, seats[2].id]).toContain(bookingSeat?.seatId); const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: bookingB.id } }); expect(ticket?.seatId).toBe(bookingSeat?.seatId); }); }); describe("scanAndBoard()", () => { it("boards successfully within the boarding window", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-OK-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await markSucceeded(booking.id); await ticketsService.generate(booking.id); const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); expect(result.success).toBe(true); expect(result.boarding?.seat).toBe(seats[0].seatNumber); const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } }); expect(ticket?.validatedAt).toBeTruthy(); }); it("refuses boarding before the boarding window opens", async () => { // Default boarding window is 4h (SystemConfigService DEFAULTS) — 6h out is still closed. const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-EARLY-${Date.now()}`, departureAt: future(360), arrivalAt: future(420) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await markSucceeded(booking.id); await ticketsService.generate(booking.id); const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); expect(result.success).toBe(false); expect(result.error).toMatch(/Boarding opens \d+ hour\(s\) before departure/i); }); it("refuses boarding once departure has passed", async () => { // Book while departure is comfortably in the future (holdSeats itself refuses a hold // within 30min of departure), THEN move departure into the past — both the schedule's // own departureAt AND the origin stop's plannedDepartureAt, since scanAndBoard resolves // the boarding time via resolveBookingSegment(), which prefers the TripStopTime over // the raw schedule field. const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-LATE-${Date.now()}`, departureAt: future(180), arrivalAt: future(240) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await markSucceeded(booking.id); await ticketsService.generate(booking.id); const past = new Date(Date.now() - 60_000); await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: past } }); await harness.prisma.tripStopTime.updateMany({ where: { scheduleId: schedule.id, stationId: IDS.stationA }, data: { plannedDepartureAt: past } }); const result = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1"); expect(result.success).toBe(false); expect(result.error).toMatch(/Boarding is closed/i); }); }); describe("validate()", () => { it("ONE_WAY: a second validate() call on the same ticket is idempotent, not an error", async () => { const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-VAL-ONEWAY-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) }); const booking = await createOneWayBooking(schedule.id, seats[0].id); await markSucceeded(booking.id); await ticketsService.generate(booking.id); const first = await ticketsService.validate(booking.bookingRef, "gate-validator-1"); expect(first.validated).toBe(true); expect((first as any).alreadyValidated).toBeUndefined(); const second = await ticketsService.validate(booking.bookingRef, "gate-validator-1"); expect(second.validated).toBe(true); expect((second as any).alreadyValidated).toBe(true); }); }); });