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; }; 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, reopenDelayMinutes: 0, }; 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), }; 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 () => { 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 () => { 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('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); }); 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: 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'); }); 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(); }); });