mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
- Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior
208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
import {
|
|
ConsolidationApprovalService,
|
|
CONSOLIDATION_APPROVAL_PENDING,
|
|
} from './consolidation-approval.service';
|
|
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
|
import { Booking } from './entities/booking.entity';
|
|
|
|
/**
|
|
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
|
* commercial call, so the pair is held for a human decision instead of going
|
|
* straight to Operations.
|
|
*
|
|
* The invariants that matter: both halves are held and released TOGETHER (a
|
|
* decision on one side of a shared wagon is meaningless without the other), and
|
|
* a decided pairing cannot be decided twice.
|
|
*/
|
|
describe('ConsolidationApprovalService', () => {
|
|
const PENDING = {
|
|
id: 'ap-1',
|
|
bookingId: 'b-1',
|
|
partnerBookingId: 'b-2',
|
|
status: ConsolidationApprovalStatus.Pending,
|
|
requestedBy: 'gl-user',
|
|
};
|
|
|
|
function makeService(overrides: {
|
|
approvals?: Partial<Record<string, jest.Mock>>;
|
|
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
|
} = {}) {
|
|
const approvals = {
|
|
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
|
findById: jest.fn().mockResolvedValue(PENDING),
|
|
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
|
decide: jest.fn().mockResolvedValue(true),
|
|
findQueue: jest.fn().mockResolvedValue([]),
|
|
findAllForBooking: jest.fn().mockResolvedValue([]),
|
|
...overrides.approvals,
|
|
};
|
|
const bookingsRepository = {
|
|
update: jest.fn().mockResolvedValue(undefined),
|
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
|
...overrides.bookingsRepository,
|
|
};
|
|
const bookingsService = {
|
|
findById: jest.fn(async (id: string) =>
|
|
({ id, reference: `BK-${id}` }) as Booking,
|
|
),
|
|
};
|
|
const notifier = {
|
|
consolidationApprovalRequestedToStaff: jest.fn(),
|
|
consolidationApprovedToStaff: jest.fn(),
|
|
consolidationRejectedToStaff: jest.fn(),
|
|
operationRequestedToStaff: jest.fn(),
|
|
};
|
|
const dataSource = {
|
|
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
|
};
|
|
|
|
const service = new ConsolidationApprovalService(
|
|
approvals as never,
|
|
bookingsRepository as never,
|
|
bookingsService as never,
|
|
notifier as never,
|
|
dataSource as never,
|
|
);
|
|
return { service, approvals, bookingsRepository, notifier };
|
|
}
|
|
|
|
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
|
const { service, approvals, bookingsRepository, notifier } = makeService();
|
|
|
|
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
|
|
|
expect(approvals.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bookingId: 'b-1',
|
|
partnerBookingId: 'b-2',
|
|
requestedBy: 'gl-user',
|
|
}),
|
|
);
|
|
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
|
});
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
|
});
|
|
expect(
|
|
notifier.consolidationApprovalRequestedToStaff,
|
|
).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not open a second review for a pairing already pending', async () => {
|
|
const { service, approvals } = makeService({
|
|
approvals: {
|
|
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
|
},
|
|
});
|
|
|
|
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
|
|
|
expect(result).toBe(PENDING);
|
|
expect(approvals.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
|
const { service, approvals, bookingsRepository, notifier } = makeService();
|
|
|
|
await service.approve('ap-1', 'approver-1', 'looks fine');
|
|
|
|
expect(approvals.decide).toHaveBeenCalledWith(
|
|
'ap-1',
|
|
ConsolidationApprovalStatus.Approved,
|
|
'approver-1',
|
|
'looks fine',
|
|
);
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
|
status: 'OPERATION_REQUEST_PENDING',
|
|
});
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
|
status: 'OPERATION_REQUEST_PENDING',
|
|
});
|
|
// Operations only learns about the pair now — the gate is what kept it out.
|
|
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
|
const { service, approvals, bookingsRepository } = makeService();
|
|
|
|
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
|
|
|
expect(approvals.decide).toHaveBeenCalledWith(
|
|
'ap-1',
|
|
ConsolidationApprovalStatus.Rejected,
|
|
'approver-1',
|
|
'partner cargo is wrong',
|
|
);
|
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
|
'b-1',
|
|
'partner cargo is wrong',
|
|
'CHANGES_REQUESTED',
|
|
);
|
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
|
'b-2',
|
|
'partner cargo is wrong',
|
|
'CHANGES_REQUESTED',
|
|
);
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
|
status: 'OPERATION_CHANGES_REQUESTED',
|
|
});
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
|
status: 'OPERATION_CHANGES_REQUESTED',
|
|
});
|
|
});
|
|
|
|
it('lets the requester approve their own pairing', async () => {
|
|
// No maker-checker separation: the permission alone decides who may approve,
|
|
// and the audit trail still records requester and approver separately.
|
|
const { service, approvals } = makeService();
|
|
|
|
await service.approve('ap-1', 'gl-user');
|
|
|
|
expect(approvals.decide).toHaveBeenCalledWith(
|
|
'ap-1',
|
|
ConsolidationApprovalStatus.Approved,
|
|
'gl-user',
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('requires a reason to reject', async () => {
|
|
const { service, approvals } = makeService();
|
|
|
|
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
|
/reason is required/i,
|
|
);
|
|
expect(approvals.decide).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a pairing that was already decided', async () => {
|
|
const { service, bookingsRepository } = makeService({
|
|
approvals: {
|
|
findById: jest.fn().mockResolvedValue({
|
|
...PENDING,
|
|
status: ConsolidationApprovalStatus.Approved,
|
|
}),
|
|
},
|
|
});
|
|
|
|
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
|
/already approved/i,
|
|
);
|
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('loses cleanly when another approver decides the same pairing first', async () => {
|
|
// decide() writes only against a still-PENDING row, so the loser of the race
|
|
// affects nothing and must not move the bookings.
|
|
const { service } = makeService({
|
|
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
|
});
|
|
|
|
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
|
/already decided by someone else/i,
|
|
);
|
|
});
|
|
});
|