diff --git a/apps/api/src/tenders/tender-normalizer.service.spec.ts b/apps/api/src/tenders/tender-normalizer.service.spec.ts index 99acd93..0f26017 100644 --- a/apps/api/src/tenders/tender-normalizer.service.spec.ts +++ b/apps/api/src/tenders/tender-normalizer.service.spec.ts @@ -3,7 +3,7 @@ import { XMLParser } from 'fast-xml-parser'; import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it } from 'vitest'; -import type { RawTenderRecord } from './tender.types'; +import type { RawTenderRecord, SourceType } from './tender.types'; import { TenderNormalizerService } from './tender-normalizer.service'; /** @@ -65,6 +65,36 @@ const NOTICE_WITH_EFORMS_ONLY_DEADLINE = const NOTICE_WITH_NO_DEADLINE_OR_VALUE = '001c68dd-258a-4bf7-a264-478604ad9c3e-01'; +/** + * Builds a RawTenderRecord for the flat ocdsPayload-bag scraper adapters + * (NetServer/cosinex-DTVP) — no fixture ZIPs exist for these sources, so + * the record is constructed inline (gap closure, 13-VERIFICATION.md). + */ +function bagRecord( + sourceType: SourceType, + bag: { + title?: string | null; + buyerName?: string | null; + procedureType?: string | null; + legalFramework?: string | null; + deadlineAt?: string | null; + }, +): RawTenderRecord { + const sourcePortal = + sourceType === 'cosinex-dtvp' ? 'cosinex-dtvp' : 'tender24'; + return { + sourceType, + sourcePortal, + sourceNoticeId: 'bag-notice-1', + ocid: undefined, + sourceUrl: `https://example.invalid/${sourcePortal}`, + fetchedAt: new Date(), + publishedAt: null, + eformsPayload: null, + ocdsPayload: bag, + }; +} + describe('TenderNormalizerService', () => { const service = new TenderNormalizerService(); @@ -210,4 +240,81 @@ describe('TenderNormalizerService', () => { expect(mutated.contentHash).not.toBe(original.contentHash); }); + + describe('generic ocdsPayload bag (ai-netserver / cosinex-dtvp gap closure)', () => { + const fullBag = { + title: 'Bauleistung X', + buyerName: 'Stadt Y', + procedureType: 'Offenes Verfahren', + legalFramework: 'VOB/A', + deadlineAt: '2026-09-01T10:00:00.000Z', + }; + + it('ai-netserver: maps the flat bag into NormalizedTenderFields', () => { + const raw = bagRecord('ai-netserver', fullBag); + + const normalized = service.normalize(raw); + + expect(normalized.title).toBe('Bauleistung X'); + expect(normalized.buyerName).toBe('Stadt Y'); + expect(normalized.procedureType).toBe('Offenes Verfahren'); + expect(normalized.deadlineAt).toBeInstanceOf(Date); + expect(normalized.deadlineAt?.toISOString()).toBe( + '2026-09-01T10:00:00.000Z', + ); + expect(normalized.cpvCodes).toEqual([]); + expect(normalized.cpvDivisions).toEqual([]); + expect(normalized.region).toBeNull(); + expect(normalized.plz).toBeNull(); + expect(normalized.bundesland).toBeNull(); + expect(normalized.estimatedValue).toBeNull(); + expect(normalized.dedupKey).toBe(`${raw.sourcePortal}:${raw.sourceNoticeId}`); + expect(normalized.status).toBe('active'); + }); + + it('cosinex-dtvp: maps an identical bag shape identically', () => { + const raw = bagRecord('cosinex-dtvp', fullBag); + + const normalized = service.normalize(raw); + + expect(normalized.title).toBe('Bauleistung X'); + expect(normalized.buyerName).toBe('Stadt Y'); + expect(normalized.procedureType).toBe('Offenes Verfahren'); + expect(normalized.deadlineAt?.toISOString()).toBe( + '2026-09-01T10:00:00.000Z', + ); + expect(normalized.cpvCodes).toEqual([]); + expect(normalized.cpvDivisions).toEqual([]); + expect(normalized.region).toBeNull(); + expect(normalized.plz).toBeNull(); + expect(normalized.bundesland).toBeNull(); + expect(normalized.estimatedValue).toBeNull(); + expect(normalized.dedupKey).toBe(`${raw.sourcePortal}:${raw.sourceNoticeId}`); + }); + + it('null/empty bag fields: buyerName/procedureType/deadlineAt stay null, title falls back', () => { + const raw = bagRecord('ai-netserver', { + title: '', + buyerName: null, + procedureType: null, + legalFramework: null, + deadlineAt: null, + }); + + const normalized = service.normalize(raw); + + expect(normalized.title).toBe('Unbenannte Ausschreibung'); + expect(normalized.buyerName).toBeNull(); + expect(normalized.procedureType).toBeNull(); + expect(normalized.deadlineAt).toBeNull(); + }); + + it('contentHash for a bag source is a stable sha256 (proves shared assemble() reuse)', () => { + const raw = bagRecord('ai-netserver', fullBag); + + const normalized = service.normalize(raw); + + expect(normalized.contentHash).toMatch(/^[a-f0-9]{64}$/); + }); + }); });