Files
edr-platform/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts
Marshal 05b13e84a8 enhance booking and contract notification systems
- Added detailed logging for socket connection events in useBookingWindowSocket.
- Introduced new notification types for contract status and schedule updates.
- Updated notification visuals to include new icons for contract status.
- Enhanced notification href resolution for contract status and schedule updates.
- Implemented booking lifecycle notifier service for customer and staff notifications.
- Created contract notifier service for managing contract lifecycle notifications.
- Added end-to-end tests for booking window socket functionality.
2026-07-06 21:03:08 +00:00

213 lines
6.9 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,
} as Booking;
const generalExportBooking = {
...generalImportBooking,
id: 'b-export',
tradeDirection: 'EXPORT',
dutyRequired: null,
} as Booking;
function makeService(overrides?: {
booking?: Booking;
workflowThrows?: 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({}),
findByResource: jest.fn().mockResolvedValue([]),
};
const fileUploadSettingsService = {
getByCode: 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),
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(),
} as never, // notifier
);
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);
});
});
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) }),
);
});
});
});