fix: test

This commit is contained in:
Nathnael
2026-07-22 07:47:25 +00:00
parent 1be6744f09
commit 905bf75bce

View File

@@ -80,7 +80,12 @@ const makeBooking = (
describe('TrainSchedulingService', () => { describe('TrainSchedulingService', () => {
let service: TrainSchedulingService; let service: TrainSchedulingService;
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
manager: { getRepository: jest.Mock };
};
let bookingsRepository: Record<string, jest.Mock>; let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock };
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>; let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => { 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 = { dataSource = {
getRepository: jest.fn(), getRepository: jest.fn(),
transaction: jest.fn(), transaction: jest.fn(),
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
query: jest.fn().mockResolvedValue([]), query: jest.fn().mockResolvedValue([]),
manager: {
getRepository: jest.fn(() => ({
createQueryBuilder: jest.fn(() => emptySiblingQb),
})),
},
}; };
bookingsRepository = { bookingsRepository = {
findEligibleForScheduling: jest.fn(), findEligibleForScheduling: jest.fn(),
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(), findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(), findAll: jest.fn(),
updateStatus: jest.fn(), updateStatus: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0),
}; };
trainScheduleBookingsRepository = { trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(), findByBookingIds: jest.fn(),
findByScheduleId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(), createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(), deleteByScheduleAndBooking: jest.fn(),
}; };
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
}); });
it('allows preview when bookings are already on the target schedule', async () => { it('allows preview when bookings are already on the target schedule', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; // 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]); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
it('allows preview when selected bookings are on different schedule dates', async () => { 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 = [ const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), 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'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
expect(result.violations).not.toContain( expect(result.violations).not.toContain(
'Selected bookings must share the same schedule date', 'Selected bookings must share the same schedule date',
); );
expect(result.valid).toBe(true); 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 () => { it('rejects bookings that are not in schedulable status', async () => {
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
originYardId: 'yard-origin', originYardId: 'yard-origin',
destinationYardId: 'yard-destination', destinationYardId: 'yard-destination',
isActive: true, isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
}; };
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' }; const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
const trainScheduleRepo = { const trainScheduleRepo = {
create: jest.fn().mockImplementation((value) => value), create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), 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 = { const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value), create: jest.fn().mockImplementation((value) => value),
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
callback(manager), 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({ const result = await service.createContainerTrainSchedule({
routeId: 'route-1', routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z', scheduleDate: futureDeparture,
locomotiveIds: ['loc-1', 'loc-2'], locomotiveIds: ['loc-1', 'loc-2'],
}); });
expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled(); expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith( // Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) }, // one locomotive may sit on several future schedules.
{ status: 'ASSIGNED' }, expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
);
expect(result.id).toBe('schedule-1'); expect(result.id).toBe('schedule-1');
}); });
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
destinationYardId: 'yard-destination', destinationYardId: 'yard-destination',
status: 'PAID', status: 'PAID',
bookingContainers: [], bookingContainers: [],
cargoType: { code: 'COFFEE' }, cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
}; };
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
}); });
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(result.summary.wagonType).toBe('MIXED'); // 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.wagonPlan.length).toBeGreaterThan(2);
expect(result.containerUnits).toHaveLength(2); expect(result.containerUnits).toHaveLength(2);
}); });
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
}); });
it('rejects create when the locked locomotive is no longer available', async () => { 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 = { const manager = {
getRepository: jest.fn(() => ({ getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
})), })),
}; };
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
originYardId: 'yard-origin', originYardId: 'yard-origin',
destinationYardId: 'yard-destination', destinationYardId: 'yard-destination',
isActive: true, isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
}), }),
}; };
} }
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
}); });
describe('getAvailableLocomotivesForRoute', () => { describe('getAvailableLocomotivesForRoute', () => {
it('returns locomotives at the route origin yard', async () => { it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
const routeId = 'route-export'; const routeId = 'route-export';
const originYardId = 'yard-addis'; const originYardId = 'yard-addis';
const routeRepo = { const routeRepo = {
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
id: routeId, id: routeId,
name: 'Addis → Djibouti', name: 'Addis → Djibouti',
isActive: true, isActive: true,
status: 'AVAILABLE',
originYardId, originYardId,
originYard: { country: 'Ethiopia' }, originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' }, destinationYard: { country: 'Djibouti' },
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo; if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() }; 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([ locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
]); ]);
const result = await service.getAvailableLocomotivesForRoute(routeId); const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ expect(result).toHaveLength(2);
where: { status: 'AVAILABLE', currentYardId: originYardId }, expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
order: { code: 'ASC' }, expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
});
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
}); });
it('returns all locomotives returned by the repository for domestic routes', async () => { it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
const routeId = 'route-domestic'; const routeId = 'route-domestic';
const originYardId = 'yard-addis'; const originYardId = 'yard-addis';
const routeRepo = { const routeRepo = {
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
id: routeId, id: routeId,
name: 'Addis → Dire Dawa', name: 'Addis → Dire Dawa',
isActive: true, isActive: true,
status: 'AVAILABLE',
originYardId, originYardId,
originYard: { country: 'Ethiopia' }, originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' },
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo; if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() }; return { findOne: jest.fn(), update: jest.fn() };
}); });
locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId); await expect(
service.getAvailableLocomotivesForRoute(routeId),
expect(result).toHaveLength(2); ).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });