mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
262 lines
7.8 KiB
TypeScript
262 lines
7.8 KiB
TypeScript
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<typeof seatRow>;
|
|
}> = {},
|
|
) {
|
|
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<typeof blockRow>[];
|
|
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']);
|
|
});
|
|
});
|