Files
edr-platform/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts
2026-08-24 12:24:23 +00:00

191 lines
6.2 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { ContractClearanceService } from './contract-clearance.service';
import type { Contract } from './entities/contract.entity';
/**
* Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will
* handle the shipment in transit; Djibouti answers with a name. The customs
* declaration stays shut until that name exists, and Djibouti may send a
* different one later.
*/
describe('ContractClearanceService — transit assignee', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'ctr-1',
reference: 'CTR-2026-00042',
tradeDirection: 'IMPORT',
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
...over,
}) as Contract;
let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock };
let contractsService: { findById: jest.Mock };
let notifier: {
transitAssigneeRequested: jest.Mock;
transitAssigneeAssigned: jest.Mock;
};
let transitAgentsService: { getAssignable: jest.Mock };
let service: ContractClearanceService;
const cycle = (over: Record<string, unknown> = {}) => ({
id: 'cyc-1',
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
...over,
});
beforeEach(() => {
repo = {
currentCycle: jest.fn().mockResolvedValue(cycle()),
updateCycle: jest.fn().mockResolvedValue(undefined),
};
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
notifier = {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
};
transitAgentsService = {
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
};
service = new ContractClearanceService(
repo as never,
contractsService as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
notifier as never,
transitAgentsService as never,
{} as never, // dataSource
);
});
describe('request (GL Ethiopia)', () => {
it('stamps the ask and pings Djibouti', async () => {
await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date);
expect(patch.transitAssigneeRequestedByUserId).toBe('et-1');
expect(patch.transitAssigneeRequestNote).toBe(
'Reefer, needs a cold-chain officer',
);
expect(notifier.transitAssigneeRequested).toHaveBeenCalled();
});
});
describe('assign (GL Djibouti)', () => {
it('records the officer and tells Ethiopia they can proceed', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.objectContaining({ id: 'ctr-1' }),
'Ahmed Bourhan',
null,
);
});
it('reassigns, carrying the previous name into the notice', async () => {
repo.currentCycle.mockResolvedValue(
cycle({
transitAssigneeRequestedAt: new Date(),
transitAssigneeName: 'Ahmed Bourhan',
}),
);
transitAgentsService.getAssignable.mockResolvedValue({
id: 'agent-2',
name: 'Fatouma Ali',
});
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.anything(),
'Fatouma Ali',
'Ahmed Bourhan',
);
});
it('refuses a suspended or out-of-window agent', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
transitAgentsService.getAssignable.mockRejectedValue(
new BadRequestException('suspended'),
);
await expect(
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses before Ethiopia has asked', async () => {
await expect(
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toThrow(/not requested/i);
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
});
});
describe('declaration gate', () => {
const ensure = (c: Contract) =>
(
service as unknown as {
ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise<void>;
}
).ensureDeclarationPrerequisites('ctr-1', c);
beforeEach(() => {
// Documents are approved; only the assignee decides the outcome here.
(
service as unknown as { isClearanceFullyApproved: unknown }
).isClearanceFullyApproved = jest.fn().mockResolvedValue(true);
});
it('tells GL to raise the request when none exists', async () => {
await expect(ensure(contract())).rejects.toThrow(
/Request a transit assignee/i,
);
});
it('tells GL to wait when Djibouti has not answered', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await expect(ensure(contract())).rejects.toThrow(/has not assigned/i);
});
it('lets the declaration through once the officer is named', async () => {
repo.currentCycle.mockResolvedValue(
cycle({
transitAssigneeRequestedAt: new Date(),
transitAssigneeName: 'Ahmed Bourhan',
}),
);
(
service as unknown as { workflowService: unknown }
).workflowService = {
listMilestones: jest
.fn()
.mockResolvedValue([
{ milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' },
]),
};
await expect(ensure(contract())).resolves.toBeUndefined();
});
});
});