import { BadRequestException } from '@nestjs/common'; import { PriorityConfig } from '../entities/priority-config.entity'; import { PriorityConfigsService } from './priority-configs.service'; /** * Contiguous-range rules for priority configs: per type (per currency for * CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range * must start at the lowest uncovered wagon count. There is no upper ceiling. */ describe('PriorityConfigsService range validation', () => { const rule = ( type: PriorityConfig['type'], min: number, max: number, currency: string | null = null, id = `${type}-${min}-${max}-${currency ?? 'none'}`, ): PriorityConfig => ({ id, type, label: `${min}-${max}`, currency, minWagonCount: min, maxWagonCount: max, }) as PriorityConfig; const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => { const repository = { findAll: jest.fn(async ({ where }: { where: { type: string } }) => rules.filter((r) => r.type === where.type), ), findById: jest.fn(async (id: string) => rules.find((r) => r.id === id) ?? null, ), }; return new PriorityConfigsService( repository as never, undefined as never, // DisplayOrderService — unused by range validation ); }; const attempt = ( svc: PriorityConfigsService, input: Partial[0]>, ) => svc.assertNoRangeCollision({ type: 'WAGON', minWagonCount: 1, maxWagonCount: 5, ...input, }); it('accepts the first WAGON range starting at 1', async () => { await expect( attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }), ).resolves.toBeUndefined(); }); it('rejects a first range that does not start at 1', async () => { await expect( attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }), ).rejects.toThrow(BadRequestException); }); it('rejects an exact duplicate (1–5 vs 1–5)', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 5)]), { minWagonCount: 1, maxWagonCount: 5, }), ).rejects.toThrow(/must start at 6/); }); it('rejects a partial overlap (4–7 after 1–5)', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 5)]), { minWagonCount: 4, maxWagonCount: 7, }), ).rejects.toThrow(/must start at 6/); }); it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 5)]), { minWagonCount: 8, maxWagonCount: 9, }), ).rejects.toThrow(/must start at 6/); }); it('accepts the contiguous continuation (6–10 after 1–5)', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 5)]), { minWagonCount: 6, maxWagonCount: 10, }), ).resolves.toBeUndefined(); }); it('after deleting a middle rule, the next range must fill the lowest gap', async () => { // Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6. const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); await expect( attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }), ).rejects.toThrow(/must start at 6/); await expect( attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }), ).resolves.toBeUndefined(); }); it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); await expect( attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }), ).rejects.toThrow(/overlaps existing rule/); }); it('imposes no upper ceiling on any type', async () => { await expect( attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }), ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, maxWagonCount: 5000, }), ).resolves.toBeUndefined(); await expect( attempt(serviceWith([]), { type: 'CUSTOMS', minWagonCount: 1, maxWagonCount: 5000, }), ).resolves.toBeUndefined(); }); it('keeps extending the chain past the old caps', async () => { await expect( attempt(serviceWith([rule('WAGON', 1, 50)]), { minWagonCount: 51, maxWagonCount: 120, }), ).resolves.toBeUndefined(); await expect( attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { type: 'CUSTOMS', minWagonCount: 16, maxWagonCount: 99, }), ).resolves.toBeUndefined(); }); it('still rejects a min greater than the max', async () => { await expect( attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }), ).rejects.toThrow(BadRequestException); }); it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]); // ETB has no rules yet → starts at 1. await expect( attempt(svc, { type: 'CURRENCY', currency: 'ETB', minWagonCount: 1, maxWagonCount: 5, }), ).resolves.toBeUndefined(); // USD must continue at 6. await expect( attempt(svc, { type: 'CURRENCY', currency: 'USD', minWagonCount: 1, maxWagonCount: 5, }), ).rejects.toThrow(/must start at 6/); }); it('excludes the rule being edited from its own contiguity check', async () => { const existing = rule('WAGON', 6, 10, null, 'editing-me'); const svc = serviceWith([rule('WAGON', 1, 5), existing]); // Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed. await expect( attempt(svc, { minWagonCount: 6, maxWagonCount: 12, excludeId: 'editing-me', }), ).resolves.toBeUndefined(); }); it('lets an upper rule keep its start while a lower gap exists', async () => { // Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap // must not block editing an upper rule's points or max. const upper = rule('WAGON', 11, 20, null, 'upper'); const svc = serviceWith([rule('WAGON', 1, 5), upper]); await expect( attempt(svc, { minWagonCount: 11, maxWagonCount: 25, excludeId: 'upper', }), ).resolves.toBeUndefined(); // But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6. await expect( attempt(svc, { minWagonCount: 30, maxWagonCount: 35, excludeId: 'upper', }), ).rejects.toThrow(/must start at 6/); }); it('reports the next-range prefill for the form', async () => { const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 }); // Past the old CUSTOMS cap of 15 the chain simply continues. await expect( serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), ).resolves.toEqual({ nextMin: 16 }); await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ nextMin: 1, }); }); });