import { WagonTransferRequestStatus } from '@edr/types'; import { ConflictException, BadRequestException } from '@nestjs/common'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; /** * Instalment fulfilment: a request for 50 wagons is met with whatever the source * yard can spare, whenever it can spare it. It stays open until the full count * lands or OCC closes it short — which is what tells the requester to go ask * another yard. */ describe('WagonTransferRequestsService — partial fulfilment', () => { const request = (over: Partial = {}): WagonTransferRequest => ({ id: 'req-1', fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', quantity: 50, fulfilledQuantity: 0, status: WagonTransferRequestStatus.Pending, requestedByUserId: 'user-1', ...over, }) as WagonTransferRequest; let requestRepo: { findOne: jest.Mock; find: jest.Mock; save: jest.Mock; create: jest.Mock; createQueryBuilder: jest.Mock; }; let wagonRepo: { find: jest.Mock; count: jest.Mock }; let wagonsService: { bulkTransfer: jest.Mock }; let inbox: { notify: jest.Mock }; let service: WagonTransferRequestsService; let stored: WagonTransferRequest; const flush = () => new Promise((resolve) => setImmediate(resolve)); const build = (row: WagonTransferRequest) => { stored = row; requestRepo.findOne.mockImplementation(async () => stored); requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => { stored = r; return r; }); }; beforeEach(() => { requestRepo = { findOne: jest.fn(), find: jest.fn().mockResolvedValue([]), save: jest.fn(), create: jest.fn((r) => r), createQueryBuilder: jest.fn(), }; wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() }; wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) }; inbox = { notify: jest.fn().mockResolvedValue(undefined) }; service = new WagonTransferRequestsService( requestRepo as never, wagonRepo as never, { find: jest.fn(), findAndCount: jest.fn() } as never, wagonsService as never, inbox as never, ); build(request()); }); const availableWagons = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `w-${i}`, wagonNumber: `100${i}`, currentYardId: 'yard-a', wagonTypeId: 'type-1', status: 'AVAILABLE', })); describe('fulfillRequest', () => { it('books an instalment and keeps the request open', async () => { wagonRepo.find.mockResolvedValue(availableWagons(20)); await service.fulfillRequest('req-1', { wagonIds: availableWagons(20).map((w) => w.id), }); expect(stored.fulfilledQuantity).toBe(20); expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1); }); it('completes the request when the last instalment lands', async () => { build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled })); wagonRepo.find.mockResolvedValue(availableWagons(20)); await service.fulfillRequest('req-1', { wagonIds: availableWagons(20).map((w) => w.id), }); expect(stored.fulfilledQuantity).toBe(50); expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled); }); it('refuses to move more than is still owed', async () => { build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled })); wagonRepo.find.mockResolvedValue(availableWagons(10)); await expect( service.fulfillRequest('req-1', { wagonIds: availableWagons(10).map((w) => w.id), }), ).rejects.toBeInstanceOf(BadRequestException); expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); }); it('refuses to touch a request that is already closed', async () => { build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 })); await expect( service.fulfillRequest('req-1', { wagonIds: ['w-0'] }), ).rejects.toBeInstanceOf(ConflictException); }); it('tells the requester what landed and what is still owed', async () => { wagonRepo.find.mockResolvedValue(availableWagons(20)); await service.fulfillRequest('req-1', { wagonIds: availableWagons(20).map((w) => w.id), }); await flush(); const sent = inbox.notify.mock.calls[0][0]; expect(sent.recipients).toEqual({ userIds: ['user-1'] }); expect(sent.body).toContain('20 wagon(s) have arrived'); expect(sent.body).toContain('30 of 50 still to come'); }); }); describe('bulkFulfill', () => { it('sends what the yard has instead of skipping a short request', async () => { wagonRepo.find.mockResolvedValue(availableWagons(20)); const result = await service.bulkFulfill(['req-1']); expect(stored.fulfilledQuantity).toBe(20); expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); expect(result.skipped).toHaveLength(0); }); it('skips only when the yard has nothing to give', async () => { wagonRepo.find.mockResolvedValue([]); const result = await service.bulkFulfill(['req-1']); expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); expect(result.skipped[0].reason).toContain('No available wagons'); }); }); describe('closeShort', () => { it('ends the request and tells the requester to ask another yard', async () => { build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); await service.closeShort('req-1', { note: 'Yard is empty until Friday' }); await flush(); expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort); expect(stored.closedShortAt).toBeInstanceOf(Date); const sent = inbox.notify.mock.calls[0][0]; expect(sent.body).toContain('Only 20 of the 50'); expect(sent.body).toContain('Yard is empty until Friday'); expect(sent.body).toContain('Request the remaining 30'); }); it('refuses when the request is already fully supplied', async () => { build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled })); await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf( ConflictException, ); }); }); describe('cancelRequest', () => { it('withdraws a request that never moved a wagon', async () => { await service.cancelRequest('req-1'); expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled); }); it('refuses once wagons have moved — close it short instead', async () => { build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); await expect(service.cancelRequest('req-1')).rejects.toThrow( /close it short/i, ); }); }); describe('createRequest', () => { it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); expect(stored.quantity).toBe(20); }); it('refuses a count larger than what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await expect( service.createRequest( { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', quantity: 50, reason: 'Grain campaign', }, 'user-1', ), ).rejects.toThrow(/only 20 wagon\(s\).*available/i); expect(requestRepo.save).not.toHaveBeenCalled(); }); it('refuses when the yard has nothing of that type available', async () => { wagonRepo.count.mockResolvedValue(0); await expect( service.createRequest( { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', quantity: 1, reason: 'Grain campaign', }, 'user-1', ), ).rejects.toThrow(/no available wagons/i); expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { await expect( service.createRequest( { fromYardId: 'yard-a', toYardId: 'yard-a', wagonTypeId: 'type-1', quantity: 5, reason: 'x', }, 'user-1', ), ).rejects.toBeInstanceOf(BadRequestException); }); }); describe('listRequests', () => { // TypeORM paginates a joined query through a DISTINCT subquery and resolves // every orderBy criterion against entity metadata — a DB column name there // (`r.created_at`) makes it read `.databaseName` of undefined → 500. it('sorts by the entity property path, not the DB column', async () => { const qb = { leftJoinAndSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getManyAndCount: jest.fn().mockResolvedValue([[], 0]), }; requestRepo.createQueryBuilder.mockReturnValue(qb); await service.listRequests({ status: 'PENDING,PARTIALLY_FULFILLED', page: 1, pageSize: 10, }); expect(qb.orderBy).toHaveBeenCalledWith('r.createdAt', 'DESC'); }); }); });