test(10-03): add failing specs + real DÖE fixtures for adapter/normalizer
- Capture real, trimmed DÖE day-export fixtures (pubDay=2026-07-19, 8 notices spanning tender/award/planning/untagged-with-awards tag classes) under tenders/__fixtures__/, live-downloaded from oeffentlichevergabe.de - Define RawTenderRecord/NormalizedTenderFields/SourceType (tender.types.ts) and TenderSourceAdapter (day-cursor fetchTenders signature per RESEARCH Pattern 1) - RED: doe-opendata.adapter.spec.ts asserts D-02 exact-count filtering, HTTP-400 no-op, and a zip-bomb ceiling guard (T-10-07) - RED: tender-normalizer.service.spec.ts asserts eForms-primary deadline recovery where OCDS is null (RESEARCH Pattern 3), nullable deadline/value, dedupKey priority (ocid -> sourcePortal:sourceNoticeId), and a stable sha256 contentHash
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
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 { TenderNormalizerService } from './tender-normalizer.service';
|
||||
|
||||
/**
|
||||
* Loads a real notice pair (eForms-DE XML + OCDS JSON) directly from the
|
||||
* committed fixture ZIPs and assembles it into a RawTenderRecord — the same
|
||||
* real-fixture precedent as the DKV PDF parser, kept independent of
|
||||
* DoeOpenDataAdapter so this unit test exercises TenderNormalizerService in
|
||||
* isolation as a pure mapping function.
|
||||
*/
|
||||
const FIXTURES_DIR = join(__dirname, '__fixtures__');
|
||||
const xmlParser = new XMLParser({
|
||||
removeNSPrefix: true,
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
function loadRawNoticePair(basename: string): RawTenderRecord {
|
||||
const eformsZip = new AdmZip(
|
||||
readFileSync(join(FIXTURES_DIR, 'doe-eforms-sample.zip')),
|
||||
);
|
||||
const ocdsZip = new AdmZip(
|
||||
readFileSync(join(FIXTURES_DIR, 'doe-ocds-sample.zip')),
|
||||
);
|
||||
|
||||
const xmlEntry = eformsZip.getEntry(`${basename}.xml`);
|
||||
const jsonEntry = ocdsZip.getEntry(`${basename}.json`);
|
||||
if (!xmlEntry || !jsonEntry) {
|
||||
throw new Error(`Fixture entry missing for ${basename}`);
|
||||
}
|
||||
|
||||
const eformsPayload = xmlParser.parse(xmlEntry.getData().toString('utf8'));
|
||||
const ocdsDocument = JSON.parse(jsonEntry.getData().toString('utf8'));
|
||||
const release = ocdsDocument.releases[0];
|
||||
|
||||
return {
|
||||
sourceType: 'doe-opendata',
|
||||
sourcePortal: 'doe-opendata',
|
||||
sourceNoticeId: release.id,
|
||||
ocid: release.ocid,
|
||||
sourceUrl: ocdsDocument.uri,
|
||||
fetchedAt: new Date(),
|
||||
publishedAt: ocdsDocument.publishedDate
|
||||
? new Date(ocdsDocument.publishedDate)
|
||||
: null,
|
||||
eformsPayload,
|
||||
ocdsPayload: release,
|
||||
};
|
||||
}
|
||||
|
||||
// Real notice: eForms XML has a structured
|
||||
// ProcurementProjectLot/TenderingProcess/TenderSubmissionDeadlinePeriod/EndDate
|
||||
// (2026-08-18), while the paired OCDS release has no `tender.tenderPeriod` at
|
||||
// all (RESEARCH.md Pattern 3 live cross-check).
|
||||
const NOTICE_WITH_EFORMS_ONLY_DEADLINE =
|
||||
'019f64ec-cd64-4c09-8fd7-4fd12d8eea64-01';
|
||||
|
||||
// Real notice: neither the eForms XML nor the OCDS release carries a
|
||||
// deadline or estimated value (RESEARCH.md Pattern 4 — the common case).
|
||||
const NOTICE_WITH_NO_DEADLINE_OR_VALUE =
|
||||
'001c68dd-258a-4bf7-a264-478604ad9c3e-01';
|
||||
|
||||
describe('TenderNormalizerService', () => {
|
||||
const service = new TenderNormalizerService();
|
||||
|
||||
it('recovers deadlineAt from eForms-DE XML when the OCDS release has no tenderPeriod (SCHEMA-01 key assertion)', () => {
|
||||
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
|
||||
|
||||
const normalized = service.normalize(raw);
|
||||
|
||||
expect(normalized.deadlineAt).not.toBeNull();
|
||||
expect(normalized.deadlineAt?.getUTCFullYear()).toBe(2026);
|
||||
expect(normalized.deadlineAt?.getUTCMonth()).toBe(7); // August (0-indexed)
|
||||
expect(normalized.deadlineAt?.getUTCDate()).toBe(18);
|
||||
});
|
||||
|
||||
it('normalizes missing deadline/value to null, not thrown or zero', () => {
|
||||
const raw = loadRawNoticePair(NOTICE_WITH_NO_DEADLINE_OR_VALUE);
|
||||
|
||||
const normalized = service.normalize(raw);
|
||||
|
||||
expect(normalized.deadlineAt).toBeNull();
|
||||
expect(normalized.estimatedValue).toBeNull();
|
||||
expect(normalized.title).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dedupKey = ocid when present', () => {
|
||||
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
|
||||
|
||||
const normalized = service.normalize(raw);
|
||||
|
||||
expect(raw.ocid).toBeTruthy();
|
||||
expect(normalized.dedupKey).toBe(raw.ocid);
|
||||
});
|
||||
|
||||
it('dedupKey falls back to sourcePortal:sourceNoticeId when ocid is absent', () => {
|
||||
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
|
||||
const rawWithoutOcid: RawTenderRecord = { ...raw, ocid: undefined };
|
||||
|
||||
const normalized = service.normalize(rawWithoutOcid);
|
||||
|
||||
expect(normalized.dedupKey).toBe(
|
||||
`${raw.sourcePortal}:${raw.sourceNoticeId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('contentHash is a stable sha256 over title+deadline+value+status', () => {
|
||||
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
|
||||
|
||||
const first = service.normalize(raw);
|
||||
const second = service.normalize(raw);
|
||||
|
||||
expect(first.contentHash).toBe(second.contentHash);
|
||||
expect(first.contentHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('contentHash changes when the deadline changes', () => {
|
||||
const raw = loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE);
|
||||
const original = service.normalize(raw);
|
||||
|
||||
// Mutate the eForms deadline to simulate a Fristverlängerung (SCHEMA-02).
|
||||
const mutatedEformsPayload = JSON.parse(JSON.stringify(raw.eformsPayload));
|
||||
const root =
|
||||
mutatedEformsPayload[Object.keys(mutatedEformsPayload)[0]];
|
||||
const lots = Array.isArray(root.ProcurementProjectLot)
|
||||
? root.ProcurementProjectLot
|
||||
: [root.ProcurementProjectLot];
|
||||
for (const lot of lots) {
|
||||
lot.TenderingProcess.TenderSubmissionDeadlinePeriod.EndDate =
|
||||
'2026-12-31+01:00';
|
||||
}
|
||||
const mutatedRaw: RawTenderRecord = {
|
||||
...raw,
|
||||
eformsPayload: mutatedEformsPayload,
|
||||
};
|
||||
|
||||
const mutated = service.normalize(mutatedRaw);
|
||||
|
||||
expect(mutated.contentHash).not.toBe(original.contentHash);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user