import { BadRequestException } from '@nestjs/common'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { SchedulingRescheduleService } from './scheduling-reschedule.service'; const makeBooking = ( id: string, reference: string, extra: Record = {}, ) => ({ id, reference, freightType: 'CONTAINER', cargoTotalWeightVgm: 100, scheduledDate: new Date('2026-06-20T08:00:00.000Z'), originYardId: 'yard-origin', destinationYardId: 'yard-destination', status: 'PAID', isGovernment: false, priorityScore: 50, bookingContainers: [ { id: `${id}-line`, wagonsRequired: 5, quantity: 1, vgmPerUnitTons: 100, }, ], ...extra, }); describe('compareSchedulingPriority', () => { it('orders government before commercial', () => { const sorted = [ { isGovernment: false, priorityScore: 50000, scheduledDate: new Date('2026-06-20'), }, { isGovernment: true, priorityScore: 100, scheduledDate: new Date('2026-06-25'), }, ].sort(compareSchedulingPriority); expect(sorted[0]?.isGovernment).toBe(true); }); }); describe('SchedulingRescheduleService', () => { let service: SchedulingRescheduleService; let trainSchedulesRepository: Record; let bookingsRepository: Record; let trainSchedulingService: Record; let schedulingRescheduleRepository: Record; beforeEach(() => { trainSchedulesRepository = { findByIdWithFullGraph: jest.fn(), updateStatus: jest.fn(), }; bookingsRepository = { findByIdsForScheduling: jest.fn(), updateSchedulingFields: jest.fn(), }; trainSchedulingService = { previewTrainSchedule: jest.fn(), unassignBooking: jest.fn(), assignBookingsToSchedule: jest.fn(), }; schedulingRescheduleRepository = { createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), }; service = new SchedulingRescheduleService( trainSchedulesRepository as never, bookingsRepository as never, trainSchedulingService as never, schedulingRescheduleRepository as never, ); }); it('rejects reschedule on dispatched trains', async () => { trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'sched-1', status: 'DISPATCHED', scheduleBookings: [], }); await expect( service.previewReschedule('sched-1', { incomingBookingIds: ['gov-1'], trigger: 'GOVERNMENT_PREEMPT', }), ).rejects.toBeInstanceOf(BadRequestException); }); it('displaces lower-priority commercial when government incoming exceeds capacity', async () => { const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false }); const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000, governmentInstitution: 'Ministry', }); trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'sched-1', status: 'DRAFT', scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), originStationId: 'yard-origin', destinationStationId: 'yard-destination', scheduleBookings: [{ bookingId: 'c1', booking: commercial }], }); bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); trainSchedulingService.previewTrainSchedule.mockImplementation( async ({ bookingIds }: { bookingIds: string[] }) => ({ valid: bookingIds.length <= 1, violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [], warnings: [], }), ); const plan = await service.previewReschedule('sched-1', { incomingBookingIds: ['g1'], trigger: 'GOVERNMENT_PREEMPT', }); expect(plan.retained.map((b) => b.id)).toEqual(['g1']); expect(plan.displaced.map((b) => b.id)).toEqual(['c1']); expect(plan.finalBookingIds).toEqual(['g1']); }); it('readmits high-priority commercial when spare capacity remains', async () => { const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 }); const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 }); const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 }); trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'sched-1', status: 'DRAFT', scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), originStationId: 'yard-origin', destinationStationId: 'yard-destination', scheduleBookings: [ { bookingId: 'c-low', booking: low }, { bookingId: 'c-high', booking: high }, ], }); bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); const fitAttempts = new Map(); trainSchedulingService.previewTrainSchedule.mockImplementation( async ({ bookingIds }: { bookingIds: string[] }) => { const key = [...bookingIds].sort().join(','); const attempt = (fitAttempts.get(key) ?? 0) + 1; fitAttempts.set(key, attempt); const fits = bookingIds.length === 1 || (key === 'c-high,g1' && attempt > 1); return { valid: fits, violations: fits ? [] : ['Train capacity exceeded'], warnings: [], }; }, ); const plan = await service.previewReschedule('sched-1', { incomingBookingIds: ['g1'], trigger: 'GOVERNMENT_PREEMPT', }); expect(plan.retained.map((b) => b.id)).toEqual(['g1']); expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']); expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']); expect(plan.finalBookingIds).toEqual(['g1', 'c-high']); }); it('maintenance reschedule updates departure and rebalances bookings', async () => { const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 }); const schedule = { id: 'sched-1', status: 'DRAFT', scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), originStationId: 'yard-origin', destinationStationId: 'yard-destination', scheduleBookings: [{ bookingId: 'c1', booking: commercial }], }; trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]); trainSchedulingService.previewTrainSchedule.mockResolvedValue({ valid: true, violations: [], warnings: [], }); trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); const result = await service.maintenanceReschedule( 'sched-1', { incomingBookingIds: ['c1'], trigger: 'TRAIN_MAINTENANCE', reason: 'Locomotive service', newDepartureDate: '2026-06-22T10:00:00.000Z', }, 'staff-1', ); expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( 'sched-1', 'DRAFT', { scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') }, ); expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( expect.objectContaining({ trigger: 'TRAIN_MAINTENANCE', actorUserId: 'staff-1', reason: 'Locomotive service', }), ); expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE'); expect(result.plan.finalBookingIds).toEqual(['c1']); }); });