import { TrainSchedulingService } from './train-scheduling.service'; import { Booking } from '../bookings/entities/booking.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; type Row = Pick & { metadata?: Record | null; triggeredAt?: Date | null; }; /** * The gate pass is secured once per train schedule, but each booking only earns * its GATEPASS_GRANTED milestone after settling freight payment. An unpaid * booking must not ride a paid neighbour's grant — the train proceeds, that * booking stays pending. */ function makeService(bookings: Array>, rows: Row[]) { const milestoneRepo = { find: jest.fn().mockResolvedValue(rows), save: jest.fn((row: Row) => Promise.resolve(row)), }; const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) }; const dataSource = { getRepository: (entity: unknown) => entity === Booking ? bookingRepo : milestoneRepo, }; const service = Object.create( TrainSchedulingService.prototype, ) as TrainSchedulingService; Object.assign(service, { dataSource, logger: { warn: jest.fn(), log: jest.fn() }, }); return { service, milestoneRepo }; } /** Reach the private bridge write under test. */ function grant(service: TrainSchedulingService, at: Date): Promise { return ( service as unknown as { completeGatepassMilestoneForSchedule(id: string, at: Date): Promise; } ).completeGatepassMilestoneForSchedule('sched-1', at); } const securedAt = new Date('2026-07-09T08:00:00.000Z'); describe('gate pass is withheld from bookings that have not paid freight', () => { it('grants the paid booking and leaves the unpaid one pending', async () => { const rows: Row[] = [ { bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, { bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, { bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, { bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, ]; const { service, milestoneRepo } = makeService( [ { id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, { id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, ], rows, ); await grant(service, securedAt); const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r); expect(saved).toHaveLength(1); expect(saved[0]!.bookingId).toBe('paid'); expect(saved[0]!.status).toBe('COMPLETED'); expect(saved[0]!.triggeredAt).toBe(securedAt); const unpaid = rows.find( (r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED', ); expect(unpaid!.status).toBe('PENDING'); }); it('treats a booking paid outside the milestone path as paid', async () => { // Some payment paths settle the invoice without writing the milestone; the // clearance views self-heal it on read, so the gate pass must not lag. const rows: Row[] = [ { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, ]; const { service, milestoneRepo } = makeService( [{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }], rows, ); await grant(service, securedAt); expect(milestoneRepo.save).toHaveBeenCalledTimes(1); expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1'); }); it('leaves an already-granted milestone untouched', async () => { const rows: Row[] = [ { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' }, ]; const { service, milestoneRepo } = makeService( [{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }], rows, ); await grant(service, securedAt); expect(milestoneRepo.save).not.toHaveBeenCalled(); }); it('does nothing when the schedule carries no customs bookings', async () => { const { service, milestoneRepo } = makeService([], []); await grant(service, securedAt); expect(milestoneRepo.save).not.toHaveBeenCalled(); }); });