mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
fix rate edit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user