Files
edr-platform/apps/edr-passenger-api/test/ticketing.e2e-spec.ts

283 lines
14 KiB
TypeScript

/**
* 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<Record<string, any>> = {}) {
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<any>;
}
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);
});
});
});