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(); const tenderSources = new Map(); 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(); }); });