From 605ea4cf801f57f5112ca591951a24d906c3fada Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 08:47:37 +0200 Subject: [PATCH] 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) --- .../src/tenders/tender-dedup.service.spec.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 apps/api/src/tenders/tender-dedup.service.spec.ts diff --git a/apps/api/src/tenders/tender-dedup.service.spec.ts b/apps/api/src/tenders/tender-dedup.service.spec.ts new file mode 100644 index 0000000..498c894 --- /dev/null +++ b/apps/api/src/tenders/tender-dedup.service.spec.ts @@ -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(); + 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(); + }); +});