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>; bookingsRepository?: Partial>; bookingsService?: Partial>; /** 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) => 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(); }); });