mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
fix(rule-engine): merge duplicate Sebeta yards, block dupes
Two active Sebeta yards (LEGACY_DEST/'Sebeta' and SEBETA/'sebeta') split rates and routes across different yard ids, so route-scoped rate lookups missed. Migration repoints every yard reference to the survivor, retires the duplicate, and adds partial unique indexes on active label and code. Service now rejects case-insensitive duplicate labels on create/update - the old guard only compared generated codes, which missed labels whose existing code differs (LEGACY_DEST). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { YardsService } from './yards.service';
|
||||
import type { Yard } from '../entities/yard.entity';
|
||||
|
||||
const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard;
|
||||
|
||||
const service = (): YardsService =>
|
||||
new YardsService(
|
||||
{
|
||||
findById: async (id: string) => ({ ...sebeta, id }),
|
||||
findByCode: async () => null,
|
||||
findByLabelInsensitive: async (label: string) =>
|
||||
label.trim().toLowerCase() === 'sebeta' ? sebeta : null,
|
||||
create: async (d: Partial<Yard>) => d as Yard,
|
||||
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
||||
} as never,
|
||||
{ resolveCreateOrder: async () => 1 } as never,
|
||||
);
|
||||
|
||||
describe('duplicate yard labels are rejected', () => {
|
||||
it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => {
|
||||
await expect(
|
||||
service().create({ label: ' sebeta ', country: 'ET' } as never),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('blocks renaming a yard onto another yard label, allows renaming itself', async () => {
|
||||
await expect(
|
||||
service().update('yard-2', { label: 'SEBETA' } as never),
|
||||
).rejects.toThrow(ConflictException);
|
||||
await expect(
|
||||
service().update('yard-1', { label: 'Sebeta' } as never),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,9 @@ export class YardsService {
|
||||
|
||||
/** Create a yard. */
|
||||
async create(dto: CreateYardDto): Promise<Yard> {
|
||||
// Label check first: the code check alone let "sebeta" in next to "Sebeta"
|
||||
// when the existing yard's code didn't match its label (LEGACY_DEST).
|
||||
await this.assertLabelAvailable(dto.label);
|
||||
const code = generateCode(dto.label).slice(0, 40);
|
||||
const existing = await this.repository.findByCode(code);
|
||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||
@@ -53,11 +56,20 @@ export class YardsService {
|
||||
/** Update a yard. */
|
||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||
await this.findById(id);
|
||||
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
|
||||
const updated = await this.repository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** No two active yards may share a label (case/whitespace-insensitive). */
|
||||
private async assertLabelAvailable(label: string, exceptId?: string): Promise<void> {
|
||||
const dupe = await this.repository.findByLabelInsensitive(label);
|
||||
if (dupe && dupe.id !== exceptId) {
|
||||
throw new ConflictException(`A yard named "${dupe.label}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
||||
|
||||
Reference in New Issue
Block a user