test(quick-260723-e7i): cover ai-netserver/cosinex bag normalization + DOE regression

- bagRecord() helper builds inline RawTenderRecords for the flat ocdsPayload
  bag shape (no fixtures exist for NetServer/cosinex-DTVP)
- ai-netserver and cosinex-dtvp full-bag mapping, null/empty-field fallback,
  and contentHash-format assertions
- Existing DOE fixture-based assertions untouched, confirming the refactor
  didn't change DOE-path behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 10:19:00 +02:00
parent 82c9a48305
commit 988100576f
@@ -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}$/);
});
});
});