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 => ({ 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(); }); });