Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
2026-08-04 08:59:33 +00:00

1540 lines
56 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 { BookingBatchService } from './booking-batch.service';
import { paymentDrainMs } from './booking-batch.constants';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonStockLedger } from './wagon-stock-ledger.util';
/**
* A pay window closed long enough ago to be past its drain tail as well — i.e.
* genuinely expirable. Inside the tail nothing expires (see payWindowLapsed).
*/
const fullyLapsedDeadline = () => new Date(Date.now() - 60_000 - paymentDrainMs());
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
const bookingId = 'booking-1';
const paidBooking = {
id: bookingId,
reference: 'BK-2026-000034',
trainScheduleId: scheduleId,
status: 'PAID',
paymentStatus: 'PAID',
isGovernment: false,
cargoTotalWeightVgm: 20,
bookingContainers: [],
} as unknown as Booking;
let service: BookingBatchService;
let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock;
findBatchPoolByRouteDay: jest.Mock;
findBatchPoolByCorridorDay: jest.Mock;
findUnacceptedForRouteDay: jest.Mock;
findReservedForSchedule: jest.Mock;
update: jest.Mock;
};
let trainScheduleBookingsRepository: {
existsForBooking: jest.Mock;
createMany: jest.Mock;
};
let trainSchedulesRepository: {
findById: jest.Mock;
findByIdWithFullGraph: jest.Mock;
findAll: jest.Mock;
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
wagonStockForSchedule: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
secured: jest.Mock;
expired: jest.Mock;
unplaced: jest.Mock;
};
beforeEach(() => {
bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]),
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
trainScheduleBookingsRepository = {
existsForBooking: jest.fn().mockResolvedValue(false),
createMany: jest.fn().mockResolvedValue(undefined),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue({
id: scheduleId,
bookingWindowStatus: 'OPEN',
windowPhase: null,
}),
findByIdWithFullGraph: jest.fn().mockResolvedValue({
id: scheduleId,
maxWagons: 10,
bookingWindowStatus: 'OPEN',
trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } },
scheduleBookings: [],
}),
findAll: jest.fn().mockResolvedValue([]),
};
trainSchedulingService = {
tryAutoWagonAllocation: jest.fn().mockResolvedValue({
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
}),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
// No physical stock configured → the wagon-type gate stands down and these
// specs keep testing the abstract capacity budget on its own.
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'YARD',
remainingByTypeId: new Map<string, number>(),
codesByTypeId: new Map<string, string>(),
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
}),
};
const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined),
// WagonType.find() / global-rules find() fall back to defaults when empty.
find: jest.fn().mockResolvedValue([]),
};
dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo),
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
const manager = {
getRepository: () => bookingRepo,
};
await fn(manager);
}),
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
// Empty = unresolvable, so the physical-stock gate stands down and these
// specs keep exercising the abstract capacity budget alone.
query: jest.fn().mockResolvedValue([]),
};
notifier = {
payNow: jest.fn(),
secured: jest.fn(),
expired: jest.fn(),
unplaced: jest.fn(),
};
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{
issuePayable: jest.fn().mockResolvedValue(null),
expirePayable: jest.fn().mockResolvedValue(undefined),
// Gateway reconcile-before-expire: default = verifiably unpaid.
reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
);
});
it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => {
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]);
await service.reconcilePaidUnlinked(scheduleId);
expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId);
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
[{ trainScheduleId: scheduleId, bookingId }],
expect.anything(),
);
});
it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => {
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1);
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
});
it('ensurePaidBookingAllocated is idempotent when already linked', async () => {
trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true);
await service.ensurePaidBookingAllocated(bookingId);
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
});
it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
wagonTypeCodes: 'NW6',
wagonsNeeded: 1,
wagonsAvailable: 0,
wagonsShort: 1,
});
await service.ensurePaidBookingAllocated(bookingId);
// Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
expect(dataSource.getRepository().update).toHaveBeenCalledWith(
bookingId,
expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
);
});
it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
{ ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
]);
await service.reconcilePaidUnlinked(scheduleId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(
trainSchedulingService.previewPaidBookingWagonShortage,
).not.toHaveBeenCalled();
});
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
await service.processSchedule(scheduleId);
expect(fillSpy).toHaveBeenCalledWith(scheduleId);
expect(settleSpy).toHaveBeenCalledWith(scheduleId);
expect(reconcileSpy).toHaveBeenCalledWith(scheduleId);
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
const fillOrder = fillSpy.mock.invocationCallOrder[0];
const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0];
const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0];
expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('extendPaymentPhaseForTopUp', () => {
const schedRepo = () => dataSource.getRepository();
it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => {
const soon = new Date(Date.now() + 5_000); // phase almost over
const departure = new Date(Date.now() + 24 * 3_600_000);
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: soon,
scheduledDepartureDate: departure,
});
await service.extendPaymentPhaseForTopUp(scheduleId);
// paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon.
expect(schedRepo().update).toHaveBeenCalledWith(
scheduleId,
expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }),
);
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan(
soon.getTime(),
);
});
it('does not pull the deadline in when the current end is already later', async () => {
const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: far,
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
});
await service.extendPaymentPhaseForTopUp(scheduleId);
expect(schedRepo().update).not.toHaveBeenCalled();
});
it('is a no-op outside the PAYMENT phase', async () => {
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'OPEN',
paymentPhaseEndsAt: null,
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
});
await service.extendPaymentPhaseForTopUp(scheduleId);
expect(schedRepo().update).not.toHaveBeenCalled();
});
it('never extends past departure', async () => {
const departure = new Date(Date.now() + 60_000); // 1 min away
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: new Date(Date.now() + 1_000),
scheduledDepartureDate: departure,
});
await service.extendPaymentPhaseForTopUp(scheduleId);
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual(
departure.getTime(),
);
});
});
describe('fillRouteDay — day-level distribution', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const day = '2026-06-20';
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
const trainA = 'train-a';
const trainB = 'train-b';
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
const commercial = (id: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
originYardId,
destinationYardId,
}) as unknown as Booking;
beforeEach(() => {
// Two OPEN legacy trains on the same route + day, train A earlier than train B.
// fillRouteDay now selects fillable schedules straight from the repository.
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: trainA,
originStationId: originYardId,
destinationStationId: destinationYardId,
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
bookingWindowStatus: 'OPEN',
windowPhase: null,
},
{
id: trainB,
originStationId: originYardId,
destinationStationId: destinationYardId,
scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'),
bookingWindowStatus: 'OPEN',
windowPhase: null,
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
Promise.resolve({
id,
maxWagons: 1,
bookingWindowStatus: 'OPEN',
trainSetId: `set-${id}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
originStationId: originYardId,
destinationStationId: destinationYardId,
}),
);
});
it('spills overflow to the next train by priority, then reports unplaced', async () => {
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
commercial('hi', 30),
commercial('mid', 20),
commercial('lo', 10),
]);
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith(
[originYardId, destinationYardId],
day,
);
// Both trains were processed.
expect(touched).toEqual([trainA, trainB]);
// Highest priority reserved on train A, next on train B (commercial → reserve).
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedOn).toEqual(['hi', 'mid']);
// The third booking fits no train and is reported unplaced (and only it).
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
});
it('reserves the chosen train id on each commercial booking', async () => {
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'hi',
expect.objectContaining({
trainScheduleId: trainA,
status: 'SELECTED_FOR_BATCH',
}),
);
});
it('reserves both partners of a consolidated pair together on one train', async () => {
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
const consol = (id: string, partnerId: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
consolidationPartnerId: partnerId,
bookingContainers: [{ quantity: 1 }],
originYardId,
destinationYardId,
}) as unknown as Booking;
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
consol('a', 'b', 30),
consol('b', 'a', 20),
]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// Both reserved on the same (first) train; neither reported unplaced.
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedIds.sort()).toEqual(['a', 'b']);
expect(notifier.unplaced).not.toHaveBeenCalled();
});
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
const lonely = {
id: 'a',
reference: 'a',
isGovernment: false,
priorityScore: 30,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
consolidationPartnerId: 'missing-partner',
bookingContainers: [{ quantity: 1 }],
originYardId,
destinationYardId,
} as unknown as Booking;
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// Never reserved — waits for its partner in a later cycle.
expect(notifier.payNow).not.toHaveBeenCalled();
});
it('clears a stale FULL flag and fills a train whose bookings all expired', async () => {
// The deadlock: train A filled once, every booking then expired, but
// bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever
// reads the budget, so the batch skipped the train forever — it just cycled
// PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd
// already-pinned booking got settled, one per cycle.
const staleFull = {
id: trainA,
maxWagons: 1,
bookingWindowStatus: 'FULL',
// The batch runs while the customer window is closed.
windowPhase: 'PAYMENT',
direction: 'IMPORT',
trainSetId: `set-${trainA}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
originStationId: originYardId,
destinationStationId: destinationYardId,
};
trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]);
// Live capacity says the train is empty: 1 free wagon, nothing allocated.
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull);
// refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase);
// the re-read reports it, and isFillable() admits CLOSED during PAYMENT.
trainSchedulesRepository.findById.mockResolvedValue({
id: trainA,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
commercial('waiting', 30),
]);
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
// The train was reopened to the batch and actually filled, not skipped.
expect(touched).toEqual([trainA]);
expect(notifier.payNow).toHaveBeenCalledTimes(1);
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
expect(notifier.unplaced).not.toHaveBeenCalled();
});
});
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const day = '2026-06-20';
const pendingBooking = {
id: 'pending-1',
reference: 'BK-PENDING-1',
status: 'OPERATION_REQUEST_PENDING',
isGovernment: false,
originYardId,
destinationYardId,
} as unknown as Booking;
beforeEach(() => {
// One fillable schedule on this corridor/day so corridorYardsForRouteDay
// resolves a non-empty yard set (legacy two-stop route → [origin, dest]).
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: 'sched-1',
originStationId: originYardId,
destinationStationId: destinationYardId,
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
},
]);
});
it('expires each un-accepted booking and clears its scheduled day', async () => {
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]);
await service.expireUnacceptedForRouteDay({
originYardId,
destinationYardId,
day,
});
expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith(
expect.arrayContaining([originYardId, destinationYardId]),
day,
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'pending-1',
expect.objectContaining({
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
scheduledDate: null,
}),
);
expect(notifier.expired).toHaveBeenCalledWith(pendingBooking);
});
it('is a no-op when nothing is un-accepted', async () => {
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]);
await service.expireUnacceptedForRouteDay({
originYardId,
destinationYardId,
day,
});
expect(bookingsRepository.update).not.toHaveBeenCalled();
expect(notifier.expired).not.toHaveBeenCalled();
});
it('does nothing when the route-day has no fillable schedule', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([]);
await service.expireUnacceptedForRouteDay({
originYardId,
destinationYardId,
day,
});
expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled();
});
});
describe('expireLeftoverExportDay — export day sweep', () => {
const exportSchedule = {
id: scheduleId,
direction: 'EXPORT',
originStationId: 'yard-origin',
destinationStationId: 'yard-dest',
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
windowPhase: 'DONE',
bookingWindowStatus: 'CLOSED',
};
let unacceptedSpy: jest.SpyInstance;
let poolSpy: jest.SpyInstance;
beforeEach(() => {
unacceptedSpy = jest
.spyOn(service, 'expireUnacceptedForRouteDay')
.mockResolvedValue(undefined);
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
});
it('ignores non-export schedules', async () => {
trainSchedulesRepository.findById.mockResolvedValue({
...exportSchedule,
direction: 'IMPORT',
});
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).not.toHaveBeenCalled();
expect(poolSpy).not.toHaveBeenCalled();
});
it('defers while another export train on the day can still take bookings', async () => {
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
trainSchedulesRepository.findAll.mockResolvedValue([
exportSchedule,
{
...exportSchedule,
id: 'sched-2',
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
},
]);
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).not.toHaveBeenCalled();
expect(poolSpy).not.toHaveBeenCalled();
});
it('defers while a FULL train still has live pay windows', async () => {
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
trainSchedulesRepository.findAll.mockResolvedValue([
exportSchedule,
{
...exportSchedule,
id: 'sched-2',
windowPhase: 'OPEN',
bookingWindowStatus: 'FULL',
},
]);
bookingsRepository.findReservedForSchedule.mockResolvedValue([
{
paymentStatus: 'PENDING',
status: 'AWAITING_PAYMENT',
paymentDeadline: new Date(Date.now() + 60_000),
},
]);
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).not.toHaveBeenCalled();
expect(poolSpy).not.toHaveBeenCalled();
});
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
trainSchedulesRepository.findAll.mockResolvedValue([
exportSchedule,
{
...exportSchedule,
id: 'sched-2',
windowPhase: 'OPEN',
bookingWindowStatus: 'FULL',
},
]);
await service.expireLeftoverExportDay(scheduleId);
expect(unacceptedSpy).toHaveBeenCalledWith({
originYardId: 'yard-origin',
destinationYardId: 'yard-dest',
day: '2026-06-20',
});
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
});
});
describe('maybeOfferPartial — split-eligibility gate', () => {
const importGeneral = {
id: 'b1',
reference: 'b1',
isGovernment: false,
tradeDirection: 'IMPORT',
contractKind: 'GENERAL',
consolidationPartnerId: null,
} as unknown as Booking;
const call = (booking: Booking, isPair: boolean): boolean =>
(
service as unknown as {
isSplitEligible: (b: Booking, p: boolean) => boolean;
}
).isSplitEligible(booking, isPair);
it('allows IMPORT + GENERAL when splitService is present', () => {
const withSplit = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{
issuePayable: jest.fn(),
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
const eligible = (
withSplit as unknown as {
isSplitEligible: (b: Booking, p: boolean) => boolean;
}
).isSplitEligible(importGeneral, false);
expect(eligible).toBe(true);
});
it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => {
const withSplit = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{
issuePayable: jest.fn(),
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
const eligible = (
withSplit as unknown as {
isSplitEligible: (b: Booking, p: boolean) => boolean;
}
).isSplitEligible(
{ ...importGeneral, contractKind: 'ONE_TIME' } as Booking,
false,
);
expect(eligible).toBe(true);
});
it('rejects when splitService is absent (default test service)', () => {
// `service` from the outer beforeEach was built without a splitService.
expect(call(importGeneral, false)).toBe(false);
});
it('rejects EXPORT, government, consolidated pairs, and other directions', () => {
const withSplit = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{
issuePayable: jest.fn(),
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
const check = (
withSplit as unknown as {
isSplitEligible: (b: Booking, p: boolean) => boolean;
}
).isSplitEligible.bind(withSplit);
expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false);
expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false);
expect(check(importGeneral, true)).toBe(false); // consolidated pair
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
});
});
describe('settleDueReservations — expire then promote the waiting list', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const trainId = 'train-a';
// 14m / 70t default wagon → two wagon slots on this locomotive.
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
const booking = (id: string, priority: number, overrides = {}): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
originYardId,
destinationYardId,
trainScheduleId: trainId,
...overrides,
}) as unknown as Booking;
beforeEach(() => {
const scheduleRow = {
id: trainId,
maxWagons: 2,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
direction: 'IMPORT',
trainSetId: `set-${trainId}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
originStationId: originYardId,
destinationStationId: destinationYardId,
};
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
trainSchedulesRepository.findById.mockResolvedValue({
id: trainId,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
originStationId: originYardId,
destinationStationId: destinationYardId,
});
});
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
// One reservation whose pay window lapsed, and one booking on the waiting list.
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: fullyLapsedDeadline(),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
.mockResolvedValue([]); // afterwards nothing is reserved
// The day pool the top-up draws from: only the waiting booking is eligible.
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
// expire()'s paid-guard and reserve()'s idempotency guard both re-read the
// booking fresh — answer with the matching row, not the paidBooking default
// (which would make the guard rescue-allocate the lapsed reservation).
const byId: Record<string, Booking> = { lapsed, waiting };
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
byId[opts?.where?.id ?? ''] ?? null,
);
await service.settleDueReservations(trainId);
// The lapsed reservation expired...
expect(notifier.expired).toHaveBeenCalledTimes(1);
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
expect(notifier.payNow).toHaveBeenCalledTimes(1);
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
});
it('holds a reservation whose deadline passed but whose drain tail has not', async () => {
// Settlement is asynchronous, so a payment made in the window's last
// seconds lands after the deadline. Expiring here would free the wagons
// out from under it — the swallowed-payment finding.
const draining = booking('draining', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([draining])
.mockResolvedValue([]);
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
const byId: Record<string, Booking> = { draining, waiting };
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
byId[opts?.where?.id ?? ''] ?? null,
);
await service.settleDueReservations(trainId);
expect(notifier.expired).not.toHaveBeenCalled();
// ...and its wagons were NOT handed to the waiting list either.
expect(notifier.payNow).not.toHaveBeenCalled();
});
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: fullyLapsedDeadline(),
});
// Both callers read the reservation; the lock must stop the second from
// acting on rows the first already expired. (The PAYMENT transition and the
// tick's overdue backstop do exactly this, in the same second.)
let reads = 0;
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
reads += 1;
return Promise.resolve(reads === 1 ? [lapsed] : []);
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
// expire()'s paid-guard re-reads the booking fresh — answer with the
// (unpaid) lapsed row, not the paidBooking default.
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
opts?.where?.id === 'lapsed' ? lapsed : null,
);
await Promise.all([
service.settleDueReservations(trainId),
service.settleDueReservations(trainId),
]);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
it('never expires a reservation whose payment landed — allocates it instead', async () => {
const latePaid = booking('late-paid', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: fullyLapsedDeadline(),
});
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([latePaid])
.mockResolvedValue([]);
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
// The payment webhook flipped paymentStatus between the settle's list
// read and expire()'s fresh re-read — the deadline had already passed.
dataSource
.getRepository()
.findOne.mockImplementation(
async (opts: { where?: { id?: string } }) =>
opts?.where?.id === 'late-paid'
? { ...latePaid, paymentStatus: 'PAID' }
: null,
);
await service.settleDueReservations(trainId);
// Money was taken → the booking boards. Never expired.
expect(notifier.expired).not.toHaveBeenCalled();
expect(notifier.secured).toHaveBeenCalledTimes(1);
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
[{ trainScheduleId: trainId, bookingId: 'late-paid' }],
expect.anything(),
);
});
});
describe('acceptIntercity — export pay window expires at window close', () => {
const exportScheduleId = 'export-train';
// Window closes in 30 minutes; the configured pay window is 60 minutes.
const closesAt = new Date(Date.now() + 30 * 60_000);
const waiting = {
id: 'ic-1',
reference: 'IC-1',
isGovernment: false,
status: 'FULLY_EXECUTED',
trainScheduleId: null,
freightType: 'CONTAINER',
cargoTotalWeightVgm: 10,
bookingContainers: [],
} as unknown as Booking;
let scheduleRepo: { findOne: jest.Mock };
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
beforeEach(() => {
bookingRepo = dataSource.getRepository();
bookingRepo.findOne.mockResolvedValue(waiting);
scheduleRepo = { findOne: jest.fn() };
// reserve() reads the target schedule to clamp export deadlines — route
// TrainSchedule reads to their own repo, everything else stays as before.
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
);
});
it('clamps the intercity pay deadline to the export window close', async () => {
scheduleRepo.findOne.mockResolvedValue({
id: exportScheduleId,
direction: 'EXPORT',
windowClosesAt: closesAt,
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
});
await service.acceptIntercity(waiting, exportScheduleId);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'ic-1',
expect.objectContaining({
status: 'SELECTED_FOR_BATCH',
paymentDeadline: closesAt,
}),
);
expect(notifier.payNow).toHaveBeenCalledTimes(1);
});
it('keeps the plain payment window on import trains', async () => {
scheduleRepo.findOne.mockResolvedValue({
id: 'import-train',
direction: 'IMPORT',
windowClosesAt: closesAt,
});
await service.acceptIntercity(waiting, 'import-train');
const deadline = (
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
).paymentDeadline;
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
});
it('rejects an accept after the export window closed — no pay window opens', async () => {
scheduleRepo.findOne.mockResolvedValue({
id: exportScheduleId,
direction: 'EXPORT',
windowClosesAt: new Date(Date.now() - 60_000),
});
await expect(
service.acceptIntercity(waiting, exportScheduleId),
).rejects.toThrow(/window has closed/);
expect(bookingsRepository.update).not.toHaveBeenCalled();
expect(notifier.payNow).not.toHaveBeenCalled();
});
it('expires an unpaid export ride-along at close and frees the train', async () => {
const lapsed = {
...(waiting as unknown as Record<string, unknown>),
status: 'SELECTED_FOR_BATCH',
trainScheduleId: exportScheduleId,
paymentDeadline: fullyLapsedDeadline(),
originYardId: 'yard-a',
destinationYardId: 'yard-b',
priorityScore: 0,
wagonsRequired: 1,
} as unknown as Booking;
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([lapsed])
.mockResolvedValue([]);
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
bookingRepo.findOne.mockResolvedValue(lapsed);
trainSchedulesRepository.findById.mockResolvedValue({
id: exportScheduleId,
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
originStationId: 'yard-a',
destinationStationId: 'yard-b',
});
await service.settleDueReservations(exportScheduleId);
expect(notifier.expired).toHaveBeenCalledTimes(1);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'ic-1',
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
);
});
});
});
describe('BookingBatchService — wagonsFor', () => {
// wagonsFor is pure arithmetic over its two arguments and touches no injected
// dependency, so the service can be built with none.
const service = new BookingBatchService(
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
) as unknown as {
wagonsFor(booking: unknown, dims: unknown): number;
needFor(booking: unknown, dims: unknown): {
wagons: number;
weightTons: number;
lengthMeters: number;
};
};
// PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m.
const dims = {
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 },
byWagonTypeId: new Map(),
};
const bulk = (cargoTons: number, over: Record<string, unknown> = {}) => ({
freightType: 'BULK',
cargoTotalWeightVgm: cargoTons,
bookingContainers: [],
...over,
});
it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => {
// 37 × 1400 fertilizer packages × 50kg = 2590T of cargo.
expect(service.wagonsFor(bulk(2590), dims)).toBe(37);
});
it('rounds a partial wagon up', () => {
expect(service.wagonsFor(bulk(70.1), dims)).toBe(2);
expect(service.wagonsFor(bulk(70), dims)).toBe(1);
});
it('still floors at one wagon when a bulk booking has no recorded cargo', () => {
expect(service.wagonsFor(bulk(0), dims)).toBe(1);
});
it('honours an explicit wagonsRequired override', () => {
expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40);
});
it('ignores a stale undersized wagonsRequired: 700T of sugar rides 10 wagons, not 1', () => {
// Rows written while sumWagonsRequired hardcoded BULK to 1 are still in the
// DB; trusting them charged one tare for the whole consist (700 + 25.2
// instead of 700 + 10 × 25.2 gross).
expect(service.wagonsFor(bulk(700, { wagonsRequired: 1 }), dims)).toBe(10);
});
it('takes the binding axis for containers: weight can exceed TEU geometry', () => {
// Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each.
const booking = {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 210,
bookingContainers: [
{ quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
],
};
expect(service.wagonsFor(booking, dims)).toBe(3);
});
it('keeps TEU geometry when it binds before weight', () => {
// Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight.
const booking = {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 40,
bookingContainers: [
{ quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } },
],
};
expect(service.wagonsFor(booking, dims)).toBe(2);
});
describe('per-booking wagon type (cargo/container type FK)', () => {
// The booking's cargo type rides PW2 (25.2T tare / 70T), but the
// representative bulk fallback is a CW3-ish 23.4T tare. Measuring the
// booking on the fallback under-charged its gross (2100 + 30 × 23.4 =
// 2802 instead of 2856), so the fill loop admitted sets that allocation's
// real-consist check later rejected — after the customer had paid.
const dimsWithTypes = {
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
bulk: { lengthMeters: 17.066, tareWeightTons: 23.4, capacityTons: 70 },
byWagonTypeId: new Map([
['pw2-id', { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }],
]),
};
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } });
const need = service.needFor(booking, dimsWithTypes);
expect(need.wagons).toBe(30);
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
});
it('falls back to the representative dims when no wagon type is configured', () => {
const need = service.needFor(bulk(2100), dimsWithTypes);
expect(need.weightTons).toBe(2802); // 2100 + 30 × 23.4 (legacy behavior)
});
it('resolves a container booking through its container type', () => {
const booking = {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 140,
bookingContainers: [
{
quantity: 2,
wagonsRequired: 2,
containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
},
],
};
const need = service.needFor(booking, dimsWithTypes);
expect(need.wagons).toBe(2);
expect(need.weightTons).toBe(190.4); // 140 + 2 × 25.2
expect(need.lengthMeters).toBeCloseTo(34.132, 3); // 2 × 17.066, not NW5's 13.966
});
});
});
describe('BookingBatchService — built-train wagon capacity', () => {
// A schedule created from a built train is capped by its PHYSICAL consist:
// wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the
// old weight/length math would call every one of these trains FULL, so any
// assertion below that says "not full" proves those axes are ignored.
const scheduleId = 'schedule-built';
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
({
id,
freightType: 'BULK',
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
bookingContainers: [],
originYardId: leg?.origin ?? 'yard-a',
destinationYardId: leg?.dest ?? 'yard-b',
}) as unknown as Booking;
const buildService = (opts: {
physicalWagons: number;
reserved: Booking[];
maxWagons?: number;
routeStops?: string[];
yardCountries?: Record<string, string>;
}) => {
const schedule = {
id: scheduleId,
maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose
bookingWindowStatus: 'OPEN',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: opts.routeStops ? 'route-1' : null,
scheduleBookings: [],
trainSet: {
locomotive: {
maxPullWeightTons: 1,
maxTrainLengthMeters: 1,
overageToleranceTons: 0,
overageToleranceMeters: 0,
},
train: { id: 'train-built-1' },
},
};
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
const milestoneRepo = {
find: jest
.fn()
.mockResolvedValue(
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
),
};
const genericRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const yardRepo = {
find: jest
.fn()
.mockResolvedValue(
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
id,
country,
})),
),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity?.name === 'Wagon') return wagonRepo;
if (entity?.name === 'RouteMilestone') return milestoneRepo;
if (entity?.name === 'Yard') return yardRepo;
return genericRepo;
}),
transaction: jest.fn(),
query: jest.fn().mockResolvedValue([]),
};
const service = new BookingBatchService(
dataSource as never,
{
findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved),
} as never,
{
findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule),
findById: jest.fn().mockResolvedValue(schedule),
} as never,
null as never,
null as never,
null as never,
null as never,
null as never,
{ emitPhase: jest.fn() } as never,
null as never,
);
return { service, wagonRepo };
};
it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => {
const { service } = buildService({
physicalWagons: 2,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
maxWagons: 44, // stale: the old slot cap would say 42 slots remain
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
const { service } = buildService({
physicalWagons: 3,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
});
// 1T pull cap would have been exhausted long ago under the old math.
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
// capacity on the edges they don't ride: a domestic corridor with cargo
// only on m1→m2 still boards bookings on the free first/last edges, so the
// window must stay open for them.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
reserved: [
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is NOT full when the border edge is sold out but a home leg still has room', async () => {
// FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so
// sub-corridor bookings can still sell that leg — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL once every leg of the corridor is sold out', async () => {
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }),
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
// so exports can still board — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
});
await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({
maxWagons: 1,
allocatedWagons: 2,
remainingSlots: 0,
overAllocatedBy: 1,
});
});
});
/**
* The reported failure: a train advertising 20 free wagons where only 16 are of
* the type the booking can ride. Selecting all 20 took the customer's money for
* space that never existed and then stalled at allocation on wagon 17.
*/
describe('BookingBatchService — physical wagon-type gate', () => {
const NW5 = 'wagon-type-nw5';
const PW2 = 'wagon-type-pw2';
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
const internals = (svc: BookingBatchService) =>
svc as unknown as {
hasWagonStock: (
stock: WagonStockLedger,
ids: string[],
needed: number,
leg: { fromEdge: number; toEdge: number },
) => boolean;
maybeOfferPartial: (
booking: Booking,
isPair: boolean,
candidates: unknown[],
need: { wagons: number; weightTons: number; lengthMeters: number },
ids: string[],
) => Promise<boolean>;
tryPartialOffer: unknown;
isSplitEligible: unknown;
};
const service = () =>
new BookingBatchService(
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
const svc = internals(service());
const stock = mixedStock();
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
// A booking that may ride either type sees all 20.
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
});
it('stands down when the booking has no allowed wagon type configured', () => {
// Unresolvable configuration must not strand every booking that uses it —
// the abstract capacity budget still governs.
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
});
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
const svc = service();
const inner = internals(svc);
// Isolate the sizing decision: eligibility and offer creation are covered
// elsewhere, what matters here is the room handed to tryPartialOffer.
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
// 20 abstract slots free, weight and length wide open.
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const offered = await inner.maybeOfferPartial(
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
false,
[candidate],
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
[NW5],
);
expect(offered).toBe(true);
// 16, not the 20 free slots — the customer is billed for what can be loaded.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
});