Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts
2026-07-23 13:54:09 +00:00

231 lines
8.3 KiB
TypeScript

import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
import { RateChangeRequest } from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity';
import { RateChangeRequestsService } from './rate-change-requests.service';
/**
* The guarantee under test: editing a LIVE rate never moves the live value.
* A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only
* approval applies it, and only then through RatesService (so every rate rule
* is re-checked against the state at approval time).
*/
describe('RateChangeRequestsService', () => {
const liveRate = (overrides: Partial<Rate> = {}): Rate =>
({
id: 'rate-1',
status: 'LIVE',
rateType: 'OCEAN_FREIGHT',
appliesTo: 'CONTAINER',
trigger: 'ALWAYS',
currency: 'USD',
// Postgres numeric comes back as a string — the no-op check must cope.
rateValue: '100.0000' as unknown as number,
rateUnit: 'PER_CONTAINER',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: null,
proposedByStaffId: 'staff-1',
...overrides,
}) as unknown as Rate;
const build = (opts: {
rate?: Rate;
pending?: RateChangeRequest | null;
applyThrows?: Error;
} = {}) => {
const rate = opts.rate ?? liveRate();
const saved: RateChangeRequest[] = [];
const repo = {
findOne: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null;
return saved.find((r) => r.id === where.id) ?? opts.pending ?? null;
}),
create: jest.fn((data: Partial<RateChangeRequest>) => ({ id: 'req-1', ...data })),
save: jest.fn(async (entity: RateChangeRequest) => {
saved.push(entity);
return entity;
}),
find: jest.fn(async () => saved),
};
const rates = {
findById: jest.fn(async () => rate),
assertUpdateValid: jest.fn(async () => undefined),
applyApprovedUpdate: jest.fn(async () => {
if (opts.applyThrows) throw opts.applyThrows;
return rate;
}),
};
const inbox = { notify: jest.fn(async () => undefined) };
const service = new RateChangeRequestsService(
repo as never,
rates as never,
inbox as never,
);
// `pending` is the very object approve/reject mutate — assert on it, not a copy.
return { service, repo, rates, inbox, pending: opts.pending };
};
describe('submit', () => {
it('files a pending request instead of touching the live rate', async () => {
const { service, rates } = build();
const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } });
expect(request.status).toBe('PENDING');
expect(request.payload).toEqual({ rateValue: 200 });
// The old value is snapshotted for the approver's diff...
expect(request.previousValues).toEqual({ rateValue: '100.0000' });
// ...and nothing wrote to the rate itself.
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
});
it('keeps only the fields that actually changed', async () => {
const { service } = build();
// A form posts every field back; only rateValue differs from the live rate.
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 200,
currency: 'USD',
rateUnit: 'PER_CONTAINER',
appliesTo: 'CONTAINER',
},
});
expect(request.payload).toEqual({ rateValue: 200 });
});
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
const { service } = build({
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
});
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 100,
originYardId: 'yard-a',
destinationYardId: 'yard-c',
},
});
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(
service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }),
).rejects.toThrow(/Nothing changed/);
});
it('refuses a rate that is not LIVE — those edit directly', async () => {
const { service } = build({ rate: liveRate({ status: 'DRAFT' }) });
await expect(
service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
).rejects.toThrow(BadRequestException);
});
it('refuses a second pending change for the same rate', async () => {
const { service } = build({
pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest,
});
await expect(
service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
).rejects.toThrow(ConflictException);
});
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
const { service, rates } = build();
rates.assertUpdateValid.mockRejectedValueOnce(
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
);
await expect(
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
).rejects.toThrow(/not valid for this rate/);
});
});
describe('approve', () => {
const pendingRequest = (): RateChangeRequest =>
({
id: 'req-1',
rateId: 'rate-1',
payload: { rateValue: 200 },
previousValues: { rateValue: '100.0000' },
status: 'PENDING',
requestedByUserId: 'staff-1',
}) as unknown as RateChangeRequest;
it('applies the change through RatesService and marks it approved', async () => {
const { service, rates } = build({ pending: pendingRequest() });
const decided = await service.approve('req-1', 'approver-1', 'Agreed');
expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 });
expect(decided.status).toBe('APPROVED');
expect(decided.decidedByUserId).toBe('approver-1');
expect(decided.decisionNote).toBe('Agreed');
});
it('blocks the requester from approving their own change', async () => {
const { service, rates } = build({ pending: pendingRequest() });
await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException);
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
});
it('lets a super admin self-approve', async () => {
const { service } = build({ pending: pendingRequest() });
await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({
status: 'APPROVED',
});
});
it('stays PENDING when applying now fails — never marks a change that did not land', async () => {
const { service, pending, repo } = build({
pending: pendingRequest(),
applyThrows: new ConflictException('A rate for this exact combination already exists.'),
});
await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/);
// Apply runs first, so a failure leaves the request untouched and re-decidable.
expect(pending!.status).toBe('PENDING');
expect(repo.save).not.toHaveBeenCalled();
});
it('refuses to decide an already-decided request', async () => {
const { service } = build({
pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest,
});
await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException);
});
});
describe('reject', () => {
it('never touches the rate — it simply keeps its current value', async () => {
const { service, rates } = build({
pending: {
id: 'req-1',
rateId: 'rate-1',
payload: { rateValue: 200 },
previousValues: { rateValue: '100.0000' },
status: 'PENDING',
requestedByUserId: 'staff-1',
} as unknown as RateChangeRequest,
});
const decided = await service.reject('req-1', 'approver-1', 'Too steep');
expect(decided.status).toBe('REJECTED');
expect(decided.decisionNote).toBe('Too steep');
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
});
});
});