import { BookingTransitionService } from './booking-transition.service'; import { Booking } from './entities/booking.entity'; /** * Staff decisions on a consolidated pair. Two bookings sharing a wagon must move * together: accepting one alone would put half a wagon into the approval chain, * and cancelling one alone would strand the other on a wagon it can no longer * fill. All-or-nothing — if either half throws, neither booking moved. */ describe('BookingTransitionService — paired staff decisions', () => { function makeService(booking: Partial) { const bookingsService = { findById: jest.fn().mockResolvedValue(booking as Booking), }; // Runs the callback so a throw propagates, which is what the all-or-nothing // guarantee reduces to from this service's point of view. const dataSource = { transaction: jest.fn(async (cb: () => Promise) => cb()), }; const service = new BookingTransitionService( {} as never, // bookingsRepository {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, {} as never, // bookingClearanceService {} as never, // workflowService {} as never, // invoiceService {} as never, // containerValidationService {} as never, // notifier {} as never, // events undefined, // milestoneService dataSource as never, ); return { service, dataSource }; } const paired = { id: 'b-1', reference: 'BK-1', consolidationPartnerId: 'b-2', } as Booking; it('accepts both halves with the same validity window', async () => { const { service } = makeService(paired); const accept = jest .spyOn(service, 'acceptIntake') .mockImplementation(async (id) => ({ id }) as Booking); const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', { validityDays: 30, }); expect(accept).toHaveBeenCalledTimes(2); expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30); expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30); expect(result.booking.id).toBe('b-1'); expect(result.partner.id).toBe('b-2'); }); it('cancels both halves with the same reason', async () => { const { service } = makeService(paired); const cancel = jest .spyOn(service, 'cancel') .mockImplementation(async (id) => ({ id }) as Booking); await service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'customer withdrew', }); expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); }); it('propagates a failure on the second half so neither is committed', async () => { const { service, dataSource } = makeService(paired); jest .spyOn(service, 'cancel') .mockImplementationOnce(async (id) => ({ id }) as Booking) .mockImplementationOnce(async () => { throw new Error('partner is already in transit'); }); await expect( service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), ).rejects.toThrow('partner is already in transit'); // Both halves ran inside one transaction, so the throw rolls the first back. expect(dataSource.transaction).toHaveBeenCalledTimes(1); }); it('refuses a booking that has no partner', async () => { const { service } = makeService({ id: 'b-1', consolidationPartnerId: null, } as Booking); await expect( service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), ).rejects.toThrow(/no consolidation partner/i); }); it('requires a validity window to accept', async () => { const { service } = makeService(paired); const accept = jest.spyOn(service, 'acceptIntake'); await expect( service.applyPairedDecision('b-1', 'accept', 'staff-1', {}), ).rejects.toThrow(/validity/i); expect(accept).not.toHaveBeenCalled(); }); it('routes operationAccept through the operation review on both halves', async () => { const { service } = makeService(paired); const review = jest .spyOn(service, 'reviewOperationRequest') .mockImplementation(async (id) => ({ id }) as Booking); await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {}); expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', { note: undefined, }); expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', { note: undefined, }); }); });