mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
214 lines
7.8 KiB
TypeScript
214 lines
7.8 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('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();
|
|
});
|
|
});
|
|
});
|