Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts
marshalyordanos 9fff469ffa feat: implement shipping line bookings management
- Add ShippingLineBookingsPage for listing and managing shipping line bookings.
- Create ShippingLineDocumentsModal for document uploads related to bookings.
- Introduce ShippingLineInitiateModal for initiating new shipping line bookings.
- Implement booking document state management with booking-doc-state utility.
- Add shipping line bookings service for API interactions.
- Update index to export new components and services.
- Enhance types for freight to include shipping line credits.
2026-08-13 15:54:40 +03:00

672 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { RuleEngineService } from './rule-engine.service';
import type { BookingEvaluationInput } from './rule-engine.service';
import type { Rate } from './entities/rate.entity';
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
const hazardRate: Rate = {
id: 'rate-hazard',
rateType: 'HAZARD_SURCHARGE',
trigger: 'HAZARDOUS',
rateValue: 50,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
} as Rate;
let ratesRepo: { findLiveRates: jest.Mock };
let service: RuleEngineService;
beforeEach(() => {
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
ratesRepo as never,
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
{} as never, // dataSource (unused by evaluate)
);
});
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
containers: [],
...overrides,
});
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
const result = await service.evaluate(input({ isHazardous: true }));
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('hazardous');
});
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
const result = await service.evaluate(input({ isHazardous: true }));
expect(result.hardBlocked).toHaveLength(0);
});
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
const result = await service.evaluate(input({}));
expect(result.hardBlocked).toHaveLength(0);
});
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
const result = await service.evaluate(
input({
containers: [
{
containerTypeId: 'ct-20',
quantity: 2,
vgmPerUnitTons: 10,
totalVgmTons: 20,
reeferQuantity: 1,
},
],
}),
);
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('reefer');
});
});
describe('RuleEngineService — overweight surcharge by trade direction', () => {
const baseImportRate: Rate = {
id: 'rate-import-20',
rateType: 'CONTAINER_IMPORT',
trigger: 'ALWAYS',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
const configuredOverweight: Rate = {
id: 'rate-ow',
rateType: 'OVERWEIGHT_PER_TON',
trigger: 'OVERWEIGHT',
rateValue: 10,
rateUnit: 'PER_TON',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
} as Rate;
let service: RuleEngineService;
beforeEach(() => {
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
});
// One 20ft at 25 t against a 20 t limit → 5 t excess.
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection,
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
],
});
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
const result = await service.evaluate(overweightInput('IMPORT'));
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(1);
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
expect(ow[0].unitPriceUsd).toBe(25);
expect(ow[0].calculatedAmount).toBe(125);
expect(ow[0].triggerValue).toBe(5);
expect(ow[0].rateId).toBe(baseImportRate.id);
});
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
const result = await service.evaluate(overweightInput('EXPORT'));
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(1);
expect(ow[0].rateId).toBe(configuredOverweight.id);
// 5 excess tons × the configured 10 USD/t.
expect(ow[0].calculatedAmount).toBe(50);
expect(ow[0].unitPriceUsd).toBeUndefined();
});
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
const result = await service.evaluate({
...overweightInput('IMPORT'),
destinationYardId: 'yard-elsewhere',
});
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(0);
});
});
describe('RuleEngineService — empty-container return per route + container type', () => {
const returnRate20: Rate = {
id: 'rate-return-20',
rateType: 'RETURN_SURCHARGE',
trigger: 'WITH_RETURN',
rateValue: 20,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
let service: RuleEngineService;
beforeEach(() => {
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
});
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
returnQuantity: 2,
},
],
...overrides,
});
it('bills the route + type matched rate on the opted-in count', async () => {
const result = await service.evaluate(returnInput({}));
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(result.hardBlocked).toHaveLength(0);
expect(ret).toHaveLength(1);
expect(ret[0].rateId).toBe(returnRate20.id);
expect(ret[0].triggerValue).toBe(2);
expect(ret[0].calculatedAmount).toBe(40);
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
});
it('hard-blocks when the booking route has no matching return rate', async () => {
const result = await service.evaluate(
returnInput({ destinationYardId: 'yard-elsewhere' }),
);
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
expect(
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
).toHaveLength(0);
});
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
});
it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
// Same service, but the return rate is sold per wagon: 4× 20ft return =
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{
findLiveRates: jest
.fn()
.mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const result = await service.evaluate(
returnInput({
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
returnQuantity: 4,
wagonsPerUnit: 0.5,
},
],
}),
);
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(ret).toHaveLength(1);
expect(ret[0].triggerValue).toBe(2);
expect(ret[0].calculatedAmount).toBe(40);
expect(ret[0].billingUnit).toBe('PER_WAGON');
});
it('legacy booking-level flag bills every container at its type rate', async () => {
const result = await service.evaluate(
returnInput({
withReturn: true,
containers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
],
}),
);
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(ret).toHaveLength(1);
expect(ret[0].triggerValue).toBe(4);
expect(ret[0].calculatedAmount).toBe(80);
});
});
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
const lashingBulkImport: Rate = {
id: 'rate-lash-bulk',
rateType: 'LASHING',
trigger: 'LASHING',
rateValue: 2,
rateUnit: 'PER_TON',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: null,
destinationYardId: null,
} as Rate;
const buildService = (rates: Rate[]): RuleEngineService =>
new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
cargoTypeId: 'cargo-sugar',
totalWagons: 0,
bulkTons: 100,
bulkWagons: 3,
containers: [],
...overrides,
});
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
it('bulk lashing bills per ton on the direction-matched rate', async () => {
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].triggerValue).toBe(100);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('PER_TON');
});
it('a rate for the other direction never bills', async () => {
const result = await buildService([
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
]).evaluate(bulkInput());
expect(lashingMods(result)).toHaveLength(0);
});
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
const result = await buildService([
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(3);
expect(mods[0].calculatedAmount).toBe(75);
});
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
const result = await buildService([
lashingBulkImport,
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].unitPriceUsd).toBe(7);
expect(mods[0].calculatedAmount).toBe(700);
});
it('container bookings never incur lashing (bulk-only service)', async () => {
const result = await buildService([lashingBulkImport]).evaluate(
bulkInput({
cargoTypeId: null,
hasLashing: true,
containers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
],
}),
);
expect(lashingMods(result)).toHaveLength(0);
});
it('no lashing charge when the cargo does not need lashing', async () => {
const service = new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const result = await service.evaluate(bulkInput());
expect(lashingMods(result)).toHaveLength(0);
});
});
describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
const fuelPerLiter: Rate = {
id: 'rate-fuel-liter',
rateType: 'FUEL_SURCHARGE',
trigger: 'FUEL',
rateValue: 2,
rateUnit: 'PER_LITER',
baseLiters: 100,
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: 'cargo-steel',
tradeDirection: 'IMPORT',
originYardId: 'yard-nagad',
destinationYardId: 'yard-mojo',
} as Rate;
const buildService = (rates: Rate[], hasFuel = true): RuleEngineService =>
new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const fuelInput = (
overrides: Partial<BookingEvaluationInput> = {},
): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
cargoTypeId: 'cargo-steel',
originYardId: 'yard-nagad',
destinationYardId: 'yard-mojo',
totalWagons: 0,
bulkTons: 100,
bulkWagons: 4,
containers: [],
...overrides,
});
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => {
const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
const mods = fuelMods(result);
expect(mods).toHaveLength(1);
// Flat: the customer sees only the total, and a frozen contract snapshot
// (also stored flat) multiplies it by quantity 1 — never by the liters.
expect(mods[0].triggerValue).toBe(1);
expect(mods[0].unitPriceUsd).toBe(200);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('FLAT');
});
it('PER_WAGON bills the wagons the cargo occupies', async () => {
const result = await buildService([
{ ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate,
]).evaluate(fuelInput());
const mods = fuelMods(result);
expect(mods[0].triggerValue).toBe(4);
expect(mods[0].calculatedAmount).toBe(200);
});
it('a rate for another lane, direction or commodity never bills', async () => {
for (const wrong of [
{ tradeDirection: 'EXPORT' },
{ originYardId: 'yard-other' },
{ destinationYardId: 'yard-other' },
{ cargoTypeId: 'cargo-wheat' },
]) {
const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate(
fuelInput(),
);
expect(fuelMods(result)).toHaveLength(0);
}
});
it('a domestic booking bills the DOMESTIC fuel lane', async () => {
const result = await buildService([
{ ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate,
]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' }));
expect(fuelMods(result)).toHaveLength(1);
});
it('no fuel charge when the cargo type does not have hasFuel', async () => {
const result = await buildService([fuelPerLiter], false).evaluate(fuelInput());
expect(fuelMods(result)).toHaveLength(0);
});
it('no matching lane rate bills nothing (lenient, like lashing)', async () => {
const result = await buildService([]).evaluate(fuelInput());
expect(fuelMods(result)).toHaveLength(0);
});
});
describe('RuleEngineService — shipping-line rates override the standard ones', () => {
const LINE = 'slc-msc';
/** Standard customer container-import rate on the lane. */
const standardBase: Rate = {
id: 'rate-standard-20',
rateType: 'CONTAINER_IMPORT',
trigger: 'ALWAYS',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
shippingLineCompanyId: null,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
/** The same lane, priced for one shipping line. */
const lineBase: Rate = {
...standardBase,
id: 'rate-line-20',
rateValue: 1200,
shippingLineCompanyId: LINE,
} as Rate;
const standardHazard: Rate = {
id: 'rate-hazard-standard',
rateType: 'HAZARD_SURCHARGE',
trigger: 'HAZARDOUS',
rateValue: 50,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
shippingLineCompanyId: null,
} as Rate;
const lineHazard: Rate = {
...standardHazard,
id: 'rate-hazard-line',
rateValue: 80,
shippingLineCompanyId: LINE,
} as Rate;
const buildService = (rates: Rate[]) =>
new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
// One 20ft at 25 t against a 20 t limit → 5 t excess.
const bookingInput = (
overrides: Partial<BookingEvaluationInput> = {},
): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
],
...overrides,
});
const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => {
const result = await buildService([standardBase, lineBase]).evaluate(
bookingInput({ shippingLineCompanyId: LINE }),
);
const ow = overweightOf(result);
expect(ow).toHaveLength(1);
// The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t.
expect(ow[0]).toMatchObject({
rateId: lineBase.id,
unitPriceUsd: 30,
calculatedAmount: 150,
});
});
it('keeps a customer booking on the standard rate even when a line rate exists', async () => {
const result = await buildService([standardBase, lineBase]).evaluate(bookingInput());
const ow = overweightOf(result);
expect(ow).toHaveLength(1);
expect(ow[0]).toMatchObject({
rateId: standardBase.id,
unitPriceUsd: 25,
calculatedAmount: 125,
});
});
it('does not fall back to the standard rate when the line has none for the lane', async () => {
const result = await buildService([standardBase]).evaluate(
bookingInput({ shippingLineCompanyId: LINE }),
);
// No line rate on the lane → nothing to derive from. Base freight is what
// hard-blocks the booking; the standard 1000 must never be borrowed here.
expect(overweightOf(result)).toHaveLength(0);
});
it('bills the line\'s own surcharge and never the standard one alongside it', async () => {
const result = await buildService([
standardBase,
lineBase,
standardHazard,
lineHazard,
]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }));
const hazard = result.appliedModifiers.filter(
(m) => m.surchargeCode === 'HAZARD_SURCHARGE',
);
expect(hazard).toHaveLength(1);
expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 });
});
it('hard-blocks a requested service the line has no surcharge rate for', async () => {
const result = await buildService([standardBase, lineBase, standardHazard]).evaluate(
bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }),
);
// The standard hazard rate exists but belongs to customers, so the line's
// hazardous booking must block rather than borrow it.
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('hazardous');
});
});