import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import type { Booking } from './entities/booking.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Who hears "Operations wants changes" depends on who owns the booking. A * customs (Path B) booking is created BY GL Ethiopia on the customer's behalf — * the customer can neither edit nor resubmit it, so the note has to reach the GL * who made it, not the portal. */ describe('BookingLifecycleNotifierService — operation changes requested', () => { const booking = (over: Partial = {}): Booking => ({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1', contractId: 'ctr-1', createdByRole: 'CUSTOMER', company: { email: 'customer@example.com' }, ...over, }) as Booking; let notifications: { directSend: jest.Mock }; let inbox: { notify: jest.Mock }; let service: BookingLifecycleNotifierService; const flush = () => new Promise((resolve) => setImmediate(resolve)); beforeEach(() => { notifications = { directSend: jest.fn().mockResolvedValue(undefined) }; inbox = { notify: jest.fn().mockResolvedValue(undefined) }; service = new BookingLifecycleNotifierService( notifications as never, inbox as never, { query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never, ); }); it('sends a GL-created customs booking back to the GL who created it, not the customer', async () => { service.operationChangesRequested( booking({ customsClearingEnabled: true, createdByRole: 'GL_ET', createdByUserId: 'gl-user-1', }), 'Cargo weight does not match the declaration', ); await flush(); expect(inbox.notify).toHaveBeenCalledTimes(1); const sent = inbox.notify.mock.calls[0][0]; expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] }); expect(sent.audience).toBe('BACKOFFICE'); expect(sent.body).toContain('Cargo weight does not match the declaration'); // Deep-links the clearance page GL works from, not the portal booking. expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1'); // The customer is not told to fix something they cannot touch. expect(notifications.directSend).not.toHaveBeenCalled(); }); it('still tells the customer when the booking is a non-customs self-service booking', async () => { service.operationChangesRequested(booking(), 'Please attach the packing list'); await flush(); const sent = inbox.notify.mock.calls[0][0]; expect(sent.recipients).toEqual({ companyId: 'co-1' }); expect(sent.audience).toBe('PORTAL'); expect(sent.link).toBe('/bookings/b-1'); expect(notifications.directSend).toHaveBeenCalled(); }); it('routes a customer-opened customs booking to the clearance desk, not the customer', async () => { // Path B lets the customer open the ONE_TIME shipment instance themselves // (contract-booking.service assertGate's customerMayInitiate) — createdByRole // stays 'CUSTOMER', but GL still owns completing/resubmitting it. service.operationChangesRequested( booking({ customsClearingEnabled: true, createdByRole: 'CUSTOMER' }), 'Fix the declaration', ); await flush(); const sent = inbox.notify.mock.calls[0][0]; expect(sent.recipients).toEqual({ permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], }); expect(sent.audience).toBe('BACKOFFICE'); expect(notifications.directSend).not.toHaveBeenCalled(); }); it('falls back to the clearance desk when the GL creator is unknown (legacy rows)', async () => { service.operationChangesRequested( booking({ customsClearingEnabled: true, createdByRole: 'GL_ET', createdByUserId: null, }), 'Fix the declaration', ); await flush(); expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], }); }); }); /** * Staff notifications used to go to every employee in every organization. They * now target a desk — and the two desks are disjoint: the GL presets hold no * bookings:view and no intake keys, so intake pings would be noise they cannot * act on. Both branches run through the same `inAppStaff` helper, which is the * easy place to lose the distinction again. */ describe('BookingLifecycleNotifierService — staff desk targeting', () => { const booking = () => ({ id: 'b-1', reference: 'BKG-0001', companyId: 'co-1' }) as Booking; let inbox: { notify: jest.Mock }; let service: BookingLifecycleNotifierService; beforeEach(() => { inbox = { notify: jest.fn().mockResolvedValue(undefined) }; service = new BookingLifecycleNotifierService( { directSend: jest.fn().mockResolvedValue(undefined) } as never, inbox as never, { query: jest.fn().mockResolvedValue([]) } as never, ); }); it('routes intake items to the booking desk and clearance items to the clearance desk', () => { service.submittedToStaff(booking()); service.clearanceDocsUploadedToStaff(booking()); expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ permissionKeys: [FREIGHT_PERMS.bookings.getNotification], }); expect(inbox.notify.mock.calls[1][0].recipients).toEqual({ permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], }); }); });