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 => ({ 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 }) => { 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) => ({ 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(); }); }); });