test(13-03): add failing spec for TenderDedupService (D-04/D-05)
RED-first: three-tier dedup resolver spec (OCID -> source:noticeId -> fingerprint), D-05 inert-proof (dedupActive=false skips fingerprint tier -> two Tender rows despite equal fingerprints), and SCHEMA-02 change-detection preservation (matched contentHash change still updates mutable Tender fields). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,286 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TenderDedupService } from './tender-dedup.service';
|
||||||
|
import { tenderFingerprint } from './tender-fingerprint';
|
||||||
|
import type { NormalizedTenderFields } from './tender.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenderDedupService.spec — SCHEMA-03 three-tier dedup resolver (Plan
|
||||||
|
* 13-03, Task 1). Hand-rolled fake PrismaService (Maps), matching the
|
||||||
|
* project convention (tender-ingestion.service.spec.ts) — no real DB I/O.
|
||||||
|
*
|
||||||
|
* D-05 inert-proof: dedupActive=false + fingerprint collision -> TWO
|
||||||
|
* Tender rows (fingerprint tier never runs). SCHEMA-02 preservation proof:
|
||||||
|
* a match with a changed contentHash still refreshes the Tender's mutable
|
||||||
|
* fields (Phase 10 change-detection lives on in resolve(), not lost).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function pick(obj: any, select: any) {
|
||||||
|
if (!select) return obj;
|
||||||
|
const out: any = {};
|
||||||
|
for (const k of Object.keys(select)) out[k] = obj[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakePrisma() {
|
||||||
|
const tenders = new Map<string, any>();
|
||||||
|
const tenderSources = new Map<string, any>();
|
||||||
|
let nextTenderId = 1;
|
||||||
|
let nextSourceId = 1;
|
||||||
|
|
||||||
|
const sourceKey = (sourcePortal: string, sourceNoticeId: string) =>
|
||||||
|
`${sourcePortal}:${sourceNoticeId}`;
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
tender: {
|
||||||
|
findFirst: vi.fn(async ({ where, select }: any) => {
|
||||||
|
for (const t of tenders.values()) {
|
||||||
|
if (where.ocid !== undefined && where.ocid !== null && t.ocid === where.ocid) {
|
||||||
|
return pick(t, select);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
where.fingerprint !== undefined &&
|
||||||
|
where.fingerprint !== null &&
|
||||||
|
t.fingerprint === where.fingerprint
|
||||||
|
) {
|
||||||
|
return pick(t, select);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where, select }: any) => {
|
||||||
|
const t = tenders.get(where.id);
|
||||||
|
return t ? pick(t, select) : null;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async ({ data }: any) => {
|
||||||
|
const id = `id-${nextTenderId++}`;
|
||||||
|
const row = { id, ...data };
|
||||||
|
tenders.set(id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }: any) => {
|
||||||
|
const existing = tenders.get(where.id);
|
||||||
|
const updated = { ...existing, ...data };
|
||||||
|
tenders.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tenderSource: {
|
||||||
|
findUnique: vi.fn(async ({ where, select }: any) => {
|
||||||
|
const key = sourceKey(
|
||||||
|
where.sourcePortal_sourceNoticeId.sourcePortal,
|
||||||
|
where.sourcePortal_sourceNoticeId.sourceNoticeId,
|
||||||
|
);
|
||||||
|
const s = tenderSources.get(key);
|
||||||
|
return s ? pick(s, select) : null;
|
||||||
|
}),
|
||||||
|
upsert: vi.fn(async ({ where, create, update }: any) => {
|
||||||
|
const key = sourceKey(
|
||||||
|
where.sourcePortal_sourceNoticeId.sourcePortal,
|
||||||
|
where.sourcePortal_sourceNoticeId.sourceNoticeId,
|
||||||
|
);
|
||||||
|
const existing = tenderSources.get(key);
|
||||||
|
if (existing) {
|
||||||
|
const updated = { ...existing, ...update };
|
||||||
|
tenderSources.set(key, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
const created = { id: `src-${nextSourceId++}`, ...create };
|
||||||
|
tenderSources.set(key, created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
create: vi.fn(async ({ data }: any) => {
|
||||||
|
const key = sourceKey(data.sourcePortal, data.sourceNoticeId);
|
||||||
|
const created = { id: `src-${nextSourceId++}`, ...data };
|
||||||
|
tenderSources.set(key, created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
__store: { tenders, tenderSources },
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE: NormalizedTenderFields = {
|
||||||
|
sourcePortal: 'doe-opendata',
|
||||||
|
sourceNoticeId: 'notice-1',
|
||||||
|
ocid: 'ocds-1',
|
||||||
|
dedupKey: 'ocds-1',
|
||||||
|
title: 'Sanierung Stadtbrücke',
|
||||||
|
buyerName: 'Stadt Testhausen',
|
||||||
|
cpvCodes: ['45000000'],
|
||||||
|
cpvDivisions: ['45'],
|
||||||
|
region: null,
|
||||||
|
plz: null,
|
||||||
|
bundesland: null,
|
||||||
|
deadlineAt: null,
|
||||||
|
estimatedValue: null,
|
||||||
|
procedureType: null,
|
||||||
|
status: 'active',
|
||||||
|
sourceUrl: 'https://example.test/notice-1',
|
||||||
|
contentHash: 'hash-1',
|
||||||
|
publishedAt: new Date('2026-07-20T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TenderDedupService.resolve — D-05 inert when dedupActive=false', () => {
|
||||||
|
it('creates TWO Tender rows for records with the same fingerprint but different source:noticeId, when dedupActive=false (fingerprint tier skipped)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const recordA: NormalizedTenderFields = { ...BASE, ocid: 'ocds-a', sourceNoticeId: 'notice-a' };
|
||||||
|
const recordB: NormalizedTenderFields = { ...BASE, ocid: 'ocds-b', sourceNoticeId: 'notice-b' };
|
||||||
|
// Same fingerprint (identical dedup-relevant fields) — would collide IF
|
||||||
|
// the fingerprint tier ran.
|
||||||
|
expect(tenderFingerprint(recordA)).toBe(tenderFingerprint(recordB));
|
||||||
|
|
||||||
|
const resultA = await service.resolve(recordA, { dedupActive: false });
|
||||||
|
const resultB = await service.resolve(recordB, { dedupActive: false });
|
||||||
|
|
||||||
|
expect(resultA.created).toBe(true);
|
||||||
|
expect(resultB.created).toBe(true);
|
||||||
|
expect(resultA.tenderId).not.toBe(resultB.tenderId);
|
||||||
|
expect(prisma.__store.tenders.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenderDedupService.resolve — D-04 three-tier match (dedupActive=true)', () => {
|
||||||
|
it('OCID match: attaches an additional TenderSource to the existing Tender, created=false', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const first = await service.resolve(BASE, { dedupActive: true });
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
|
||||||
|
const secondSourceRecord: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
sourcePortal: 'ai-netserver',
|
||||||
|
sourceNoticeId: 'ns-42',
|
||||||
|
sourceUrl: 'https://tender24.example/ns-42',
|
||||||
|
};
|
||||||
|
const second = await service.resolve(secondSourceRecord, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.tenderId).toBe(first.tenderId);
|
||||||
|
expect(prisma.__store.tenders.size).toBe(1);
|
||||||
|
expect(prisma.__store.tenderSources.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idempotent re-poll of the exact same source:noticeId (no OCID present): upserts the TenderSource, no new Tender', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const noOcidRecord: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
ocid: null,
|
||||||
|
dedupKey: 'ai-netserver:ns-42',
|
||||||
|
sourcePortal: 'ai-netserver',
|
||||||
|
sourceNoticeId: 'ns-42',
|
||||||
|
};
|
||||||
|
const first = await service.resolve(noOcidRecord, { dedupActive: true });
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
|
||||||
|
const reseen: NormalizedTenderFields = { ...noOcidRecord, sourceUrl: 'https://updated.example/ns-42' };
|
||||||
|
const second = await service.resolve(reseen, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.tenderId).toBe(first.tenderId);
|
||||||
|
expect(prisma.__store.tenders.size).toBe(1);
|
||||||
|
expect(prisma.__store.tenderSources.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fingerprint match (no OCID/noticeId overlap): attaches an additional TenderSource, created=false (D-03 merge)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const recordA: NormalizedTenderFields = { ...BASE, ocid: 'ocds-a', sourceNoticeId: 'notice-a' };
|
||||||
|
const first = await service.resolve(recordA, { dedupActive: true });
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
|
||||||
|
// Different source, no OCID, but identical dedup-relevant fields ->
|
||||||
|
// same fingerprint.
|
||||||
|
const recordB: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
ocid: null,
|
||||||
|
dedupKey: 'ai-netserver:ns-99',
|
||||||
|
sourcePortal: 'ai-netserver',
|
||||||
|
sourceNoticeId: 'ns-99',
|
||||||
|
};
|
||||||
|
expect(tenderFingerprint(recordB)).toBe(tenderFingerprint(recordA));
|
||||||
|
|
||||||
|
const second = await service.resolve(recordB, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.tenderId).toBe(first.tenderId);
|
||||||
|
expect(prisma.__store.tenders.size).toBe(1);
|
||||||
|
expect(prisma.__store.tenderSources.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no match on any tier: creates a new Tender (with fingerprint) + a new TenderSource, created=true', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.resolve(BASE, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(result.created).toBe(true);
|
||||||
|
const row = prisma.__store.tenders.get(result.tenderId);
|
||||||
|
expect(row.fingerprint).toBe(tenderFingerprint(BASE));
|
||||||
|
expect(prisma.__store.tenderSources.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tenderSource.upsert targets the @@unique([sourcePortal, sourceNoticeId]) composite key', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
await service.resolve(BASE, { dedupActive: true });
|
||||||
|
const secondSourceRecord: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
sourcePortal: 'ai-netserver',
|
||||||
|
sourceNoticeId: 'ns-42',
|
||||||
|
};
|
||||||
|
await service.resolve(secondSourceRecord, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(prisma.tenderSource.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
sourcePortal_sourceNoticeId: { sourcePortal: 'ai-netserver', sourceNoticeId: 'ns-42' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TenderDedupService.resolve — SCHEMA-02 change-detection preservation (no Phase-10 regress)', () => {
|
||||||
|
it('OCID match with a changed contentHash still updates the Tender mutable fields (title, deadlineAt, ...)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const first = await service.resolve(BASE, { dedupActive: true });
|
||||||
|
|
||||||
|
const rePoll: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
title: 'Sanierung Stadtbrücke (Frist verlängert)',
|
||||||
|
deadlineAt: new Date('2026-09-01T10:00:00Z'),
|
||||||
|
contentHash: 'hash-2',
|
||||||
|
};
|
||||||
|
const second = await service.resolve(rePoll, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
const row = prisma.__store.tenders.get(first.tenderId);
|
||||||
|
expect(row.title).toBe('Sanierung Stadtbrücke (Frist verlängert)');
|
||||||
|
expect(row.deadlineAt).toEqual(new Date('2026-09-01T10:00:00Z'));
|
||||||
|
expect(row.contentHash).toBe('hash-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OCID match with an UNCHANGED contentHash does NOT call tender.update', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
await service.resolve(BASE, { dedupActive: true });
|
||||||
|
prisma.tender.update.mockClear();
|
||||||
|
|
||||||
|
const reseen: NormalizedTenderFields = { ...BASE, sourceUrl: 'https://example.test/notice-1-v2' };
|
||||||
|
await service.resolve(reseen, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(prisma.tender.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user