mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
357 lines
15 KiB
TypeScript
357 lines
15 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;
|
|
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>): 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),
|
|
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>): 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();
|
|
});
|
|
});
|
|
});
|