mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes.
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
import { ContractTransitionService } from './contract-transition.service';
|
|
import type { Contract } from './entities/contract.entity';
|
|
|
|
/**
|
|
* Suspension is only worth having if it is reversible and if it actually
|
|
* freezes things, and the customer's own cancel is only safe while no shipment
|
|
* is running. Those three rules are the whole feature — everything else is
|
|
* plumbing.
|
|
*/
|
|
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
|
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
|
({
|
|
id: 'c-1',
|
|
reference: 'CTR-2026-00042',
|
|
companyId: 'co-1',
|
|
status: 'CONTRACT_ACTIVE',
|
|
freightType: 'CONTAINER',
|
|
...over,
|
|
}) as Contract;
|
|
|
|
let current: Contract;
|
|
let repo: {
|
|
update: jest.Mock;
|
|
createReviewNote: jest.Mock;
|
|
countActiveBookings: jest.Mock;
|
|
};
|
|
let notifier: {
|
|
suspended: jest.Mock;
|
|
suspensionLifted: jest.Mock;
|
|
cancelledByCustomer: jest.Mock;
|
|
};
|
|
let service: ContractTransitionService;
|
|
|
|
/** A staff user holding the suspend key — authorization is tested elsewhere. */
|
|
const staff = {
|
|
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
current = contract();
|
|
repo = {
|
|
// Mirror the real repository: the update patches the row the next
|
|
// findById returns, so resume() reads what suspend() wrote.
|
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
|
current = { ...current, ...patch } as Contract;
|
|
return Promise.resolve(current);
|
|
}),
|
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
|
countActiveBookings: jest.fn().mockResolvedValue(0),
|
|
};
|
|
notifier = {
|
|
suspended: jest.fn(),
|
|
suspensionLifted: jest.fn(),
|
|
cancelledByCustomer: jest.fn(),
|
|
};
|
|
// These three transitions touch only the repository, the read-back service
|
|
// and the notifier — the other 14 constructor deps stay unused, so the
|
|
// instance is built bare and only what is exercised is injected.
|
|
service = Object.create(
|
|
ContractTransitionService.prototype,
|
|
) as ContractTransitionService;
|
|
Object.assign(service, {
|
|
contractsRepository: repo,
|
|
contractsService: { findById: () => Promise.resolve(current) },
|
|
notifier,
|
|
});
|
|
});
|
|
|
|
it('freezes at the current step and remembers where to come back to', async () => {
|
|
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
|
|
|
|
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
|
|
|
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
|
status: 'SUSPENDED',
|
|
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
|
|
});
|
|
expect(notifier.suspended).toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores the pre-suspension status when the suspension is lifted', async () => {
|
|
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
|
|
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
|
|
|
|
await service.resume('c-1', undefined, 'staff-1', staff as never);
|
|
|
|
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
|
|
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
|
statusBeforeSuspension: null,
|
|
});
|
|
});
|
|
|
|
it('refuses to suspend a contract the customer has not signed yet', async () => {
|
|
current = contract({ status: 'PENDING_APPROVAL' });
|
|
|
|
await expect(
|
|
service.suspend('c-1', 'too early', 'staff-1', staff as never),
|
|
).rejects.toThrow(/PENDING_APPROVAL/);
|
|
expect(repo.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets the customer cancel a contract with no live shipment', async () => {
|
|
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
|
|
|
|
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
|
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
|
'c-1',
|
|
'Changed supplier',
|
|
'CANCELLATION',
|
|
'user-1',
|
|
'CUSTOMER',
|
|
);
|
|
});
|
|
|
|
it('blocks the customer cancel while a shipment is still running', async () => {
|
|
repo.countActiveBookings.mockResolvedValue(2);
|
|
|
|
await expect(
|
|
service.cancelByCustomer('c-1', undefined, 'user-1'),
|
|
).rejects.toThrow(/2 active shipments/);
|
|
expect(repo.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
|
|
current = contract({ status: 'SUSPENDED' });
|
|
|
|
await expect(
|
|
service.cancelByCustomer('c-1', undefined, 'user-1'),
|
|
).rejects.toThrow(/suspended/);
|
|
expect(repo.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|