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', () => {
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 locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
let wagonAllocationBulkLoadsRepository: 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(),
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0),
};
trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(),
findByScheduleId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(),
};
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
});
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]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
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 = [
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'),
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
expect(result.violations).not.toContain(
'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 () => {
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
};
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
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),
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
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: '2026-06-20T08:00:00.000Z',
scheduleDate: futureDeparture,
locomotiveIds: ['loc-1', 'loc-2'],
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
{ status: 'ASSIGNED' },
);
// 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');
});
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
destinationYardId: 'yard-destination',
status: 'PAID',
bookingContainers: [],
cargoType: { code: 'COFFEE' },
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
};
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
});
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.containerUnits).toHaveLength(2);
});
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
});
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: 'ASSIGNED' }),
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
})),
};
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
status: 'AVAILABLE',
direction: 'IMPORT',
}),
};
}
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
});
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 originYardId = 'yard-addis';
const routeRepo = {
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
id: routeId,
name: 'Addis → Djibouti',
isActive: true,
status: 'AVAILABLE',
originYardId,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' },
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
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(locomotivesRepository.findAll).toHaveBeenCalledWith({
where: { status: 'AVAILABLE', currentYardId: originYardId },
order: { code: 'ASC' },
});
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
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('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 originYardId = 'yard-addis';
const routeRepo = {
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
id: routeId,
name: 'Addis → Dire Dawa',
isActive: true,
status: 'AVAILABLE',
originYardId,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' },
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
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);
expect(result).toHaveLength(2);
await expect(
service.getAvailableLocomotivesForRoute(routeId),
).rejects.toBeInstanceOf(BadRequestException);
});
});