mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
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> = {}): 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');
|
|
});
|
|
});
|
|
});
|