import { ConflictException } from '@nestjs/common'; import { TrainSchedulingService } from './train-scheduling.service'; const nw5 = { id: 'wagon-type-1', code: 'NW5', name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, }; const locomotive = { id: 'loc-1', code: 'LOC-001', maxPullWeightTons: 3500, maxTrainLengthMeters: 760, status: 'AVAILABLE', }; const makeBooking = ( id: string, reference: string, weight: number, quantity: number, containerCode: string, scheduledDate = '2026-06-20T08:00:00.000Z', originYardId = 'yard-origin', destinationYardId = 'yard-destination', ) => ({ id, reference, freightType: 'CONTAINER', cargoTotalWeightVgm: weight, scheduledDate: new Date(scheduledDate), originYardId, destinationYardId, status: 'PAID', customer: { companyName: 'Demo Customer' }, originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, bookingContainers: [ { quantity, containerType: { code: containerCode, label: containerCode }, }, ], }); describe('TrainSchedulingService', () => { let service: TrainSchedulingService; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; }; let locomotivesRepository: { findById: jest.Mock; }; let wagonTypesRepository: { findAll: jest.Mock; }; beforeEach(() => { dataSource = { getRepository: jest.fn(), transaction: jest.fn(), }; locomotivesRepository = { findById: jest.fn(), }; wagonTypesRepository = { findAll: jest.fn(), }; service = new TrainSchedulingService( dataSource as never, locomotivesRepository as never, wagonTypesRepository as never, ); }); it('computes the expected valid preview for Group A', async () => { const bookings = [ makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'), ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); dataSource.getRepository.mockImplementation((entity: { name?: string }) => { if (entity?.name === 'Booking') { return { find: jest.fn().mockResolvedValue(bookings) }; } if (entity?.name === 'TrainScheduleBooking') { return { find: jest.fn().mockResolvedValue([]) }; } if (entity?.name === 'Locomotive') { return { count: jest.fn().mockResolvedValue(2), find: jest.fn().mockResolvedValue([locomotive]), }; } throw new Error(`Unexpected repository ${entity?.name}`); }); const result = await service.previewContainerTrainSchedule({ bookingIds: bookings.map((booking) => booking.id), scheduleDate: '2026-06-20T08:00:00.000Z', originStationId: 'yard-origin', destinationStationId: 'yard-destination', }); expect(result.valid).toBe(true); expect(result.violations).toEqual([]); expect(result.summary).toEqual({ totalBookings: 3, totalWeightTons: 1250, wagonType: 'NW5', wagonsNeeded: 18, totalLengthMeters: 252, }); expect(result.wagonPlan).toHaveLength(18); expect(result.wagonPlan[0]?.allocations[0]).toEqual({ bookingId: 'b1', bookingReference: 'BKG-CONT-001', allocatedWeightTons: 70, }); }); it('flags the overweight booking as invalid', async () => { const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); dataSource.getRepository.mockImplementation((entity: { name?: string }) => { if (entity?.name === 'Booking') { return { find: jest.fn().mockResolvedValue(bookings) }; } if (entity?.name === 'TrainScheduleBooking') { return { find: jest.fn().mockResolvedValue([]) }; } if (entity?.name === 'Locomotive') { return { count: jest.fn().mockResolvedValue(1), find: jest.fn().mockResolvedValue([locomotive]), }; } throw new Error(`Unexpected repository ${entity?.name}`); }); const result = await service.previewContainerTrainSchedule({ bookingIds: ['b6'], scheduleDate: '2026-06-20T08:00:00.000Z', originStationId: 'yard-origin', destinationStationId: 'yard-destination', }); expect(result.valid).toBe(false); expect(result.summary.totalWeightTons).toBe(3600); expect(result.violations).toContain( 'Total booking weight 3600T exceeds max train weight 3500T', ); }); it('rejects bookings that are not in schedulable status', async () => { const bookings = [ { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'), status: 'APPROVED', }, ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); dataSource.getRepository.mockImplementation((entity: { name?: string }) => { if (entity?.name === 'Booking') { return { find: jest.fn().mockResolvedValue(bookings) }; } if (entity?.name === 'TrainScheduleBooking') { return { find: jest.fn().mockResolvedValue([]) }; } if (entity?.name === 'Locomotive') { return { count: jest.fn().mockResolvedValue(1), find: jest.fn().mockResolvedValue([locomotive]), }; } throw new Error(`Unexpected repository ${entity?.name}`); }); const result = await service.previewContainerTrainSchedule({ bookingIds: ['b7'], scheduleDate: '2026-06-20T08:00:00.000Z', originStationId: 'yard-origin', destinationStationId: 'yard-destination', }); expect(result.valid).toBe(false); expect(result.violations).toContain( 'Only PAID bookings can be scheduled; received: APPROVED', ); }); it('creates a schedule transactionally when validation passes', async () => { const route = { id: 'route-1', name: 'Djibouti to Addis', originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, }; const lockedLocomotiveRepo = { findOne: jest.fn().mockResolvedValue(locomotive), update: jest.fn().mockResolvedValue(undefined), }; const trainScheduleRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), }; const manager = { getRepository: jest.fn((entity: { name?: string }) => { switch (entity?.name) { case 'Locomotive': return lockedLocomotiveRepo; case 'TrainSchedule': return trainScheduleRepo; case 'TrainSet': return trainSetRepo; default: throw new Error(`Unexpected transaction repository ${entity?.name}`); } }), }; jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); dataSource.getRepository.mockImplementation((entity: { name?: string }) => { if (entity?.name === 'Route') { return { findOne: jest.fn().mockResolvedValue(route) }; } throw new Error(`Unexpected repository ${entity?.name}`); }); jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); const result = await service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', locomotiveId: 'loc-1', }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); expect(result).toEqual({ id: 'schedule-1' }); }); it('rejects create when the locked locomotive is no longer available', async () => { const manager = { getRepository: jest.fn(() => ({ findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), })), }; jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); dataSource.getRepository.mockImplementation((entity: { name?: string }) => { if (entity?.name === 'Route') { return { findOne: jest.fn().mockResolvedValue({ id: 'route-1', name: 'Djibouti to Addis', originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, }), }; } throw new Error(`Unexpected repository ${entity?.name}`); }); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); await expect( service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', locomotiveId: 'loc-1', }), ).rejects.toBeInstanceOf(ConflictException); }); });