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:
2026-07-23 08:47:37 +02:00
parent 83d8eff40d
commit 605ea4cf80
@@ -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();
});
});