import { BadRequestException } from '@nestjs/common'; import { ContractDocPhase } from '@edr/types'; import { BookingClearanceService } from './booking-clearance.service'; import type { Booking } from '../bookings/entities/booking.entity'; const generalImportBooking = { id: 'b-general', status: 'DOCUMENTS_UNDER_REVIEW', tradeDirection: 'IMPORT', freightType: 'CONTAINER', customsClearingEnabled: true, contractKind: 'GENERAL', contractId: 'c-1', dutyRequired: true, roHoldReason: null, vesselDepartureDate: null, // Djibouti already named the transit officer — the declaration gate is open. transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'), transitAssigneeName: 'Ahmed Bourhan', } as Booking; const generalExportBooking = { ...generalImportBooking, id: 'b-export', tradeDirection: 'EXPORT', dutyRequired: null, } as Booking; function makeService(overrides?: { booking?: Booking; workflowThrows?: boolean; /** Resolve the input doc set with no required fields → every doc counts approved. */ docsApproved?: boolean; }) { const booking = overrides?.booking ?? generalImportBooking; const bookingsRepository = { findDocumentReviews: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(booking), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), }; const filesService = { upsertByCode: jest.fn().mockResolvedValue({}), upload: jest.fn().mockResolvedValue({}), deleteByCode: jest.fn().mockResolvedValue(undefined), findByResource: jest.fn().mockResolvedValue([]), }; const fileUploadSettingsService = { getByCode: overrides?.docsApproved ? jest.fn().mockResolvedValue({ fields: [] }) : jest.fn().mockRejectedValue(new Error('no setting')), }; const workflowService = { assertPriorCompleteForBooking: overrides?.workflowThrows ? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete')) : jest.fn().mockResolvedValue(undefined), completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined), onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), listMilestonesForBooking: jest.fn().mockResolvedValue([]), resolvePhaseForBooking: jest.fn().mockReturnValue(null), computeNextActionForBooking: jest.fn().mockReturnValue(null), isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false), markReadyForOperation: jest.fn().mockResolvedValue(undefined), onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined), }; const milestoneService = { adviseDuty: jest.fn().mockResolvedValue(undefined), }; const dropdownSettingsService = { getByCode: jest.fn().mockResolvedValue({ children: [{ value: '2' }], }), }; const glOperationsService = { t1State: jest.fn().mockResolvedValue({ bookingId: 'b-general', wagonAllocated: false, trainDepartedAt: null, trainArrivedAt: null, closed: false, closedAt: null, }), }; const service = new BookingClearanceService( bookingsRepository as never, bookingsService as never, filesService as never, fileUploadSettingsService as never, workflowService as never, milestoneService as never, dropdownSettingsService as never, glOperationsService as never, { dutyAdvised: jest.fn(), clearanceReady: jest.fn(), documentQueried: jest.fn(), dutySlipUploadedToStaff: jest.fn(), clearanceDocsUploadedToStaff: jest.fn(), transitAssigneeRequested: jest.fn(), transitAssigneeAssigned: jest.fn(), } as never, // notifier { listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange { getAssignable: jest .fn() .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents ); return { service, bookingsRepository, bookingsService, filesService, workflowService, milestoneService, }; } describe('BookingClearanceService', () => { describe('adviseDuty', () => { it('skips duty milestones when duty is not required', async () => { const { service, workflowService, bookingsRepository } = makeService(); await service.adviseDuty('b-general', { dutyRequired: false }); expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-general', expect.objectContaining({ dutyRequired: false, clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, }), ); }); it('records duty advice when duty applies', async () => { const { service, milestoneService } = makeService(); await service.adviseDuty( 'b-general', { dutyRequired: true, amount: 1500, currency: 'ETB', declarationSerial: 'DS-1', }, undefined, // The duty notice attachment is now mandatory when duty applies. { fieldname: 'duty_tax_notice' } as Express.Multer.File, ); expect(milestoneService.adviseDuty).toHaveBeenCalledWith( 'b-general', expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }), undefined, ); }); }); describe('uploadDutySlip', () => { it('rejects when duty is not required', async () => { const { service } = makeService({ booking: { ...generalImportBooking, dutyRequired: false } as Booking, }); await expect( service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File), ).rejects.toBeInstanceOf(BadRequestException); }); it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => { const { service, filesService, workflowService, bookingsRepository } = makeService(); const file = { fieldname: 'file' } as Express.Multer.File; await service.uploadDutySlip('b-general', file); expect(filesService.upsertByCode).toHaveBeenCalledWith( expect.objectContaining({ resourceId: 'b-general', code: 'duty_tax_receipt', file, }), ); expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith( 'b-general', 'DUTY_TAX_PAID', ); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-general', expect.objectContaining({ clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, }), ); }); }); describe('uploadDeclaration', () => { it('rejects when a prior milestone is incomplete', async () => { const { service } = makeService({ workflowThrows: true }); await expect( service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects an import declaration before Djibouti names the transit officer', async () => { const { service, workflowService } = makeService({ docsApproved: true, booking: { ...generalImportBooking, transitAssigneeRequestedAt: null, transitAssigneeName: null, } as Booking, }); await expect( service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), ).rejects.toThrow(/Request a transit assignee/i); expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled(); }); it('lets the export declaration through without a transit assignee', async () => { const { service, workflowService } = makeService({ docsApproved: true, booking: { ...generalExportBooking, transitAssigneeRequestedAt: null, transitAssigneeName: null, } as Booking, }); await service.uploadDeclaration('b-export', [ { fieldname: 'decl' } as Express.Multer.File, ]); expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled(); }); }); describe('transit assignee handshake', () => { it('refuses an assignment GL Ethiopia never asked for', async () => { const { service } = makeService({ booking: { ...generalImportBooking, transitAssigneeRequestedAt: null, transitAssigneeName: null, } as Booking, }); await expect( service.assignTransitAssignee('b-general', 'Ahmed Bourhan'), ).rejects.toThrow(/has not requested a transit assignee/i); }); it('stamps the ask and then the name', async () => { const { service, bookingsRepository } = makeService({ booking: { ...generalImportBooking, transitAssigneeName: null, } as Booking, }); await service.requestTransitAssignee('b-general', ' night shift '); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-general', expect.objectContaining({ transitAssigneeRequestedAt: expect.any(Date), transitAssigneeRequestNote: 'night shift', }), ); await service.assignTransitAssignee('b-general', ' Ahmed Bourhan '); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-general', expect.objectContaining({ transitAssigneeName: 'Ahmed Bourhan', transitAssigneeAssignedAt: expect.any(Date), }), ); }); }); describe('uploadReleaseOrder', () => { it('places RO on hold when vessel departs too soon', async () => { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const dateStr = tomorrow.toISOString().slice(0, 10); const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); const result = await service.uploadReleaseOrder( 'b-export', { fieldname: 'ro' } as Express.Multer.File, dateStr, ); expect(result.hold).toBe(true); expect(result.holdReason).toMatch(/minimum lead time/i); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-export', expect.objectContaining({ roHoldReason: expect.any(String) }), ); }); }); });