Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts
Marshal 11771e5f92 remove reopen delay minutes from global rules and update related types
- Removed the  field from  and related components.
- Updated  to reflect the removal of the reopen delay input field.
- Modified  to include new train number fields:  and .
- Added  interface to manage active schedules with trade direction.
- Introduced  interface to track wagon shortages in bookings.
- Updated  logic to ensure consistent UI state representation.
- Created migrations to drop the  column and add  and  columns to the  table.
- Added tests for the new booking window display logic and wagon planning functionality.
2026-07-15 09:13:02 +00:00

269 lines
12 KiB
TypeScript

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;
fillFromWaitingList: 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,
};
const baseSchedule = (over: Partial<TrainSchedule>): 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<boolean> =>
(service as unknown as {
advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise<boolean>;
}).advanceImport(s, cfg, now);
const concludeCycle = (s: TrainSchedule, now: Date): Promise<void> =>
(service as unknown as {
concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise<void>;
}).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),
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
fillFromWaitingList: 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();
});
});