import { ConflictException } from '@nestjs/common'; import { BookingBatchService } from './booking-batch.service'; import { Booking } from '../bookings/entities/booking.entity'; /** * Export whole-booking single-train gate: an export booking never splits — it * rides one train whole or is rejected. The report must try every fillable * train on the day (first full → use the second), and when none fits, say how * much space is still bookable so the customer knows what he CAN book. */ describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => { const DAY = new Date('2026-07-20T10:00:00Z'); const schedule = (id: string) => ({ id, status: 'SCHEDULED', direction: 'EXPORT', scheduledDepartureDate: DAY, bookingWindowStatus: 'OPEN', windowPhase: null, // legacy gate: OPEN alone makes it fillable }); const fullGraph = (id: string) => ({ id, originStationId: 'yard-a', destinationStationId: 'yard-b', routeId: null, // legacy two-stop pseudo-route — no milestone query scheduleBookings: [], trainSet: { locomotive: { maxPullWeightTons: 500, maxTrainLengthMeters: 140, overageToleranceTons: 0, overageToleranceMeters: 0, }, }, }); const exportBooking = (cargoTons: number) => ({ id: 'bk-exp', freightType: 'BULK', tradeDirection: 'EXPORT', scheduledDate: DAY, originYardId: 'yard-a', destinationYardId: 'yard-b', cargoTotalWeightVgm: cargoTons, bookingContainers: [], }) as unknown as Booking; // A reserved bulk booking heavy enough to exhaust the 500t pull budget. const heavyReserved = { id: 'bk-heavy', freightType: 'BULK', originYardId: 'yard-a', destinationYardId: 'yard-b', cargoTotalWeightVgm: 476, bookingContainers: [], } as unknown as Booking; let service: BookingBatchService; let trainSchedulesRepository: { findAll: jest.Mock; findByIdWithFullGraph: jest.Mock; findById: jest.Mock; }; let bookingsRepository: { findReservedForSchedule: jest.Mock }; beforeEach(() => { trainSchedulesRepository = { findAll: jest.fn().mockResolvedValue([]), findByIdWithFullGraph: jest .fn() .mockImplementation(async (id: string) => fullGraph(id)), findById: jest.fn(), }; bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) }; // WagonType.find() → [] so representative default dims apply (bulk 60t // payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null). const genericRepo = { find: jest.fn().mockResolvedValue([]) }; const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) }; service = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, {} as never, // trainScheduleBookingsRepository {} as never, // notifier {} as never, // scheduler {} as never, // trainSchedulingService {} as never, // billing {} as never, // bookingWindowGateway {} as never, // pricingService ); }); it('uses the other train when the first one is full', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ schedule('train-1'), schedule('train-2'), ]); bookingsRepository.findReservedForSchedule.mockImplementation( async (id: string) => (id === 'train-1' ? [heavyReserved] : []), ); const report = await service.exportSpaceReport(exportBooking(60)); expect(report.scheduleId).toBe('train-2'); }); it('rejects a booking no single train fits and reports the bookable space', async () => { trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]); const report = await service.exportSpaceReport(exportBooking(900)); expect(report.scheduleId).toBeNull(); expect(report.bestAvailable).not.toBeNull(); expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0); expect(report.bestAvailable!.cargoTons).toBeLessThan(900); expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/); expect(report.fullMessage).toMatch(/single train whole/); await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow( ConflictException, ); }); it('says no train is accepting bookings when the day has none', async () => { trainSchedulesRepository.findAll.mockResolvedValue([]); const report = await service.exportSpaceReport(exportBooking(60)); expect(report.scheduleId).toBeNull(); expect(report.fullMessage).toBe( 'No export train is accepting bookings for this day', ); }); describe('dayImportAvailability (advisory, summed across the day)', () => { const DAY_STR = '2026-07-20'; const importSchedule = (id: string, over: Record = {}) => ({ id, status: 'SCHEDULED', direction: 'IMPORT', scheduledDepartureDate: DAY, bookingWindowStatus: 'OPEN', windowPhase: 'OPEN', // still OPEN — the advisory ignores the fill phase ...over, }); // A bulk booking small enough to fit; freeWagons is what matters, not `fits`. const importBooking = (cargoTons: number) => ({ id: 'bk-imp', freightType: 'BULK', tradeDirection: 'IMPORT', originYardId: 'yard-a', destinationYardId: 'yard-b', cargoTotalWeightVgm: cargoTons, bookingContainers: [], }) as unknown as Booking; it('sums free wagons across every import train on the day', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ importSchedule('train-1'), importSchedule('train-2'), ]); const one = await service.dayImportAvailability( importBooking(60), DAY_STR, ); // Re-run with a single train to prove two trains sum to double one train. trainSchedulesRepository.findAll.mockResolvedValue([ importSchedule('train-1'), ]); const solo = await service.dayImportAvailability( importBooking(60), DAY_STR, ); expect(solo.freeWagons).toBeGreaterThan(0); expect(one.freeWagons).toBe(solo.freeWagons * 2); expect(one.trainsForDay).toBe(true); }); it('ignores EXPORT trains — they are not part of the import pool', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ importSchedule('train-1'), { ...importSchedule('train-2'), direction: 'EXPORT' }, ]); const both = await service.dayImportAvailability( importBooking(60), DAY_STR, ); trainSchedulesRepository.findAll.mockResolvedValue([ importSchedule('train-1'), ]); const solo = await service.dayImportAvailability( importBooking(60), DAY_STR, ); expect(both.freeWagons).toBe(solo.freeWagons); }); it('ignores FULL trains', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ importSchedule('train-1', { bookingWindowStatus: 'FULL' }), ]); const report = await service.dayImportAvailability( importBooking(60), DAY_STR, ); expect(report.freeWagons).toBe(0); expect(report.trainsForDay).toBe(false); }); it('nets out capacity already held by reserved bookings', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ importSchedule('train-1'), ]); const empty = await service.dayImportAvailability( importBooking(60), DAY_STR, ); bookingsRepository.findReservedForSchedule.mockResolvedValue([ heavyReserved, ]); const withHold = await service.dayImportAvailability( importBooking(60), DAY_STR, ); expect(withHold.freeWagons).toBeLessThan(empty.freeWagons); }); }); });