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