import { ReportsService } from './reports.service'; /** * Covers the blocked-seat half of the seat status report. * * The count used to be a raw `SeatBlock` row count with an exact `type === 'dining'` * exclusion. Real EDR data stores display names in `CoachType.type` ('Dining Coach '), * so dining seats slipped through, and every global block counted even when its coach * never joined the train. These cases pin the corrected rule. */ const SCHEDULE_ID = 'sched-1'; const DEPARTURE = new Date('2026-03-10T06:00:00.000Z'); interface CoachSpec { id: string; number: string; typeType?: string; typeName?: string; } const passengerCoach: CoachSpec = { id: 'coach-1', number: 'C1' }; const diningCoach: CoachSpec = { id: 'coach-dining', number: 'D1', // As the data actually looks: display name in `type`, trailing space included. typeType: 'Dining Coach ', typeName: 'Dining Coach', }; function seatRow( id: string, coach: CoachSpec, seatNumber: string, bedPosition: string | null = null, ) { return { id, coachId: coach.id, seatNumber, bedPosition, coach: { number: coach.number, coachType: { name: coach.typeName ?? 'Standard', type: coach.typeType ?? 'passenger', seatClasses: [{ name: 'Economy', bedPosition: null }], }, }, }; } function blockRow( overrides: Partial<{ id: string; scheduleId: string | null; reason: string; blockedAt: Date; unblockAt: Date | null; seat: ReturnType; }> = {}, ) { return { id: 'block-1', scheduleId: SCHEDULE_ID as string | null, reason: 'VIP hold', blockedBy: 'user-1', blockedAt: new Date('2026-03-01T00:00:00.000Z'), unblockAt: null as Date | null, seat: seatRow('seat-1', passengerCoach, '1'), ...overrides, }; } function makeService(opts: { blocks: ReturnType[]; assignedCoachIds?: string[]; departureAt?: Date | null; bookingSeats?: any[]; }) { const prisma = { bookingSeat: { findMany: jest.fn().mockResolvedValue(opts.bookingSeats ?? []) }, seatHold: { findMany: jest.fn().mockResolvedValue([]) }, trainSchedule: { findUnique: jest.fn().mockResolvedValue( opts.departureAt === null ? null : { departureAt: opts.departureAt ?? DEPARTURE }, ), }, coachAssignment: { findMany: jest .fn() .mockResolvedValue( (opts.assignedCoachIds ?? [passengerCoach.id, diningCoach.id]).map((coachId) => ({ coachId, })), ), }, seatBlock: { findMany: jest.fn().mockResolvedValue(opts.blocks) }, }; return { service: new ReportsService(prisma as any, {} as any, {} as any), prisma, }; } describe('getSeatStatusReport — blocked seats', () => { it('counts a schedule-scoped block on a passenger coach', async () => { const { service } = makeService({ blocks: [blockRow()] }); const report = await service.getSeatStatusReport(SCHEDULE_ID); expect(report.summary.blockedCount).toBe(1); expect(report.blockedSeats).toHaveLength(1); expect(report.blockedSeats[0]).toMatchObject({ coachNumber: 'C1', seatNumber: '1' }); }); it('leaves out a dining coach whose type carries a display name', async () => { const { service } = makeService({ blocks: [blockRow({ seat: seatRow('seat-d', diningCoach, '1') })], }); const report = await service.getSeatStatusReport(SCHEDULE_ID); expect(report.summary.blockedCount).toBe(0); expect(report.blockedSeats).toEqual([]); }); it('leaves out placeholder seats', async () => { const { service } = makeService({ blocks: [blockRow({ seat: seatRow('seat-p', passengerCoach, '-1') })], }); expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); }); it('counts a global block on a coach assigned to this train', async () => { const { service } = makeService({ blocks: [blockRow({ scheduleId: null })], assignedCoachIds: [passengerCoach.id], }); expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(1); }); it('ignores a global block whose coach never joined this train', async () => { const { service } = makeService({ blocks: [blockRow({ scheduleId: null })], assignedCoachIds: ['some-other-coach'], }); expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); }); it('ignores a global block that had already been lifted by departure', async () => { const { service } = makeService({ blocks: [ blockRow({ scheduleId: null, unblockAt: new Date('2026-03-05T00:00:00.000Z'), }), ], assignedCoachIds: [passengerCoach.id], }); expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); }); it('ignores a global block created after departure', async () => { const { service } = makeService({ blocks: [blockRow({ scheduleId: null, blockedAt: new Date('2026-03-20T00:00:00.000Z') })], assignedCoachIds: [passengerCoach.id], }); expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); }); it('counts a seat blocked both globally and for this schedule once', async () => { const seat = seatRow('seat-1', passengerCoach, '1'); const { service } = makeService({ blocks: [ blockRow({ id: 'block-schedule', seat, reason: 'Crew seat' }), blockRow({ id: 'block-global', scheduleId: null, seat, reason: 'Broken armrest' }), ], assignedCoachIds: [passengerCoach.id], }); const report = await service.getSeatStatusReport(SCHEDULE_ID); expect(report.summary.blockedCount).toBe(1); // The schedule-scoped block is the more specific statement, so it is the one shown. expect(report.blockedSeats[0].reason).toBe('Crew seat'); }); it('reports nothing blocked when the schedule does not exist', async () => { const { service } = makeService({ blocks: [blockRow({ scheduleId: null })], departureAt: null, }); expect((await service.getSeatStatusReport(SCHEDULE_ID)).summary.blockedCount).toBe(0); }); it('asks the database only for non-maintenance, non-ticketing blocks', async () => { const { service, prisma } = makeService({ blocks: [] }); await service.getSeatStatusReport(SCHEDULE_ID); const where = prisma.seatBlock.findMany.mock.calls[0][0].where; expect(where.NOT).toEqual([ { reason: { startsWith: 'MAINTENANCE:' } }, { reason: { startsWith: 'Booked in tickets' } }, ]); }); }); describe('getSeatStatusReport — booked seats', () => { const booking = { bookingRef: 'BK-1', status: 'CONFIRMED', totalMinor: 20000, currency: 'ETB', createdAt: new Date('2026-03-01T00:00:00.000Z'), paymentIntent: { status: 'SUCCEEDED' }, }; it('keeps dining-coach seats out of the paid and unpaid counts', async () => { const { service } = makeService({ blocks: [], bookingSeats: [ { passengerName: 'Abebe', passengerCategory: 'ADULT', fareMinor: 20000, booking, seat: seatRow('seat-1', passengerCoach, '1'), }, { passengerName: 'Diner', passengerCategory: 'ADULT', fareMinor: 0, booking, seat: seatRow('seat-d', diningCoach, '1'), }, { passengerName: 'Kebede', passengerCategory: 'ADULT', fareMinor: 20000, booking: { ...booking, status: 'PENDING_PAYMENT', paymentIntent: null }, seat: seatRow('seat-2', passengerCoach, '2'), }, ], }); const report = await service.getSeatStatusReport(SCHEDULE_ID); expect(report.summary.paidCount).toBe(1); expect(report.summary.unpaidCount).toBe(1); expect(report.paidSeats.map((s) => s.passengerName)).toEqual(['Abebe']); }); });