mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
230 lines
7.1 KiB
TypeScript
230 lines
7.1 KiB
TypeScript
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 1..cap with no gaps and no overlaps; the next range
|
||
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
|
||
* CURRENCY 35, CUSTOMS 15.
|
||
*/
|
||
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<Parameters<PriorityConfigsService['assertNoRangeCollision']>[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('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
|
||
await expect(
|
||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
|
||
).rejects.toThrow(/may not exceed 50/);
|
||
await expect(
|
||
attempt(serviceWith([]), {
|
||
type: 'CURRENCY',
|
||
currency: 'USD',
|
||
minWagonCount: 1,
|
||
maxWagonCount: 36,
|
||
}),
|
||
).rejects.toThrow(/may not exceed 35/);
|
||
await expect(
|
||
attempt(serviceWith([]), {
|
||
type: 'CUSTOMS',
|
||
minWagonCount: 1,
|
||
maxWagonCount: 16,
|
||
}),
|
||
).rejects.toThrow(/may not exceed 15/);
|
||
});
|
||
|
||
it('rejects any new rule once the chain covers the full range', async () => {
|
||
await expect(
|
||
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
||
minWagonCount: 51,
|
||
maxWagonCount: 51,
|
||
}),
|
||
).rejects.toThrow(/may not exceed 50/);
|
||
await expect(
|
||
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
||
type: 'CUSTOMS',
|
||
minWagonCount: 1,
|
||
maxWagonCount: 1,
|
||
}),
|
||
).rejects.toThrow(/already cover the full 1–15 range/);
|
||
});
|
||
|
||
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,
|
||
maxCap: 50,
|
||
});
|
||
await expect(
|
||
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
|
||
).resolves.toEqual({ nextMin: null, maxCap: 15 });
|
||
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
|
||
nextMin: 1,
|
||
maxCap: 35,
|
||
});
|
||
});
|
||
});
|