import { BookingWindowService } from './booking-window.service'; import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; /** * Window state-machine tests: exercise the real advanceImport transitions and the * concludeCycle reopen/done decision with mocked collaborators. Drives the exact * production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and * asserts the side effects the batch/settle/reopen flow depends on. */ describe('BookingWindowService — window state machine', () => { const scheduleId = 'sched-1'; let service: BookingWindowService; let batch: { setWindow: jest.Mock; processRouteDay: jest.Mock; expireUnacceptedForRouteDay: jest.Mock; settleDueReservations: jest.Mock; isScheduleFull: jest.Mock; hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; expireLeftoverDayPool: jest.Mock; expireLeftoverExportDay: jest.Mock; fillFromWaitingList: jest.Mock; countUnacceptedForRouteDay: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; let updateMock: jest.Mock; const cfg = { importWindowLeadDays: 3, exportBookingLeadHours: 24, windowOpenHour: 0, // 24h desk → reopen opens immediately windowCloseHour: 0, windowDurationHours: 1, docReviewMinutes: 30, paymentWindowMinutes: 60, exportPaymentWindowMinutes: 60, }; const baseSchedule = (over: Partial): TrainSchedule => ({ id: scheduleId, direction: 'IMPORT', originStationId: 'yard-o', destinationStationId: 'yard-d', scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', bookingCycleNo: 0, windowOpensAt: null, windowClosesAt: null, docReviewEndsAt: null, docReviewCompletedAt: null, paymentPhaseEndsAt: null, ...over, }) as unknown as TrainSchedule; const advanceImport = (s: TrainSchedule, now: Date): Promise => (service as unknown as { advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise; }).advanceImport(s, cfg, now); const concludeCycle = (s: TrainSchedule, now: Date): Promise => (service as unknown as { concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise; }).concludeCycle(s, cfg, now); beforeEach(() => { updateMock = jest.fn().mockResolvedValue(undefined); batch = { setWindow: jest.fn().mockResolvedValue(undefined), processRouteDay: jest.fn().mockResolvedValue(undefined), expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined), settleDueReservations: jest.fn().mockResolvedValue(undefined), isScheduleFull: jest.fn().mockResolvedValue(false), // No reservation is mid-pay-window by default, so the cycle concludes. hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), expireLeftoverDayPool: jest.fn().mockResolvedValue(0), expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined), // No waiting booking fits by default, so conclude proceeds to reopen/DONE. fillFromWaitingList: jest.fn().mockResolvedValue(0), countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), findAll: jest.fn().mockResolvedValue([]), }; trainSchedulingService = { finalizeSchedule: jest.fn().mockResolvedValue(undefined), getWindowConfig: jest.fn().mockResolvedValue(cfg), }; service = new BookingWindowService( { getRepository: () => ({ update: updateMock }) } as never, trainSchedulesRepository as never, batch as never, trainSchedulingService as never, { directSend: jest.fn() } as never, { notify: jest.fn() } as never, { emitPhase: jest.fn() } as never, ); }); it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => { const s = baseSchedule({ windowPhase: 'PRE_WINDOW', windowOpensAt: new Date('2026-07-01T00:00:00.000Z'), }); const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z')); expect(advanced).toBe(true); expect(s.windowPhase).toBe('OPEN'); expect(s.bookingCycleNo).toBe(1); expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN'); }); it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => { const closesAt = new Date('2026-07-01T01:00:00.000Z'); const s = baseSchedule({ windowPhase: 'OPEN', bookingWindowStatus: 'OPEN', windowClosesAt: closesAt, }); const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z')); expect(advanced).toBe(true); expect(s.windowPhase).toBe('DOC_REVIEW'); expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000)); expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED'); }); it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => { // The batch reserved someone (live reservations exist) → real PAYMENT phase. batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), }); const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z')); expect(advanced).toBe(true); expect(s.windowPhase).toBe('PAYMENT'); expect(s.paymentPhaseEndsAt).not.toBeNull(); // Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity). expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1); expect(batch.processRouteDay).toHaveBeenCalledTimes(1); const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0]; const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0]; expect(expireOrder).toBeLessThan(batchOrder); }); it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => { batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done }); const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z')); expect(advanced).toBe(true); expect(s.windowPhase).toBe('PAYMENT'); }); it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => { // Default hasLiveReservations=false: the batch reserved nobody. Waiting a // full payment window with the desk shut would serve no one — the cycle // concludes immediately (24h desk + far departure → straight to PRE_WINDOW). const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), }); const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z')); expect(advanced).toBe(true); expect(batch.processRouteDay).toHaveBeenCalledTimes(1); expect(s.windowPhase).toBe('PRE_WINDOW'); expect(s.windowOpensAt).not.toBeNull(); }); it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => { const s = baseSchedule({ windowPhase: 'PAYMENT', paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), }); const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); expect(advanced).toBe(true); // settleDueReservations runs (allocate paid / expire unpaid, then top-up). expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); }); it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => { // `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each // booking's own deadline milliseconds later. So the phase deadline always passes // first, and concluding here would kill customers who still had time to pay — and // leave no cycle for the waiting-list top-up to run in. batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'PAYMENT', paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), }); const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); expect(advanced).toBe(true); expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); // Still PAYMENT — the cycle was NOT concluded and the window did not reopen. expect(s.windowPhase).toBe('PAYMENT'); expect(batch.isScheduleFull).not.toHaveBeenCalled(); }); it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => { batch.isScheduleFull.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'PAYMENT' }); await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z')); expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL'); expect(s.windowPhase).toBe('DONE'); expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId); // The day's leftover waiting list is swept once this train is done. expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId); }); it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => { batch.isScheduleFull.mockResolvedValue(false); const s = baseSchedule({ windowPhase: 'PAYMENT', // departure well in the future so nextCycleOpensAt returns a real time. scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), }); await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z')); expect(s.windowPhase).toBe('PRE_WINDOW'); expect(s.windowOpensAt).not.toBeNull(); expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled(); }); it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => { batch.isScheduleFull.mockResolvedValue(false); batch.fillFromWaitingList.mockResolvedValue(2); batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'PAYMENT', scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), }); const now = new Date('2026-07-01T02:30:05.000Z'); await concludeCycle(s, now); expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId); expect(s.windowPhase).toBe('PAYMENT'); // Fresh pay window from `now`, not a reopen. expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000)); }); it('conclude: NOT full but NO cycle fits before departure → DONE', async () => { batch.isScheduleFull.mockResolvedValue(false); const s = baseSchedule({ windowPhase: 'PAYMENT', // departure already passed → nextCycleOpensAt returns null → finish. scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'), }); await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z')); expect(s.windowPhase).toBe('DONE'); // No further train can run for this day → leftover waiting list is swept. expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId); }); it('no transition fires before its deadline (idempotent tick)', async () => { const s = baseSchedule({ windowPhase: 'OPEN', bookingWindowStatus: 'OPEN', windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future }); const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z')); expect(advanced).toBe(false); expect(s.windowPhase).toBe('OPEN'); expect(batch.setWindow).not.toHaveBeenCalled(); }); // ---- header alarm --------------------------------------------------------- describe('getDocReviewAlert', () => { const reviewing = (over: Partial): TrainSchedule => baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), ...over, }); it('returns null when nothing is under document review', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ baseSchedule({ windowPhase: 'OPEN' }), ]); expect(await service.getDocReviewAlert()).toBeNull(); }); it('returns null when every request on the route-day is decided', async () => { trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]); batch.countUnacceptedForRouteDay.mockResolvedValue(0); expect(await service.getDocReviewAlert()).toBeNull(); }); it('reports the deadline, its own pending count and the phase length', async () => { trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]); batch.countUnacceptedForRouteDay.mockResolvedValue(3); const alert = await service.getDocReviewAlert(); expect(alert).toMatchObject({ scheduleId, originYardId: 'yard-o', destinationYardId: 'yard-d', tradeDirection: 'IMPORT', pendingCount: 3, docReviewMinutes: 30, docReviewEndsAt: '2026-07-01T01:30:00.000Z', }); }); it('skips the nearest deadline when it has nothing pending', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ reviewing({ id: 'sched-later', destinationStationId: 'yard-far', docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'), }), reviewing({ id: 'sched-soon' }), ]); // Nearest (sched-soon, yard-d) is clear; the later route-day still isn't. batch.countUnacceptedForRouteDay.mockImplementation( async (g: { destinationYardId: string }) => g.destinationYardId === 'yard-far' ? 2 : 0, ); const alert = await service.getDocReviewAlert(); expect(alert?.scheduleId).toBe('sched-later'); expect(alert?.pendingCount).toBe(2); }); it('counts a route-day once when sibling trains share the review phase', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ reviewing({ id: 'sched-a' }), reviewing({ id: 'sched-b' }), ]); batch.countUnacceptedForRouteDay.mockResolvedValue(4); const alert = await service.getDocReviewAlert(); expect(alert?.pendingCount).toBe(4); expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1); }); it('ignores a phase staff already completed early', async () => { trainSchedulesRepository.findAll.mockResolvedValue([ reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }), ]); batch.countUnacceptedForRouteDay.mockResolvedValue(5); expect(await service.getDocReviewAlert()).toBeNull(); }); }); });