import { ConflictException } from '@nestjs/common'; import { RatesService } from './rates.service'; import type { Rate } from '../entities/rate.entity'; /** * One rate per lane + scope, whatever the unit. * * Pricing resolves a single rate for a (type, container type, leg) and then * applies whatever unit it carries — it has no way to choose between a * per-container and a per-wagon row for the same 20ft lane, and used to bill * whichever the database happened to return first. So the unit is NOT part of a * rate's identity: changing how a lane is billed means editing its rate. */ describe('RatesService — one rate per pattern', () => { const DJ = '11111111-1111-4000-8000-000000000001'; const ET = '11111111-1111-4000-8000-000000000002'; const CT20 = '11111111-1111-4000-8000-000000000003'; const existing = (over: Partial = {}): Rate => ({ id: 'rate-existing', rateType: 'CONTAINER_IMPORT', rateUnit: 'PER_WAGON', rateValue: 1690, containerTypeId: CT20, cargoTypeId: null, tradeDirection: 'IMPORT', originYardId: DJ, destinationYardId: ET, status: 'LIVE', ...over, }) as Rate; const dto = { appliesTo: 'CONTAINER', trigger: 'ALWAYS', tradeDirection: 'IMPORT', containerTypeId: CT20, originYardId: DJ, destinationYardId: ET, rateValue: 845, rateUnit: 'PER_CONTAINER', }; let repository: { findByPattern: jest.Mock; create: jest.Mock }; let service: RatesService; beforeEach(() => { repository = { findByPattern: jest.fn().mockResolvedValue(null), create: jest.fn(async (r) => ({ id: 'rate-new', ...r })), }; service = new RatesService( repository as never, { findById: jest.fn(async (id: string) => ({ id, country: id === DJ ? 'Djibouti' : 'Ethiopia', label: id === DJ ? 'Doraleh' : 'Gelan', })), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, // Shipping line companies — these rates carry no owner, so it is never hit. { findById: jest.fn() } as never, ); }); it('refuses a second rate on the same lane that only differs by unit', async () => { repository.findByPattern.mockResolvedValue(existing()); await expect(service.create(dto as never, 'staff-1')).rejects.toBeInstanceOf( ConflictException, ); expect(repository.create).not.toHaveBeenCalled(); }); it('looks the pattern up without the unit, so either order collides', async () => { await service.create(dto as never, 'staff-1'); const pattern = repository.findByPattern.mock.calls[0][0]; expect(pattern).not.toHaveProperty('rateUnit'); expect(pattern).toMatchObject({ rateType: 'CONTAINER_IMPORT', containerTypeId: CT20, originYardId: DJ, destinationYardId: ET, }); }); it('still allows the same unit on a different lane', async () => { await service.create(dto as never, 'staff-1'); expect(repository.create).toHaveBeenCalledWith( expect.objectContaining({ rateUnit: 'PER_CONTAINER', rateValue: 845, status: 'DRAFT', }), ); }); /** * Additive surcharges are billed per matching rate, each by its own unit, so * hazard is legitimately per-container for boxes AND per-ton for bulk. The * unit stays part of their identity or the second one could never be created. */ it('keeps the unit in the key for an additive surcharge', async () => { await service.create( { appliesTo: 'OTHER', trigger: 'HAZARDOUS', rateValue: 300, rateUnit: 'PER_CONTAINER', } as never, 'staff-1', ); expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({ rateType: 'HAZARD_SURCHARGE', rateUnit: 'PER_CONTAINER', }); }); it('treats lashing as singly resolved — one unit per direction', async () => { await service.create( { appliesTo: 'OTHER', trigger: 'LASHING', tradeDirection: 'IMPORT', rateValue: 40, rateUnit: 'PER_TON', } as never, 'staff-1', ); expect(repository.findByPattern.mock.calls[0][0]).not.toHaveProperty( 'rateUnit', ); }); });