diff --git a/apps/api/src/tenders/tender-fingerprint.spec.ts b/apps/api/src/tenders/tender-fingerprint.spec.ts new file mode 100644 index 0000000..3c78e76 --- /dev/null +++ b/apps/api/src/tenders/tender-fingerprint.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { tenderFingerprint } from './tender-fingerprint'; + +/** + * SCHEMA-03 (D-04) — pure fingerprint function. NULL-tolerant because the + * live DB is 92.4% NULL estimatedValue / 15.7% NULL deadlineAt (13-RESEARCH + * Pattern 4) — a fingerprint that requires those fields would never match + * the overwhelming majority of real DÖE tenders. + */ +describe('tenderFingerprint', () => { + const base = { + buyerName: 'Stadt München', + title: 'Neubau einer Kindertagesstätte', + cpvDivisions: ['45', '71'], + deadlineAt: null as Date | null, + estimatedValue: null as number | null, + }; + + it('matches when title+buyer+cpv are equal and value+deadline are both NULL', () => { + const a = tenderFingerprint({ ...base }); + const b = tenderFingerprint({ ...base }); + expect(a).toBe(b); + }); + + it('differs for a different title (collision guard, Pitfall 1)', () => { + const a = tenderFingerprint(base); + const b = tenderFingerprint({ ...base, title: 'Sanierung eines Kindergartens' }); + expect(a).not.toBe(b); + }); + + it('normalizes umlauts so "München" and "Muenchen" contribute the same text', () => { + const a = tenderFingerprint({ ...base, buyerName: 'Stadt München' }); + const b = tenderFingerprint({ ...base, buyerName: 'Stadt Muenchen' }); + expect(a).toBe(b); + }); + + it('is order-independent and deduplicated for cpvDivisions', () => { + const a = tenderFingerprint({ ...base, cpvDivisions: ['45', '71'] }); + const b = tenderFingerprint({ ...base, cpvDivisions: ['71', '45', '71'] }); + expect(a).toBe(b); + }); + + it('buckets estimatedValue by order of magnitude — 12000 and 15000 collide', () => { + const a = tenderFingerprint({ ...base, estimatedValue: 12000 }); + const b = tenderFingerprint({ ...base, estimatedValue: 15000 }); + expect(a).toBe(b); + }); + + it('separates estimatedValue across a magnitude boundary', () => { + const a = tenderFingerprint({ ...base, estimatedValue: 9000 }); + const b = tenderFingerprint({ ...base, estimatedValue: 90000 }); + expect(a).not.toBe(b); + }); + + it('returns a stable sha256 hex string (deterministic across runs)', () => { + const result = tenderFingerprint(base); + expect(result).toMatch(/^[a-f0-9]{64}$/); + }); + + it('treats a null buyerName as an empty segment, not a crash', () => { + expect(() => tenderFingerprint({ ...base, buyerName: null })).not.toThrow(); + const a = tenderFingerprint({ ...base, buyerName: null }); + const b = tenderFingerprint({ ...base, buyerName: null }); + expect(a).toBe(b); + }); +});