mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 12:33:42 +00:00
482 lines
16 KiB
TypeScript
482 lines
16 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>>;
|
||
bookingsService?: Partial<Record<string, jest.Mock>>;
|
||
/** Contract rows the id→reference lookup should return. */
|
||
contracts?: { id: string; reference: string }[];
|
||
/** Yard ids the caller is scoped to; null = unrestricted. */
|
||
yardScope?: string[] | null;
|
||
} = {},
|
||
) {
|
||
const approvals = {
|
||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||
findById: jest.fn().mockResolvedValue(PENDING),
|
||
create: jest.fn().mockResolvedValue({ id: "ap-1" }),
|
||
decide: jest.fn().mockResolvedValue(true),
|
||
findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }),
|
||
countByStatus: jest
|
||
.fn()
|
||
.mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }),
|
||
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||
...overrides.approvals,
|
||
};
|
||
const bookingsRepository = {
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
|
||
...overrides.bookingsRepository,
|
||
};
|
||
const bookingsService = {
|
||
findById: jest.fn(
|
||
async (id: string) =>
|
||
({
|
||
id,
|
||
reference: `BK-${id}`,
|
||
originYardId: "mojo",
|
||
destinationYardId: "djibouti",
|
||
}) as Booking,
|
||
),
|
||
...overrides.bookingsService,
|
||
};
|
||
const notifier = {
|
||
consolidationApprovalRequestedToStaff: jest.fn(),
|
||
consolidationApprovedToStaff: jest.fn(),
|
||
consolidationRejectedToStaff: jest.fn(),
|
||
operationRequestedToStaff: jest.fn(),
|
||
};
|
||
const contractRepo = {
|
||
find: jest
|
||
.fn()
|
||
.mockResolvedValue(overrides.contracts ?? []),
|
||
};
|
||
const dataSource = {
|
||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||
getRepository: jest.fn(() => contractRepo),
|
||
};
|
||
const yardScope = {
|
||
getScopedYardIds: jest
|
||
.fn()
|
||
.mockResolvedValue(overrides.yardScope ?? null),
|
||
};
|
||
|
||
const service = new ConsolidationApprovalService(
|
||
approvals as never,
|
||
bookingsRepository as never,
|
||
bookingsService as never,
|
||
notifier as never,
|
||
dataSource as never,
|
||
yardScope as never,
|
||
);
|
||
return {
|
||
service,
|
||
approvals,
|
||
bookingsRepository,
|
||
notifier,
|
||
yardScope,
|
||
contractRepo,
|
||
};
|
||
}
|
||
|
||
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",
|
||
[
|
||
ConsolidationApprovalStatus.Pending,
|
||
ConsolidationApprovalStatus.Rejected,
|
||
],
|
||
);
|
||
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,
|
||
[
|
||
ConsolidationApprovalStatus.Pending,
|
||
ConsolidationApprovalStatus.Rejected,
|
||
],
|
||
);
|
||
});
|
||
|
||
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,
|
||
);
|
||
});
|
||
|
||
it("approves a pairing that was rejected earlier, releasing both halves", async () => {
|
||
// A rejection is not final: the reviewer may change their mind, or GL may
|
||
// argue the case. Only an already-approved pairing is closed.
|
||
const { service, bookingsRepository } = makeService({
|
||
approvals: {
|
||
findById: jest.fn().mockResolvedValue({
|
||
...PENDING,
|
||
status: ConsolidationApprovalStatus.Rejected,
|
||
decidedBy: "approver-1",
|
||
}),
|
||
},
|
||
});
|
||
|
||
await service.approve("ap-1", "approver-2", "resolved with GL");
|
||
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||
status: "OPERATION_REQUEST_PENDING",
|
||
});
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||
status: "OPERATION_REQUEST_PENDING",
|
||
});
|
||
});
|
||
|
||
it("refuses to reject a pairing that was already rejected", async () => {
|
||
const { service, bookingsRepository } = makeService({
|
||
approvals: {
|
||
findById: jest.fn().mockResolvedValue({
|
||
...PENDING,
|
||
status: ConsolidationApprovalStatus.Rejected,
|
||
}),
|
||
},
|
||
});
|
||
|
||
await expect(
|
||
service.reject("ap-1", "approver-1", "still wrong"),
|
||
).rejects.toThrow(/already rejected/i);
|
||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("names the requester and the decider on every queue row", async () => {
|
||
// The stored ids mean nothing to a reviewer reading the history.
|
||
const { service } = makeService({
|
||
approvals: {
|
||
findQueuePage: jest.fn().mockResolvedValue({
|
||
items: [
|
||
{
|
||
...PENDING,
|
||
status: ConsolidationApprovalStatus.Approved,
|
||
decidedBy: "approver-1",
|
||
},
|
||
],
|
||
total: 1,
|
||
}),
|
||
},
|
||
bookingsRepository: {
|
||
resolveStaffNames: jest.fn().mockResolvedValue(
|
||
new Map([
|
||
["gl-user", "Selam GL"],
|
||
["approver-1", "Abebe Approver"],
|
||
]),
|
||
),
|
||
},
|
||
});
|
||
|
||
const { items, meta, counts } = await service.queue({ pageSize: 10 });
|
||
|
||
expect(items[0].requestedByName).toBe("Selam GL");
|
||
expect(items[0].decidedByName).toBe("Abebe Approver");
|
||
// Badges count the whole queue, not the page that happened to load.
|
||
expect(counts.APPROVED).toBe(4);
|
||
expect(meta).toMatchObject({
|
||
page: 1,
|
||
pageSize: 10,
|
||
total: 1,
|
||
totalPages: 1,
|
||
});
|
||
});
|
||
|
||
it("pages the queue in SQL and reports the page meta", async () => {
|
||
// The page must be cut in the query, not sliced out of a full fetch —
|
||
// otherwise ordering only holds within whatever page loaded.
|
||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 });
|
||
const { service } = makeService({ approvals: { findQueuePage } });
|
||
|
||
const { meta } = await service.queue({
|
||
status: ConsolidationApprovalStatus.Rejected,
|
||
page: 2,
|
||
pageSize: 10,
|
||
});
|
||
|
||
expect(findQueuePage).toHaveBeenCalledWith({
|
||
status: ConsolidationApprovalStatus.Rejected,
|
||
page: 2,
|
||
pageSize: 10,
|
||
});
|
||
expect(meta).toMatchObject({
|
||
page: 2,
|
||
totalPages: 3,
|
||
hasNextPage: true,
|
||
hasPreviousPage: true,
|
||
});
|
||
});
|
||
|
||
it("narrows the queue and the badges to the caller's yards", async () => {
|
||
// A Mojo + Adama desk sees both yards' pairings, and nothing else. The
|
||
// badges must be narrowed too, or they promise rows the caller cannot open.
|
||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
|
||
const countByStatus = jest
|
||
.fn()
|
||
.mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 });
|
||
const { service } = makeService({
|
||
approvals: { findQueuePage, countByStatus },
|
||
yardScope: ["mojo", "adama"],
|
||
});
|
||
|
||
await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 });
|
||
|
||
expect(findQueuePage).toHaveBeenCalledWith(
|
||
expect.objectContaining({ yardIds: ["mojo", "adama"] }),
|
||
);
|
||
expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]);
|
||
});
|
||
|
||
it("leaves the queue unnarrowed for an unrestricted caller", async () => {
|
||
// Super admin, `yards:view_all`, or a desk with no yard mapping at all —
|
||
// the mapping narrows access, it never grants it.
|
||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
|
||
const { service } = makeService({
|
||
approvals: { findQueuePage },
|
||
yardScope: null,
|
||
});
|
||
|
||
await service.queue({ user: { id: "u-1" } });
|
||
|
||
expect(findQueuePage).toHaveBeenCalledWith(
|
||
expect.objectContaining({ yardIds: undefined }),
|
||
);
|
||
});
|
||
|
||
it("refuses to decide a pairing outside the caller's yards", async () => {
|
||
// Hiding the row is not enough — the id is guessable from a shared link,
|
||
// and deciding moves two other yards' bookings.
|
||
const { service, bookingsRepository } = makeService({
|
||
yardScope: ["adama"],
|
||
});
|
||
|
||
await expect(
|
||
service.approve("ap-1", "approver-1", undefined, { id: "u-1" }),
|
||
).rejects.toThrow(/outside your assigned yards/i);
|
||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("allows a decision when only the PARTNER half touches the caller's yard", async () => {
|
||
// The pair is one decision, so seeing one side is seeing the pairing.
|
||
const { service, bookingsRepository } = makeService({
|
||
yardScope: ["dire-dawa"],
|
||
bookingsService: {
|
||
findById: jest.fn(async (id: string) =>
|
||
id === "b-2"
|
||
? ({
|
||
id,
|
||
reference: "BK-b-2",
|
||
originYardId: "djibouti",
|
||
destinationYardId: "dire-dawa",
|
||
} as Booking)
|
||
: ({
|
||
id,
|
||
reference: "BK-b-1",
|
||
originYardId: "mojo",
|
||
destinationYardId: "djibouti",
|
||
} as Booking),
|
||
),
|
||
},
|
||
});
|
||
|
||
await service.approve("ap-1", "approver-1", undefined, { id: "u-1" });
|
||
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||
status: "OPERATION_REQUEST_PENDING",
|
||
});
|
||
});
|
||
|
||
it("attaches each half's contract reference for the queue link", async () => {
|
||
// Booking has no contract relation (contract–booking split), so the
|
||
// references are batch-loaded by id — one query for the whole page.
|
||
const { service, contractRepo } = makeService({
|
||
approvals: {
|
||
findQueuePage: jest.fn().mockResolvedValue({
|
||
items: [
|
||
{
|
||
...PENDING,
|
||
booking: { id: "b-1", contractId: "c-1" },
|
||
partnerBooking: { id: "b-2", contractId: "c-2" },
|
||
},
|
||
],
|
||
total: 1,
|
||
}),
|
||
},
|
||
contracts: [
|
||
{ id: "c-1", reference: "CT-001" },
|
||
{ id: "c-2", reference: "CT-002" },
|
||
],
|
||
});
|
||
|
||
const { items } = await service.queue();
|
||
|
||
expect(items[0].contractReference).toBe("CT-001");
|
||
expect(items[0].partnerContractReference).toBe("CT-002");
|
||
expect(contractRepo.find).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("leaves the contract reference null when a half has no contract", async () => {
|
||
const { service, contractRepo } = makeService({
|
||
approvals: {
|
||
findQueuePage: jest.fn().mockResolvedValue({
|
||
items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }],
|
||
total: 1,
|
||
}),
|
||
},
|
||
});
|
||
|
||
const { items } = await service.queue();
|
||
|
||
expect(items[0].contractReference).toBeNull();
|
||
expect(items[0].partnerContractReference).toBeNull();
|
||
// Nothing to look up — no query at all.
|
||
expect(contractRepo.find).not.toHaveBeenCalled();
|
||
});
|
||
});
|