import { BadRequestException } from '@nestjs/common'; import { TrainSchedulingService } from './services/train-scheduling.service'; /** * Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard * blocks the train; a booking that boards further down the corridor * (A→B→C→D carrying a B→C load) never does — its wagons are not due until its * own yard, so the SQL is scoped by `b.origin_yard_id = `. * The scoping lives in the query, so this checks the parameters that carry it * plus the throw/pass decision on the rows it returns. */ describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => { const ORIGIN = 'yard-a'; const SET = 'set-1'; const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => { const calls: Array<{ sql: string; params: unknown[] }> = []; const svc = Object.create(TrainSchedulingService.prototype) as { dataSource: { query: (sql: string, params: unknown[]) => Promise }; assertNoPartiallyLoadedBookings( schedule: unknown, boardingYardId: string, context: { action: string; yardLabel?: string }, ): Promise; assertPassedYardsFullyLoaded( schedule: unknown, stations: Array<{ sequenceNo: number; yardId: string; label: string }>, sequenceNo: number, ): Promise; }; svc.dataSource = { query: async (sql: string, params: unknown[]) => { calls.push({ sql, params }); return rows; }, }; return { svc, calls }; }; const schedule = { trainSetId: SET, originStationId: ORIGIN }; it('scopes the scan to bookings boarding at this departure yard', async () => { const { svc, calls } = makeService([]); await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }); expect(calls).toHaveLength(1); // The origin filter is what keeps a mid-corridor booking from holding the // train — without it, one early-loaded B→C wagon blocks dispatch at A. expect(calls[0].sql).toContain('b.origin_yard_id = $2'); expect(calls[0].params).toEqual([SET, ORIGIN]); }); it('lets the train go when nothing at this yard is half-loaded', async () => { const { svc } = makeService([]); await expect( svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), ).resolves.toBeUndefined(); }); it('blocks a booking half-loaded at this yard, naming its progress', async () => { const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]); await expect( svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), ).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/); await expect( svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('skips the scan entirely for a schedule with no train set', async () => { const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]); await expect( svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, { action: 'dispatch', }), ).resolves.toBeUndefined(); expect(calls).toHaveLength(0); }); }); /** * Mid-corridor twin: logging a checkpoint at station N means the train left * every earlier stop, so each of those yards is checked for its OWN * half-loaded bookings. The origin is excluded (dispatch gated it) and the * yard being arrived at is excluded (its loading has not happened yet). */ describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => { const STATIONS = [ { sequenceNo: 0, yardId: 'mojo', label: 'Mojo' }, { sequenceNo: 1, yardId: 'adama', label: 'Adama' }, { sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' }, { sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' }, ]; const makeService = (rowsByYard: Record>>) => { const scanned: string[] = []; const svc = Object.create(TrainSchedulingService.prototype) as { dataSource: { query: (sql: string, params: unknown[]) => Promise }; assertPassedYardsFullyLoaded( schedule: unknown, stations: typeof STATIONS, sequenceNo: number, ): Promise; }; svc.dataSource = { query: async (_sql: string, params: unknown[]) => { const yardId = params[1] as string; scanned.push(yardId); return rowsByYard[yardId] ?? []; }, }; return { svc, scanned }; }; const schedule = { trainSetId: 'set-1', originStationId: 'mojo' }; it('checks the stops already departed, never the origin or the yard being reached', async () => { const { svc, scanned } = makeService({}); await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3); // Mojo is dispatch's job; Djibouti has not been loaded at yet. expect(scanned).toEqual(['adama', 'dire']); }); it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => { const { svc } = makeService({ adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], }); await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( /Adama.*BK-200 \(5\/8 wagons loaded\)/s, ); }); it('names the resolution the operator has: load the rest, or cancel it', async () => { const { svc } = makeService({ adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], }); await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( /customer fault: cancellation fee; EDR fault: no fee, rebookable/, ); }); it('scans nothing at the first checkpoint after the origin', async () => { const { svc, scanned } = makeService({}); await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1); expect(scanned).toEqual([]); }); });