Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
Hagernesh 69062389c7 fix(train-scheduling): marshalling docs respect mid-corridor wagon coupling
Origin marshalling (import + export) now prints leg slots as
TO BE LOADED AT <board yard> with board/alight stations and keeps
their containers out of the loaded tallies. Intercity Marshalling 2 no
longer hides whole-route cargo whose allocation never left PLANNED
(import flow confirms loading at schedule level), so the on-board view
matches the physical consist before and after mid-corridor coupling.
2026-08-26 08:30:38 +00:00

2025 lines
75 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, ConflictException } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
id: 'wagon-type-1',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
};
const locomotive = {
id: 'loc-1',
code: 'LOC-001',
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
currentYardId: 'yard-origin',
};
const cw3 = {
id: 'wagon-type-bulk',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
wagonsRequired: number,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
extra: Record<string, unknown> = {},
) => ({
id,
reference,
freightType: 'CONTAINER',
cargoTotalWeightVgm: weight,
scheduledDate: new Date(scheduledDate),
originYardId,
destinationYardId,
status: 'PAID',
schedulingStatus: 'HOLDING',
holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
company: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
id: `${id}-line`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
},
],
...extra,
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
manager: { getRepository: jest.Mock };
};
let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
let trainSchedulesRepository: Record<string, jest.Mock>;
let trainScheduleBookingsRepository: Record<string, jest.Mock>;
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
beforeEach(() => {
// findGroupSiblings runs a query builder off dataSource.manager; default it
// to "no sibling schedules" so isolated unit tests don't need to wire it.
const emptySiblingQb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
query: jest.fn().mockResolvedValue([]),
manager: {
getRepository: jest.fn(() => ({
createQueryBuilder: jest.fn(() => emptySiblingQb),
})),
},
};
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
findAll: jest.fn(),
updateSchedulingFields: jest.fn(),
};
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
wagonTypesRepository = { findAll: jest.fn() };
trainSchedulesRepository = {
findById: jest.fn(),
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
update: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0),
};
trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(),
findByScheduleId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(),
};
wagonBookingAllocationsRepository = {
deleteByTrainSetId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(),
};
wagonAllocationContainerItemsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
wagonAllocationBulkLoadsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
};
service = new TrainSchedulingService(
dataSource as never,
bookingsRepository as never,
locomotivesRepository as never,
wagonTypesRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
wagonBookingAllocationsRepository as never,
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
{ create: jest.fn() } as never, // trainCompositionRemovalLogRepository
{
autoUnloadArrivedBookings: jest.fn(),
autoUnloadExportAtDjibouti: jest.fn(),
} as never,
{
htmlToPdfBuffer: jest.fn(),
} as never,
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
{
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
} as never, // bookingJourneyService
{ dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
);
const defaultFleetWagons = [
...Array.from({ length: 100 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
})),
...Array.from({ length: 50 }, (_, index) => ({
id: `wagon-cw3-${index}`,
wagonTypeId: cw3.id,
status: WagonStatus.Available,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
})),
];
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
});
it('returns fleet availability and defers bookings when fleet is insufficient', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const availableWagons = Array.from({ length: 15 }, (_, index) => ({
id: `wagon-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(availableWagons) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
// Availability rows now report what the BOUNDED plan actually uses per
// type (never more than stock, so no shortfall on the rows themselves);
// the shortage is carried by the deferred bookings' own shortage rows.
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
expect(
result.fleetAvailability?.every((row) => row.needed <= row.available),
).toBe(true);
expect(result.deferredBookings?.length).toBeGreaterThan(0);
expect(result.deferredBookings?.[0]?.reason).toContain('short');
expect(result.summary.wagonsNeeded).toBeLessThan(30);
expect(result.warnings.some((w) => w.includes('deferred'))).toBe(true);
});
it('computes slot-based preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
// TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
// the planner packs by container size, not the stored per-line fallback.
expect(result.summary.wagonsNeeded).toBe(40);
expect(result.wagonPlan).toHaveLength(40);
});
it('returns soft hold warnings without forceAssign', async () => {
const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('soft hold window');
});
it('warns on the overweight booking but still allows scheduling', async () => {
const bookings = [
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
bookingContainers: [
{
id: 'b6-line',
containerTypeId: 'ct-1',
quantity: 80,
wagonsRequired: 80,
vgmPerUnitTons: 45,
isOverweight: true,
containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
},
],
}),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.violations.some((v) => v.includes('overweight'))).toBe(false);
expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true);
});
it('allows preview when bookings are already on the target schedule', async () => {
// A booking already pinned to the target schedule is exempt from the
// corridor/day/status gates — mark it so on the entity, matching the link row.
const bookings = [
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
{ bookingId: 'b1', trainScheduleId: 'sched-target' },
]);
trainSchedulesRepository.findById.mockResolvedValue({
id: 'sched-target',
direction: 'IMPORT',
});
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
targetScheduleId: 'sched-target',
});
expect(result.violations).not.toContain(
'One or more selected bookings are already assigned to a train schedule',
);
expect(result.valid).toBe(true);
});
it('flags a booking scheduled for a different day than the train departure', async () => {
// The old cross-booking "must share the same schedule date" rule is gone;
// the live rule is that every booking must match the departure day. b2
// departs a day later, so it's the one flagged.
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.violations).not.toContain(
'Selected bookings must share the same schedule date',
);
expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
expect(result.valid).toBe(false);
});
it('rejects bookings that are not in schedulable status', async () => {
const bookings = [
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(false);
expect(result.violations).toContain(
'Only PAID bookings can be scheduled; received: APPROVED',
);
});
it('creates a schedule transactionally when validation passes', async () => {
const route = {
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
};
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
const lockedLocomotiveRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce(locomotive)
.mockResolvedValueOnce(locomotive2),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
})),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const trainSetLocomotiveRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
case 'Locomotive':
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainSet':
return trainSetRepo;
case 'TrainSetLocomotive':
return trainSetLocomotiveRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
// of now), so use a comfortably-future date rather than a hardcoded one.
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
const result = await service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: futureDeparture,
locomotiveIds: ['loc-1', 'loc-2'],
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
// one locomotive may sit on several future schedules.
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
expect(result.id).toBe('schedule-1');
});
it('previews mixed container and bulk bookings', async () => {
const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
status: 'PAID',
bookingContainers: [],
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
};
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
if (where?.code === 'NW5') return [nw5];
return [nw5, cw3];
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c1', 'b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
// Mixed freight now labels the summary by the concrete wagon type codes it uses.
expect(result.summary.wagonType).toContain('NW5');
expect(result.summary.wagonType).toContain('CW3');
expect(result.wagonPlan.length).toBeGreaterThan(2);
expect(result.containerUnits).toHaveLength(2);
});
it('previews container bookings without requiring placements', async () => {
const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c2'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.containerUnits).toHaveLength(1);
});
it('rejects create when the locked locomotive is no longer available', async () => {
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
})),
};
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
findOne: jest.fn().mockResolvedValue({
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
}),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveIds: ['loc-1', 'loc-2'],
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects pin when wagon is not at the schedule origin yard', async () => {
const scheduleId = 'sched-1';
const slotId = 'slot-1';
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
originStationId: 'yard-origin',
trainSet: {
wagons: [{ id: slotId, physicalWagonId: null }],
},
});
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity === Wagon) {
return {
findOne: jest.fn().mockResolvedValue({
id: 'wagon-1',
wagonNumber: 'WGN-001',
status: WagonStatus.Available,
currentYardId: 'yard-other',
currentTrainScheduleId: null,
}),
update: jest.fn(),
};
}
if (entity === TrainSetWagon) {
return { update: jest.fn() };
}
throw new Error(`Unexpected repository ${entity?.name}`);
}),
};
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
callback(manager),
);
await expect(
service.pinWagons(scheduleId, {
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
const exportBooking = makeBooking(
'exp-1',
'BKG-EXP',
50,
1,
'40FT',
1,
'2026-06-20T08:00:00.000Z',
'yard-addis',
'yard-djibouti',
{
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
},
);
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([
{ ...locomotive, currentYardId: 'yard-addis' },
]);
const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
wagonNumber: `WGN-${index}`,
status: WagonStatus.Available,
currentYardId: 'yard-djibouti',
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.previewContainerTrainSchedule({
bookingIds: ['exp-1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-addis',
destinationStationId: 'yard-djibouti',
});
// List-based planner: a booking with no plannable wagon at the yard is
// DEFERRED with the wagon-type reason (assign still hard-fails when no
// booking fits), instead of surfacing a phantom-slot violation.
expect(result.valid).toBe(true);
expect(result.wagonPlan).toHaveLength(0);
expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
});
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
const scheduleId = 'sched-assign-1';
const trainSetId = 'train-set-1';
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
trainSchedulesRepository.findById.mockResolvedValue({
id: scheduleId,
direction: 'IMPORT',
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
trainSetId,
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
trainSet: {
id: trainSetId,
locomotive,
wagons: [],
},
scheduleBookings: [],
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const wagonRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn(),
};
const trainSetWagonRepo = {
delete: jest.fn(),
create: jest.fn((v) => v),
save: jest.fn(async (rows) =>
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
...r,
id: `slot-${i + 1}`,
})),
),
update: jest.fn(),
};
const manager = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Wagon) return wagonRepo;
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
if (entity === TrainSetWagon) return trainSetWagonRepo;
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
return {
create: jest.fn((v) => v),
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
delete: jest.fn(),
};
}
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
}),
};
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
cb(manager),
);
await expect(
service.assignBookingsToSchedule(
scheduleId,
{ bookingIds: ['b-pin'], containerPlacements: [] },
'CONTAINER',
),
).rejects.toBeInstanceOf(BadRequestException);
});
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
const future = new Date(Date.now() + 30 * 24 * 3600_000);
const update = jest.fn();
beforeEach(() => {
update.mockClear();
// Global rules read + the TrainSchedule repo the restamp writes through.
dataSource.getRepository.mockImplementation((entity: unknown) => {
const name = (entity as { name?: string })?.name;
if (name === 'TrainSchedulingGlobalRules') {
return { find: jest.fn().mockResolvedValue([]) };
}
return { update };
});
});
it('re-stamps a schedule that follows the global rules', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: 'sched-global',
direction: 'IMPORT',
scheduledDepartureDate: future,
windowRuleCustom: false,
},
]);
await expect(service.restampPendingWindows()).resolves.toBe(1);
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
});
it('leaves a hand-configured schedule alone', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: 'sched-custom',
direction: 'IMPORT',
scheduledDepartureDate: future,
windowRuleCustom: true,
},
]);
// Staff picked these times deliberately — a global-rules edit must not
// overwrite them, or the per-schedule configuration would be pointless.
await expect(service.restampPendingWindows()).resolves.toBe(0);
expect(update).not.toHaveBeenCalled();
});
});
describe('getUnassignedBookings', () => {
const scheduleId = 'sched-unassigned-1';
const trainSetId = 'train-set-unassigned';
const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);
const buildScheduleGraph = () => ({
id: scheduleId,
status: 'DRAFT',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
trainSet: {
id: trainSetId,
locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
},
scheduleBookings: [],
});
beforeEach(() => {
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
bookingsRepository.findAll.mockResolvedValue([
{
...assignedBooking,
trainScheduleId: scheduleId,
paymentStatus: 'PAID',
isGovernment: false,
},
{
...unassignedBooking,
trainScheduleId: scheduleId,
paymentStatus: 'PAID',
isGovernment: false,
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
});
it('allows assign when train slots are full but origin yard has matching wagons', async () => {
const yardFleet = [
{
id: 'wagon-pinned',
wagonTypeId: nw5.id,
status: WagonStatus.Assigned,
currentYardId: 'yard-origin',
currentTrainScheduleId: scheduleId,
},
...Array.from({ length: 2 }, (_, index) => ({
id: `wagon-yard-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
})),
];
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
const map = new Map([
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
]);
return ids.map((id) => map.get(id)).filter(Boolean);
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(yardFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
if (entity === WagonBookingAllocation) {
return {
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
};
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.getUnassignedBookings(scheduleId);
expect(result.bookings).toHaveLength(1);
expect(result.bookings[0].id).toBe(unassignedBooking.id);
expect(result.bookings[0].canAssign).toBe(true);
expect(result.bookings[0].blockReason).toBeNull();
expect(
result.fleetAtOrigin.some(
(row: { wagonTypeCode: string; available: number }) =>
row.wagonTypeCode === 'NW5' && row.available >= 2,
),
).toBe(true);
});
it('blocks assign when origin yard lacks wagons of the required type', async () => {
const yardFleet = [
{
id: 'wagon-pinned',
wagonTypeId: nw5.id,
status: WagonStatus.Assigned,
currentYardId: 'yard-origin',
currentTrainScheduleId: scheduleId,
},
];
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
const map = new Map([
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
]);
return ids.map((id) => map.get(id)).filter(Boolean);
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(yardFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
if (entity === WagonBookingAllocation) {
return {
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
};
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.getUnassignedBookings(scheduleId);
expect(result.bookings).toHaveLength(1);
expect(result.bookings[0].canAssign).toBe(false);
expect(result.bookings[0].blockReason).toBeTruthy();
});
});
describe('getAvailableLocomotivesForRoute', () => {
it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
const routeId = 'route-export';
const originYardId = 'yard-addis';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Djibouti',
isActive: true,
status: 'AVAILABLE',
originYardId,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' },
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() };
});
// Advance-scheduling picker: nothing is filtered by yard — every in-service
// locomotive is returned and annotated with whether it's at the origin yet.
locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(2);
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
});
it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
const routeId = 'route-domestic';
const originYardId = 'yard-addis';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Dire Dawa',
isActive: true,
status: 'AVAILABLE',
originYardId,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' },
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() };
});
await expect(
service.getAvailableLocomotivesForRoute(routeId),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('marshalling documents', () => {
// Staff check these against the physical consist, so every wagon on the
// train set has to appear — an empty wagon that renders no row reads as a
// wagon that is not on the train.
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
sequenceNo,
wagonNumber,
physicalWagon: { wagonNumber },
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
lengthMeters: 14,
capacityTons: 70,
allocations,
});
const loadedAllocation = {
bookingId: 'booking-1',
bookingReference: 'BK-2026-000001',
loadType: 'CONTAINER',
allocatedWeightTons: 24.5,
containerNumbers: ['CONT-001'],
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
};
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
it('lists an empty wagon on the export document and marks it EMPTY', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [
makeWagon(1, 'W-001', [loadedAllocation]),
makeWagon(2, 'W-002', []),
makeWagon(3, 'W-003', []),
],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(countRows(html)).toBe(3);
expect(html).toContain('W-002');
expect(html).toContain('W-003');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
// The wagon count must agree with the rows the reader can see.
expect(html).toContain('3 (2 empty)');
});
it('lists an empty wagon on the import document and marks it EMPTY', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
trainNumber: '8002',
route: 'Djibouti → Indode',
origin: 'Djibouti Port',
destination: 'Indode',
totalBookings: 1,
wagons: [
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
],
operation: { status: {} },
};
const html = (service as never as {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
expect(countRows(html)).toBe(2);
expect(html).toContain('W-002');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('2 (1 empty)');
});
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
trainNumber: '7002',
route: 'DCT/SGTD → GMP',
origin: 'DCT/SGTD',
destination: 'GMP',
totalBookings: 2,
wagons: [
{
sequenceNo: 1,
wagonNumber: 'W-ICY',
boardYard: 'Dire Dawa Port',
alightYard: null,
allocations: [
{
...loadedAllocation,
containerItems: [{ containerNumber: 'ICY-001' }],
},
],
},
{
sequenceNo: 2,
wagonNumber: 'W-IMP',
boardYard: null,
alightYard: null,
allocations: [
{
...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
},
],
},
],
operation: { status: {} },
};
const html = (service as never as {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
// Departure station of the leg slot is its board yard, not the origin.
expect(html).toContain('<td>Dire Dawa Port</td>');
// Only the origin-loaded container counts; the leg slot's tallies separately.
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => {
const sizedAllocation = {
...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
};
const legWagon = { ...makeWagon(2, 'W-LEG', [sizedAllocation]), id: 'slot-leg' };
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, legWagon] },
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
});
expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
it('lists loaded empty containers by number and states they are empty', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8301',
direction: 'EXPORT',
trainSet: {
wagons: [makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', []), makeWagon(3, 'W-003', [])],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml(schedule, {
emptyContainers: [
{ containerNumber: 'CMU9876543', containerSize: '40', wagonSequenceNo: 1 },
{ containerNumber: 'TEMU1112223', containerSize: '20', wagonSequenceNo: 2 },
{ containerNumber: 'TEMU4445556', containerSize: '20', wagonSequenceNo: 2 },
],
});
expect(html).toContain('CMU9876543');
expect(html).toContain('TEMU1112223, TEMU4445556');
expect(html.match(/EMPTY CONTAINER/g)).toHaveLength(2);
// Wagon 3 carries nothing at all, so it keeps the bare-wagon wording.
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('3 (1 empty)');
expect(html).toContain('<span>Empty containers</span><strong>3</strong>');
});
it('renders wagons in consist order regardless of the order the relation returns', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
});
it('omits the empty-count suffix when every wagon is loaded', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown) => string;
}).buildExportLoadListHtml(schedule);
expect(html).not.toContain('empty)');
expect(html).not.toContain('EMPTY');
});
// ---- intercity marshalling (Marshalling 2): the current on-board view ----
const onBoardView = (schedule: unknown) =>
(service as never as {
intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] };
}).intercityOnBoardView(schedule);
const buildWithOpts = (schedule: unknown, opts: unknown) =>
(service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml(schedule, opts);
const allocWith = (over: Record<string, unknown>) => ({ ...loadedAllocation, ...over });
it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => {
const schedule = {
trainSet: {
wagons: [
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' },
{
...makeWagon(3, 'W-003', [
allocWith({ status: 'LOADED', bookingId: 'booking-3' }),
allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }),
]),
status: 'RESERVED',
},
],
},
scheduleBookings: [],
};
const { wagons } = onBoardView(schedule);
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
(w) => w.physicalWagon.wagonNumber,
);
expect(numbers).toEqual(['W-001', 'W-003']);
const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find(
(w) => w.physicalWagon.wagonNumber === 'W-003',
);
expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']);
});
it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' },
],
},
scheduleBookings: [],
};
const { wagons, unassignedBookings } = onBoardView(schedule);
const html = buildWithOpts(schedule, { wagons, unassignedBookings });
expect(html).toContain('W-002');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('2 (1 empty)');
});
it('keeps whole-route cargo whose allocation never left PLANNED (import flow) on board', () => {
// The import flow confirms loading at schedule level and never flips the
// allocation to LOADED — the cargo is still on the train until DEPARTED.
const schedule = {
trainSet: {
wagons: [
{ ...makeWagon(1, 'W-IMP', [allocWith({ status: 'PLANNED' })]), status: 'RESERVED' },
{ ...makeWagon(2, 'W-ICY', [allocWith({ status: 'LOADED', bookingId: 'booking-2' })]), status: 'RESERVED', boardYardId: 'yard-mid' },
],
},
scheduleBookings: [],
};
const { wagons } = onBoardView(schedule);
const byNumber = wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: unknown[] }>;
expect(byNumber.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-IMP', 'W-ICY']);
expect(byNumber[0].allocations).toHaveLength(1);
});
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
const schedule = {
trainSet: { wagons: [legWagonEmpty, legWagonLoaded] },
scheduleBookings: [],
};
const { wagons } = onBoardView(schedule);
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
(w) => w.physicalWagon.wagonNumber,
);
expect(numbers).toEqual(['W-LEG2']);
});
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
const rider = {
id: 'booking-9',
reference: 'BK-2026-000009',
status: 'IN_TRANSIT',
company: { name: 'Rider Co' },
cargoType: { cargoTypeName: 'Cement', code: 'CEM' },
originYard: { label: 'Adama' },
destinationYard: { label: 'Dire Dawa' },
bookingContainers: [{ containerNumber: 'RIDE-001' }],
};
const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' };
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] },
scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }],
};
const { wagons, unassignedBookings } = onBoardView(schedule);
expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']);
const html = buildWithOpts(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel: 'After Dire Dawa',
wagons,
unassignedBookings,
});
expect(html).toContain('ON BOARD — WAGON NOT RECORDED');
expect(html).toContain('BK-2026-000009');
expect(html).toContain('RIDE-001');
expect(html).not.toContain('BK-2026-000008');
expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)');
expect(html).toContain('After Dire Dawa');
});
it('rejects the intercity marshalling document for a train that has not been dispatched', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'schedule-1',
status: 'SCHEDULED',
});
await expect(
service.intercityMarshallingDocument('schedule-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('moveWagonLoad — staff rearrange', () => {
const containerType = {
code: 'NX70',
supportedLoadTypes: ['CONTAINER'],
supportsContainer: true,
};
let slotA: Record<string, unknown>;
let slotB: Record<string, unknown>;
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
let allocRepo: { find: jest.Mock; update: jest.Mock };
let slotRepo: {
update: jest.Mock;
create: jest.Mock;
save: jest.Mock;
createQueryBuilder: jest.Mock;
};
let wagonRepo: { findOne: jest.Mock };
const makeSchedule = (over: Record<string, unknown> = {}) => ({
id: 'sched-1',
status: 'SCHEDULED',
trainSetId: 'ts-1',
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
...over,
});
beforeEach(() => {
slotA = {
id: 'wA',
trainSetId: 'ts-1',
sequenceNo: 1,
capacityTons: 61,
lengthMeters: 14,
assignedWeightTons: 40,
status: 'RESERVED',
boardYardId: 'yard-1',
alightYardId: null,
wagonType: containerType,
};
slotB = {
id: 'wB',
trainSetId: 'ts-1',
sequenceNo: 2,
capacityTons: 61,
lengthMeters: 14,
assignedWeightTons: 25,
status: 'RESERVED',
boardYardId: null,
alightYardId: null,
wagonType: containerType,
};
allocsByWagon = {
// 20ft pair (two allocations sharing wagon A) — must travel together.
wA: [
{ id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
{ id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
],
// one 40ft on wagon B.
wB: [
{ id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
],
};
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
allocRepo = {
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
),
update: jest.fn().mockResolvedValue(undefined),
};
slotRepo = {
update: jest.fn().mockResolvedValue(undefined),
create: jest.fn((row: Record<string, unknown>) => row),
save: jest.fn((row: Record<string, unknown>) =>
Promise.resolve({ id: 'slot-new', ...row }),
),
createQueryBuilder: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({ maxSequenceNo: 2 }),
})),
};
wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WagonBookingAllocation) return allocRepo;
if (entity === TrainSetWagon) return slotRepo;
if (entity === Wagon) return wagonRepo;
return { find: jest.fn().mockResolvedValue([]) };
});
dataSource.transaction.mockImplementation(
async (fn: (m: unknown) => Promise<void>) =>
fn({ getRepository: dataSource.getRepository }),
);
jest
.spyOn(
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
'getTrainScheduleById' as never,
)
.mockResolvedValue({ id: 'sched-1' } as never);
});
it('rejects moves on a dispatched train', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
makeSchedule({ status: 'DISPATCHED' }),
);
await expect(
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
).rejects.toThrow(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
await expect(
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
).rejects.toThrow(/not part of this schedule/);
});
it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
// The 20ft pair moved together onto wagon B…
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
// …and the 40ft came back to wagon A.
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
// Load-coupled slot fields follow their loads.
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
assignedWeightTons: 40,
status: 'RESERVED',
boardYardId: 'yard-1',
alightYardId: null,
});
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
assignedWeightTons: 25,
status: 'RESERVED',
boardYardId: null,
alightYardId: null,
});
});
it('moves the load onto an empty consist-only wagon without renaming wagons', async () => {
wagonRepo.findOne.mockResolvedValue({
id: 'phys-9',
wagonTypeId: 'wt-1',
wagonNumber: 'WGN-9',
wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
});
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });
expect(wagonRepo.findOne).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
);
// A slot is created ON the target wagon, carrying the source's load
// fields. sequence_no appends past the existing max so it clears the
// (train_set_id, sequence_no) unique index.
expect(slotRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
trainSetId: 'ts-1',
physicalWagonId: 'phys-9',
wagonTypeId: 'wt-1',
sequenceNo: 3,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 40,
status: 'RESERVED',
boardYardId: 'yard-1',
alightYardId: null,
}),
);
// The whole load crosses onto that new slot…
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'slot-new' });
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'slot-new' });
// …and the source wagon stays itself, just empty.
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
assignedWeightTons: 0,
status: 'PLANNED',
boardYardId: null,
alightYardId: null,
});
// The bug this replaced: the source slot must NOT be repinned to another
// physical wagon — that reorders the train instead of moving the load.
expect(slotRepo.update).not.toHaveBeenCalledWith(
'wA',
expect.objectContaining({ physicalWagonId: expect.anything() }),
);
});
it('reuses the existing slot when the target wagon is addressed by wagon id', async () => {
// wB is already pinned to physical wagon phys-B. Addressing that wagon
// directly must land in wB, not mint a second slot on the same wagon.
(slotB as Record<string, unknown>).physicalWagonId = 'phys-B';
wagonRepo.findOne.mockResolvedValue({
id: 'phys-B',
wagonTypeId: 'wt-1',
wagonNumber: 'WGN-B',
wagonType: containerType,
});
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-B' });
expect(slotRepo.save).not.toHaveBeenCalled();
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
});
it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
allocsByWagon.wA = [
{ id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
];
allocsByWagon.wB = [];
await expect(
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
).rejects.toThrow(/cannot carry a bulk load/);
});
it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
allocsByWagon.wA = [
{ id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
];
allocsByWagon.wB = [];
await expect(
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
).rejects.toThrow(/over its/);
});
});
describe('government booking protection', () => {
const scheduleId = 'sched-gov-1';
const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, {
isGovernment: true,
wagonsRequired: 10,
});
const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
wagonsRequired: 5,
});
const scheduleGraph = {
id: scheduleId,
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
trainSetId: 'ts-1',
trainSet: {
id: 'ts-1',
locomotive,
wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }],
},
scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }],
};
it('unassignBooking rejects a government booking', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking);
await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow(
/Government bookings cannot be removed/,
);
});
it('switchGovernmentBooking rejects a non-government incoming booking', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]);
await expect(
service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']),
).rejects.toThrow(/Only government bookings/);
});
it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WagonBookingAllocation) {
return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) };
}
return { find: jest.fn().mockResolvedValue([]) };
});
bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) =>
Promise.resolve(
ids.map((id) => (id === 'gov-1' ? govBooking : commercial)),
),
);
jest
.spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig')
.mockResolvedValue({} as never);
// Gov booking fits the plan (10 slots) but the switched-out booking only
// frees 5 wagons — the user-facing wagon rule must still reject it.
jest
.spyOn(
service as never as { validateBookingsForScheduling: () => unknown },
'validateBookingsForScheduling',
)
.mockResolvedValue({
valid: true,
violations: [],
warnings: [],
deferredBookings: [],
bookings: [govBooking],
wagonPlan: Array.from({ length: 10 }, (_, i) => ({
sequenceNo: i + 1,
allocations: [{ bookingId: 'gov-1' }],
})),
} as never);
await expect(
service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']),
).rejects.toThrow(/free only 5/);
});
});
describe('unassignBooking — MANUAL_ONLY status', () => {
const scheduleId = 'sched-rm-1';
const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
status: 'PAID',
wagonsRequired: 5,
});
const graph = {
id: scheduleId,
status: 'DRAFT',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
trainSetId: 'ts-rm',
trainSet: {
id: 'ts-rm',
locomotive,
trainId: null,
wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }],
},
scheduleBookings: [{ bookingId: 'bk-rm' }],
};
const txManager = {
getRepository: jest.fn(() => ({
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
save: jest.fn().mockResolvedValue(undefined),
create: jest.fn((x: unknown) => x),
})),
};
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph);
bookingsRepository.findById = jest.fn().mockResolvedValue(removed);
bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined);
dataSource.transaction.mockImplementation(
async (fn: (m: unknown) => Promise<void>) => fn(txManager),
);
jest
.spyOn(
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
'getTrainScheduleById' as never,
)
.mockResolvedValue({ id: scheduleId } as never);
});
it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => {
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith(
'bk-rm',
expect.objectContaining({
schedulingStatus: 'MANUAL_ONLY',
trainScheduleId: null,
wagonsRequired: null,
}),
expect.anything(),
);
expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith(
scheduleId,
'bk-rm',
expect.anything(),
);
expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith(
['alloc-rm-1'],
expect.anything(),
);
});
it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => {
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]);
expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false);
});
});
describe('updateCheckpoint — leg time correction', () => {
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
const schedule = {
id: 'sch-track',
status: 'ARRIVED',
routeId: null,
originStationId: 'y0',
destinationStationId: 'y1',
actualDepartureAt: t(8),
};
const events = () => [
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
];
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
});
it('rejects a leg time earlier than the previous leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
).rejects.toThrow(/cannot be earlier than/);
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
});
it('rejects a leg time later than the next leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
).rejects.toThrow(/cannot be later than/);
});
it('rejects a future time', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
).rejects.toThrow(/future/);
});
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
await service.updateCheckpoint('sch-track', 1, {
occurredAt: t(11).toISOString(),
note: 'late log',
});
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
occurredAt: t(11),
note: 'late log',
});
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
actualArrivalAt: t(11),
});
});
});
describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number })
.effectiveWagonsRequired(booking);
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
// ceil(20/4) = 5 wagons.
const perItemBooking = (wagonsRequired: number | null) => ({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired,
cargoType: {
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
itemsPerWagonMap: { 'wt-nw5': 4 },
},
});
it('overrides a stale too-small stamp with the item-aware recompute', () => {
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
expect(effective(perItemBooking(1))).toBe(5);
});
it('keeps a stored stamp that is at least the recompute', () => {
expect(effective(perItemBooking(7))).toBe(7);
});
it('trusts the stamp when BULK cargo relations are not loaded', () => {
expect(
effective({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired: 5,
cargoType: null,
}),
).toBe(5);
});
});
describe('maintenanceReschedule — window reopens when it had already finished', () => {
const { TrainSchedule } = jest.requireActual(
'../../train-schedules/entities/train-schedule.entity',
);
const doneExportSchedule = (extra: Record<string, unknown> = {}) => ({
id: 'sch-done',
status: 'SCHEDULED',
direction: 'EXPORT',
windowPhase: 'DONE',
bookingWindowStatus: 'CLOSED',
scheduledDepartureDate: new Date('2027-06-20T05:00:00.000Z'),
scheduledArrivalDate: null,
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [],
// Frozen rule snapshot: desk 817 EAT, 24h lead, close 120min before departure.
ruleWindowOpenHour: 8,
ruleWindowCloseHour: 17,
ruleExportBookingLeadHours: 24,
ruleExportCloseOffsetMinutes: 120,
...extra,
});
let scheduleUpdate: jest.Mock;
beforeEach(() => {
scheduleUpdate = jest.fn().mockResolvedValue({ affected: 1 });
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === TrainSchedule) {
return { update: scheduleUpdate, find: jest.fn().mockResolvedValue([]) };
}
return {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn(),
};
});
trainSchedulesRepository.findById.mockResolvedValue(null);
});
it('reopens a DONE export window against the new departure', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule(),
);
// New departure 12:00 EAT → window opens 24h earlier (12:00 EAT, inside
// the desk) and closes at departure 120min = 10:00 EAT.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-20T09:00:00.000Z',
} as never);
expect(scheduleUpdate).toHaveBeenCalledWith(
'sch-done',
expect.objectContaining({
scheduledDepartureDate: new Date('2027-06-20T09:00:00.000Z'),
windowPhase: 'PRE_WINDOW',
bookingWindowStatus: 'CLOSED',
windowOpensAt: new Date('2027-06-19T09:00:00.000Z'),
windowClosesAt: new Date('2027-06-20T07:00:00.000Z'),
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
}),
);
});
it('keeps a FULL train closed — nothing left to sell', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({ bookingWindowStatus: 'FULL' }),
);
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-20T09:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-20T09:00:00.000Z'),
);
expect(written.windowPhase).toBeUndefined();
expect(written.windowOpensAt).toBeUndefined();
});
it('moves an OPEN export close to the new departure but keeps the open', async () => {
const opensAt = new Date('2027-06-19T03:00:00.000Z');
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowOpensAt: opensAt,
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
}),
);
// Departure pushed 3 days later → close = new departure 120min; the
// open customers already booked against stays untouched.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-23T05:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-23T05:00:00.000Z'),
);
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
expect(written.windowOpensAt).toBeUndefined();
expect(written.windowPhase).toBeUndefined();
});
});
});