Files
edr-platform/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts
2026-08-23 05:00:43 +00:00

482 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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