Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/schedule-close-offset.spec.ts
2026-09-06 13:50:07 +00:00

267 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown> = {}) => ({
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 817: 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<string, unknown> | null;
siblings?: Record<string, unknown>[];
now?: Date;
};
const makeService = (fx: Fixture) => {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const repo = {
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
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');
});
});
});