import { BadRequestException, ConflictException } from '@nestjs/common'; import { BookingTransitionService } from './booking-transition.service'; /** * Operation-request review for general-contract drawdown orders: * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/ * domestic bookings wait for their booking-day window cycle (no immediate * batch enqueue at accept time). * - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { const booking = { id: 'b-1', reference: 'BKG-1', status: 'OPERATION_REQUEST_PENDING', originYardId: 'o-1', destinationYardId: 'd-1', scheduledDate: new Date('2026-07-01T00:00:00.000Z'), serviceType: { code: serviceTypeCode }, }; const bookingsRepository = { update: jest.fn().mockResolvedValue({ id: 'b-1' }), createReviewNote: jest.fn().mockResolvedValue(undefined), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), pickExportSchedule: jest.fn(), acceptExportBooking: jest.fn(), }; const invoiceService = { ensureInvoiceForBooking: jest .fn() .mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }), updateStatus: jest.fn().mockResolvedValue(undefined), }; const service = new BookingTransitionService( bookingsRepository as never, {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, // workflowService invoiceService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { accepted: jest.fn(), approved: jest.fn(), rejected: jest.fn(), changesRequested: jest.fn(), documentQueried: jest.fn(), clearanceReady: jest.fn(), operationChangesRequested: jest.fn(), operationAccepted: jest.fn(), inTransit: jest.fn(), completed: jest.fn(), cancelled: jest.fn(), submittedToStaff: jest.fn(), customerSignedToStaff: jest.fn(), operationRequestedToStaff: jest.fn(), clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier ); return { service, bookingsRepository, bookingBatchService, invoiceService }; } it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => { const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('RAIL_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'FULLY_EXECUTED' }), ); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); // Import/domestic train bookings are batched by the window cycle later — // never enqueued directly at accept time. expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled(); }); it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => { const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('ROAD_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), ); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); }); it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => { const { service, bookingsRepository } = makeService('RAIL_CONTAINER'); await expect( service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}), ).rejects.toBeInstanceOf(BadRequestException); await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', { note: 'Fix the schedule', }); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }), ); }); }); /** * Export over-book gate at the customer's requestOperation step: export never * splits, so the free-space check runs the moment the customer commits to a * shipment day. When no single export train that day can carry the whole * booking, `pickExportSchedule` throws and the request is refused BEFORE the * booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated * here (they are batched + splittable later). */ describe('BookingTransitionService — requestOperation export space gate', () => { function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) { const booking = { id: 'b-1', reference: 'BKG-1', status: 'CLEARANCE_READY', tradeDirection, originYardId: 'o-1', destinationYardId: 'd-1', totalAmount: 1000, contractId: null, serviceType: { code: 'RAIL_CONTAINER' }, }; const bookingsRepository = { update: jest.fn().mockResolvedValue({ id: 'b-1' }), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), checkDayCompatibilityForBooking: jest .fn() .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), }; const bookingBatchService = { // Over-book → the export gate rejects; otherwise it returns a schedule id. pickExportSchedule: overbook ? jest.fn().mockRejectedValue(new ConflictException('Not enough train space')) : jest.fn().mockResolvedValue('sched-1'), }; const notifier = { operationRequestedToStaff: jest.fn() }; const service = new BookingTransitionService( bookingsRepository as never, {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, notifier as never, ); return { service, bookingsRepository, bookingBatchService }; } it('rejects an over-booked export request and does NOT advance the booking', async () => { const { service, bookingsRepository, bookingBatchService } = makeService( 'EXPORT', true, ); await expect( service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'), ).rejects.toBeInstanceOf(ConflictException); expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); expect(bookingsRepository.update).not.toHaveBeenCalled(); }); it('lets an export request through when a train fits the whole booking', async () => { const { service, bookingsRepository, bookingBatchService } = makeService( 'EXPORT', false, ); await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), ); }); it('never runs the export gate for an import request', async () => { const { service, bookingsRepository, bookingBatchService } = makeService( 'IMPORT', true, // would reject IF called — proves it is not called ); await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'); expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled(); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), ); }); });