import { BadRequestException } from '@nestjs/common'; import { BookingTransitionService } from './booking-transition.service'; /** * Focused tests for the clearance 100%-approved gate in finalizeClearance. * Uses minimal stubs for the service's collaborators. */ describe('BookingTransitionService — finalizeClearance gate', () => { const booking = { id: 'b-1', status: 'DOCUMENTS_UNDER_REVIEW', tradeDirection: 'IMPORT', freightType: 'CONTAINER', serviceType: { includesCustoms: false }, // no output set → only the input gate }; // Input set has two required docs. const inputSetting = { code: 'clearance_import_container_without_customs', fields: [ { fileKey: 'commercial_invoice', isRequired: true }, { fileKey: 'packing_list', isRequired: true }, ], }; function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) { const bookingsRepository = { findDocumentReviews: jest.fn().mockResolvedValue(reviews), update: jest.fn().mockResolvedValue({ id: 'b-1' }), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), }; const fileUploadSettingsService = { getByCode: jest.fn().mockResolvedValue(inputSetting), }; const filesService = { findByResource: jest.fn().mockResolvedValue([]) }; const service = new BookingTransitionService( bookingsRepository as never, {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } it('rejects when a required document is not APPROVED', async () => { const { service } = makeService([ { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED', }, // packing_list is still PENDING (missing approval) ]); await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf( BadRequestException, ); }); it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => { const { service, bookingsRepository } = makeService([ { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' }, { settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' }, ]); await service.finalizeClearance('b-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'CLEARANCE_READY' }), ); }); }); /** * Customs bookings additionally require the GL output documents before * finalizing — they are cleared by Global Logistics, not the customer alone. */ describe('BookingTransitionService — finalizeClearance customs output gate', () => { const customsBooking = { id: 'b-2', status: 'DOCUMENTS_UNDER_REVIEW', tradeDirection: 'IMPORT', freightType: 'CONTAINER', serviceType: { includesCustoms: true }, // input + output sets apply }; const inputSetting = { code: 'clearance_import_container_with_customs', fields: [{ fileKey: 'commercial_invoice', isRequired: true }], }; const outputSetting = { code: 'clearance_output_import_container', fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }], }; function makeCustomsService(uploadedOutputCodes: string[]) { const bookingsRepository = { findDocumentReviews: jest.fn().mockResolvedValue([ { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' }, ]), update: jest.fn().mockResolvedValue({ id: 'b-2' }), }; const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) }; const fileUploadSettingsService = { getByCode: jest.fn((code: string) => Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting), ), }; const filesService = { findByResource: jest .fn() .mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))), }; const service = new BookingTransitionService( bookingsRepository as never, {} as never, {} as never, {} as never, {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } it('rejects when required customs output documents are missing', async () => { const { service } = makeCustomsService([]); // no output uploaded await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf( BadRequestException, ); }); it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => { const { service, bookingsRepository } = makeCustomsService(['im4']); await service.finalizeClearance('b-2'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-2', expect.objectContaining({ status: 'CLEARANCE_READY' }), ); }); }); /** * The first clearance submission (AWAITING_DOCUMENTS) must include every * required input document; subsequent re-uploads during review only need the * specific files being fixed, so already-uploaded required docs stay in place. */ describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => { const inputSetting = { code: 'clearance_import_container_without_customs', fields: [ { fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true }, { fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true }, ], }; function makeService(status: string, existingCodes: string[]) { const bookingsRepository = { upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue({ id: 'b-3' }), }; const booking = { id: 'b-3', status, tradeDirection: 'IMPORT', freightType: 'CONTAINER', serviceType: { includesCustoms: false }, }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking) }; const fileUploadSettingsService = { getByCode: jest.fn().mockResolvedValue(inputSetting), }; const filesService = { findByResource: jest .fn() .mockResolvedValue(existingCodes.map((code) => ({ code }))), upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }), }; const service = new BookingTransitionService( bookingsRepository as never, {} as never, {} as never, {} as never, {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, filesService }; } function fakeFile(fieldname: string): Express.Multer.File { return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File; } it('rejects the first submission when a required document is missing', async () => { const { service } = makeService('AWAITING_DOCUMENTS', []); await expect( service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]), ).rejects.toBeInstanceOf(BadRequestException); }); it('accepts the first submission when every required document is provided', async () => { const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []); await service.submitClearanceDocuments('b-3', [ fakeFile('commercial_invoice'), fakeFile('packing_list'), ]); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-3', expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }), ); }); it('allows re-uploading a single queried document during review without re-sending the rest', async () => { // packing_list was already uploaded in the first round; the customer is now // only re-uploading the queried commercial_invoice. const { service, bookingsRepository } = makeService( 'DOCUMENTS_UNDER_REVIEW', ['packing_list'], ); await service.submitClearanceDocuments('b-3', [ fakeFile('commercial_invoice'), ]); // Only the re-uploaded doc is touched — no full re-gate, no rework on the rest. expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1); expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith( expect.objectContaining({ fileKey: 'commercial_invoice' }), ); }); });