feat(14-02): add RssAdapter.parseFeed + 'rss' normalizer dispatch

Fixture-first RSS parsing (INGEST-04): parses live-captured
service.bund.de (pubDate present, numeric-HTML-entity titles) and
subreport-elvis (pubDate absent, CDATA titles) feed shapes into
RawTenderRecord[] via fast-xml-parser, mirroring the DoeOpenDataAdapter
config. SourceType extended with 'rss'; normalize() dispatches 'rss'
through the existing normalizeBag() path unchanged (D-04/D-05).

Rule 1 fix: fast-xml-parser only decodes the 5 predefined XML entities,
not numeric character references — added an explicit decode step so
service.bund.de titles ("Übermittlung...") render correctly
instead of leaking raw entity syntax.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:17:02 +02:00
parent a47c0c57ee
commit 3a96cbbbe6
7 changed files with 597 additions and 6 deletions
@@ -81,7 +81,11 @@ function bagRecord(
},
): RawTenderRecord {
const sourcePortal =
sourceType === 'cosinex-dtvp' ? 'cosinex-dtvp' : 'tender24';
sourceType === 'cosinex-dtvp'
? 'cosinex-dtvp'
: sourceType === 'rss'
? 'service-bund'
: 'tender24';
return {
sourceType,
sourcePortal,
@@ -317,4 +321,38 @@ describe('TenderNormalizerService', () => {
expect(normalized.contentHash).toMatch(/^[a-f0-9]{64}$/);
});
});
describe("'rss' sourceType (Phase 14, Plan 02, INGEST-04)", () => {
it('maps an rss bag through normalizeBag(): title passes through, cpvDivisions stays empty', () => {
const raw = bagRecord('rss', {
title: 'Übermittlung von Bohrungsdaten',
buyerName: null,
procedureType: null,
legalFramework: null,
deadlineAt: null,
});
const normalized = service.normalize(raw);
expect(normalized.title).toBe('Übermittlung von Bohrungsdaten');
expect(normalized.cpvDivisions).toEqual([]);
expect(normalized.cpvCodes).toEqual([]);
expect(normalized.region).toBeNull();
expect(normalized.bundesland).toBeNull();
expect(normalized.buyerName).toBeNull();
expect(normalized.procedureType).toBeNull();
expect(normalized.deadlineAt).toBeNull();
expect(normalized.dedupKey).toBe(
`${raw.sourcePortal}:${raw.sourceNoticeId}`,
);
});
it('rss title falls back to "Unbenannte Ausschreibung" when empty', () => {
const raw = bagRecord('rss', { title: '' });
const normalized = service.normalize(raw);
expect(normalized.title).toBe('Unbenannte Ausschreibung');
});
});
});