import { BadRequestException, NotFoundException } from '@nestjs/common'; import { closeOffsetReopenCheck } from './batch-window.util'; import { TrainSchedulingService } from './services/train-scheduling.service'; /** * "Reopen booking by shortening the close offset": a train whose booking shut * ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its * window re-armed. Every other closed state is refused. Pure guard first, then * the service against stub repositories. */ describe('close-offset reopen', () => { // Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT. const DEPARTURE = new Date('2026-09-09T07:00:00.000Z'); // Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff. const NOW = new Date('2026-09-07T06:00:00.000Z'); const THREE_DAYS = 3 * 1_440; const cfg = { importWindowLeadDays: 3, exportBookingLeadHours: 24, windowOpenHour: 8, windowCloseHour: 17, windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, exportPaymentWindowMinutes: 60, importCloseOffsetMinutes: THREE_DAYS, exportCloseOffsetMinutes: THREE_DAYS, }; const closedByOffset = (over: Record = {}) => ({ id: 'S1', reference: 'S-2026-00001', status: 'SCHEDULED', direction: 'IMPORT', windowPhase: 'DONE', bookingWindowStatus: 'CLOSED', scheduledDepartureDate: DEPARTURE, originStationId: 'Y-ADD', destinationStationId: 'Y-DJ', ruleWindowOpenHour: 8, ruleWindowCloseHour: 17, ruleWindowDurationHours: 3, ruleImportWindowLeadDays: 3, ruleExportBookingLeadHours: 24, ruleImportCloseOffsetMinutes: THREE_DAYS, ruleExportCloseOffsetMinutes: THREE_DAYS, ...over, }); describe('closeOffsetReopenCheck', () => { it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => { const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW); expect(check.eligible).toBe(true); expect(check.offsetMinutes).toBe(THREE_DAYS); expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z'); }); it.each([ ['dispatched train', { status: 'DISPATCHED' }, /dispatched/i], ['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i], ['full train', { bookingWindowStatus: 'FULL' }, /full/i], ['window still open', { windowPhase: 'OPEN' }, /not closed yet/i], ['legacy row with no window', { windowPhase: null }, /managed booking window/i], ])('refuses a %s', (_label, over, reason) => { const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW); expect(check.eligible).toBe(false); expect(check.reason).toMatch(reason); }); it('refuses when the schedule never had an offset (booking ran to departure)', () => { const check = closeOffsetReopenCheck( closedByOffset(), { ...cfg, importCloseOffsetMinutes: null }, NOW, ); expect(check.eligible).toBe(false); expect(check.reason).toMatch(/no close offset/i); expect(check.cutoffAt).toBeNull(); }); it('refuses when the desk could not reopen before departure even with no offset', () => { // Tuesday 18:00 EAT, desk 8–17: next opening is Wednesday 08:00, but the // train departs Wednesday 07:00 EAT — the offset is not the blocker. const lateNow = new Date('2026-09-08T15:00:00.000Z'); const earlyDeparture = new Date('2026-09-09T04:00:00.000Z'); const check = closeOffsetReopenCheck( closedByOffset({ scheduledDepartureDate: earlyDeparture }), cfg, lateNow, ); expect(check.eligible).toBe(false); expect(check.reason).toMatch(/would not reopen before departure/i); }); it('reads the export offset for an EXPORT schedule', () => { const check = closeOffsetReopenCheck( closedByOffset({ direction: 'EXPORT' }), { ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 }, NOW, ); expect(check.eligible).toBe(true); expect(check.offsetMinutes).toBe(120); }); }); describe('TrainSchedulingService.reduceScheduleCloseOffset', () => { type Fixture = { schedule: Record | null; siblings?: Record[]; now?: Date; }; const makeService = (fx: Fixture) => { const updates: Array<{ id: string; patch: Record }> = []; const repo = { update: jest.fn().mockImplementation(async (id: string, patch: Record) => { updates.push({ id, patch }); }), }; const siblingsQb = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(fx.siblings ?? []), }; const dataSource = { getRepository: jest.fn().mockReturnValue(repo), manager: { getRepository: jest .fn() .mockReturnValue({ createQueryBuilder: () => siblingsQb }), }, }; const service = Object.create( TrainSchedulingService.prototype, ) as TrainSchedulingService; const emitted: string[] = []; Object.assign(service, { dataSource, trainSchedulesRepository: { findById: jest.fn().mockResolvedValue(fx.schedule), }, getWindowConfig: jest.fn().mockResolvedValue(cfg), emitWindowState: jest.fn().mockImplementation(async (id: string) => { emitted.push(id); }), logger: { log: jest.fn(), warn: jest.fn() }, }); jest.useFakeTimers().setSystemTime(fx.now ?? NOW); return { service, updates, emitted }; }; afterEach(() => jest.useRealTimers()); it('404s on an unknown schedule', async () => { const { service } = makeService({ schedule: null }); await expect( service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }), ).rejects.toBeInstanceOf(NotFoundException); }); it('refuses a train whose window is not shut by its offset', async () => { const { service, updates } = makeService({ schedule: closedByOffset({ bookingWindowStatus: 'FULL' }), }); await expect( service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }), ).rejects.toThrow(/full/i); expect(updates).toHaveLength(0); }); it('refuses an offset that is not shorter than the current one', async () => { const { service, updates } = makeService({ schedule: closedByOffset() }); await expect( service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }), ).rejects.toThrow(/shorter than the current 3 days/i); expect(updates).toHaveLength(0); }); it('refuses an offset whose new cutoff is still before the next desk opening', async () => { // 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a // cycle fits… but 2 days 1 hour (Mon 09:00) does not. const { service } = makeService({ schedule: closedByOffset() }); await expect( service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 2 * 1_440 + 60, }), ).rejects.toBeInstanceOf(BadRequestException); }); it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => { const { service, updates, emitted } = makeService({ schedule: closedByOffset() }); // 1 day before departure → new cutoff Tuesday 10:00 EAT. await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 }); expect(updates).toHaveLength(1); const [{ id, patch }] = updates; expect(id).toBe('S1'); expect(patch).toMatchObject({ ruleImportCloseOffsetMinutes: 1_440, windowRuleCustom: true, windowPhase: 'PRE_WINDOW', docReviewCompletedAt: null, docReviewEndsAt: null, paymentPhaseEndsAt: null, }); // Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z). expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString()); expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z'); expect(emitted).toEqual(['S1']); }); it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => { // Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close. const tueAfternoon = new Date('2026-09-08T13:00:00.000Z'); const { service, updates } = makeService({ schedule: closedByOffset(), now: tueAfternoon, }); await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 }); const [{ patch }] = updates; expect(patch.ruleImportCloseOffsetMinutes).toBeNull(); expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString()); // Desk close (17:00 EAT = 14:00Z) ends the cycle before departure. expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z'); }); it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => { const { service, updates } = makeService({ schedule: closedByOffset(), siblings: [ closedByOffset({ id: 'S2' }), // Already full — booking did not close because of the offset. closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }), // Still mid-cycle — must keep the state its customers see. closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }), ], }); await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 }); expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']); expect(updates[1].patch).toMatchObject({ ruleImportCloseOffsetMinutes: 1_440, windowPhase: 'PRE_WINDOW', }); }); it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => { const { service, updates } = makeService({ schedule: closedByOffset({ direction: 'EXPORT' }), siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })], }); await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 }); expect(updates).toHaveLength(1); const [{ patch }] = updates; expect(patch).toMatchObject({ ruleExportCloseOffsetMinutes: 120, windowPhase: 'PRE_WINDOW', }); expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString()); // Departure 07:00Z − 2h. expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z'); }); }); });