mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
fix issues
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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 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<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user