mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
enhance train scheduling and booking management
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
};
|
||||
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>): 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),
|
||||
};
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user