Files
edr-platform/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts

340 lines
12 KiB
TypeScript

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;
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
yardScope?: string[] | null;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
};
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
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents
);
return {
service,
bookingsRepository,
bookingsService,
filesService,
workflowService,
milestoneService,
};
}
describe('BookingClearanceService', () => {
describe('etQueue yard scope', () => {
const queueBookings = [
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
] as unknown as Booking[];
it('keeps only bookings whose origin or destination is in scope', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
});
it('shows everything when the position has no yard mapping', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows).toHaveLength(3);
});
});
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) }),
);
});
});
});