feat(13-04): NetServer-Adapter fuer tender24/lhs-vpbw/vergabe.landbw (INGEST-02)

- Package-Legitimacy-Checkpoint bestaetigt: cheerio (sauberer Gate-Pass,
  27M Downloads/Woche) statt node-html-parser installiert
- EIN config-getriebener NetServerAdapter bedient alle 3 AI-AG-Portale
  via PublicationSearchControllerServlet-Trefferliste, parst <table> mit
  cheerio zu RawTenderRecord[], sourcePortal je Zeile korrekt
- sourceNoticeId = data-oid (stabil, per Zeile eindeutig); sourceUrl =
  Such-URL als dokumentierter Best-Effort-Fallback (kein Deep-Link ohne
  JS-Ausfuehrung ermittelbar, Open Question 2)
- Fehlertoleranz: try/catch pro Zeile + pro Portal, []-Fallback bei
  Totalausfall, AbortController 15s-Timeout, hardcodierte Portal-URLs
  (SSRF-Guard T-13-04-01)
- Live-gecapturte Fixture (tender24.de, 2026-07-23) mit Umlaut-/&amp;-
  Buyer, gefuellter und leerer Deadline; 14 Spec-Tests gruen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 09:13:01 +02:00
parent 2c28605397
commit 043ada9438
5 changed files with 651 additions and 21 deletions
@@ -0,0 +1,255 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NetServerAdapter } from './netserver.adapter';
/**
* Real, live-captured NetServer results table (6 rows, trimmed from a
* 161 KB live GET against
* `https://www.tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications`,
* 2026-07-23) — covers an umlaut buyer name ("Sterr-Kölln & Partner",
* exercising `&amp;`-entity decoding), a populated deadline row, and
* several empty-deadline rows (already-awarded / no open deadline).
*/
const FIXTURE_PATH = join(
__dirname,
'..',
'__fixtures__',
'netserver-search.html',
);
const FIXTURE_HTML = readFileSync(FIXTURE_PATH, 'utf8');
const FIXTURE_ROW_COUNT = 6;
function stubFetchWithHtml(html: string): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return {
ok: true,
status: 200,
text: async () => html,
} as Response;
}),
);
}
function stubFetchThrowing(): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network unreachable');
}),
);
}
describe('NetServerAdapter', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('declares sourceType ai-netserver and the 3 AI-AG NetServer portals', () => {
const adapter = new NetServerAdapter();
expect(adapter.sourceType).toBe('ai-netserver');
expect(adapter.portals).toEqual(['tender24', 'lhs-vpbw', 'vergabe.landbw']);
});
describe('parseSearchResults (pure, fixture-driven)', () => {
it('parses all rows of the fixture into RawTenderRecord[], sourcePortal set per row', () => {
const adapter = new NetServerAdapter();
const fetchedAt = new Date();
const records = adapter.parseSearchResults(
FIXTURE_HTML,
'tender24',
fetchedAt,
);
expect(records.length).toBeGreaterThanOrEqual(1);
expect(records).toHaveLength(FIXTURE_ROW_COUNT);
for (const record of records) {
expect(record.sourceType).toBe('ai-netserver');
expect(record.sourcePortal).toBe('tender24');
expect(record.sourceNoticeId).toMatch(/^54321-NetTender-/);
expect(record.sourceUrl).toBe(
'https://www.tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications',
);
expect(record.fetchedAt).toBe(fetchedAt);
}
});
it('decodes an umlaut + &amp;-entity buyer name correctly (Sterr-Kölln & Partner)', () => {
const adapter = new NetServerAdapter();
const records = adapter.parseSearchResults(
FIXTURE_HTML,
'tender24',
new Date(),
);
const row = records.find(
(r) => r.sourceNoticeId === '54321-NetTender-19f800ce3b1-36b275763103203a',
);
expect(row).toBeDefined();
const payload = row!.ocdsPayload as { buyerName: string | null };
expect(payload.buyerName).toBe('Sterr-Kölln & Partner');
});
it('uses the row data-oid attribute as sourceNoticeId', () => {
const adapter = new NetServerAdapter();
const records = adapter.parseSearchResults(
FIXTURE_HTML,
'lhs-vpbw',
new Date(),
);
const ids = records.map((r) => r.sourceNoticeId);
expect(ids).toContain('54321-NetTender-19f800ce3b1-36b275763103203a');
expect(ids).toContain('54321-NetTender-19e21c643b8-4268f33140e63c36');
// every id is unique — no accidental row-collapsing
expect(new Set(ids).size).toBe(ids.length);
});
it('maps a populated deadline row to a Date and an empty-deadline row to null (never throws)', () => {
const adapter = new NetServerAdapter();
const records = adapter.parseSearchResults(
FIXTURE_HTML,
'tender24',
new Date(),
);
const withDeadline = records.find(
(r) => r.sourceNoticeId === '54321-NetTender-19f800ce3b1-36b275763103203a',
);
const withoutDeadline = records.find(
(r) => r.sourceNoticeId === '54321-NetTender-19e21c643b8-4268f33140e63c36',
);
expect(withDeadline).toBeDefined();
expect(withoutDeadline).toBeDefined();
const withDeadlinePayload = withDeadline!.ocdsPayload as {
deadlineAt: string | null;
};
const withoutDeadlinePayload = withoutDeadline!.ocdsPayload as {
deadlineAt: string | null;
};
// "20.08.2026 24:00" -> normalized to 2026-08-21T00:00Z (NetServer's 24:00 = next-day midnight)
expect(withDeadlinePayload.deadlineAt).toBe('2026-08-21T00:00:00.000Z');
expect(withoutDeadlinePayload.deadlineAt).toBeNull();
});
it('returns [] for empty HTML instead of throwing', () => {
const adapter = new NetServerAdapter();
expect(adapter.parseSearchResults('', 'tender24', new Date())).toEqual(
[],
);
});
it('returns [] for HTML with no matching result rows instead of throwing', () => {
const adapter = new NetServerAdapter();
const broken = '<html><body><p>Keine Treffer</p></body></html>';
expect(
adapter.parseSearchResults(broken, 'tender24', new Date()),
).toEqual([]);
});
it('skips a row missing data-oid without throwing or affecting other rows', () => {
const adapter = new NetServerAdapter();
const html = `
<table>
<tr class="tableRow publicationDetail">
<td>22.07.2026</td>
<td class="tender">Ohne OID</td>
<td class="tenderAuthority">Test-Vergabestelle</td>
<td class="tenderType">Offenes Verfahren</td>
<td class="tenderType">VOB</td>
<td class="tenderDeadline"></td>
</tr>
<tr class="tableRow publicationDetail" data-oid="test-oid-1">
<td>22.07.2026</td>
<td class="tender">Mit OID</td>
<td class="tenderAuthority">Test-Vergabestelle 2</td>
<td class="tenderType">Offenes Verfahren</td>
<td class="tenderType">VOB</td>
<td class="tenderDeadline"></td>
</tr>
</table>
`;
const records = adapter.parseSearchResults(html, 'tender24', new Date());
expect(records).toHaveLength(1);
expect(records[0]?.sourceNoticeId).toBe('test-oid-1');
});
});
describe('fetchTenders (portal fan-out, fetch mocked)', () => {
it('fetches all 3 portals and aggregates their parsed records', async () => {
stubFetchWithHtml(FIXTURE_HTML);
const adapter = new NetServerAdapter();
const records = await adapter.fetchTenders('2026-07-23');
expect(records.length).toBe(FIXTURE_ROW_COUNT * 3);
const portals = new Set(records.map((r) => r.sourcePortal));
expect(portals).toEqual(new Set(['tender24', 'lhs-vpbw', 'vergabe.landbw']));
});
it('returns [] for a portal whose fetch throws, without aborting the other portals (D-01)', async () => {
const adapter = new NetServerAdapter();
let callCount = 0;
vi.stubGlobal(
'fetch',
vi.fn(async () => {
callCount += 1;
if (callCount === 1) throw new Error('portal 1 unreachable');
return { ok: true, status: 200, text: async () => FIXTURE_HTML } as Response;
}),
);
const records = await adapter.fetchTenders('2026-07-23');
// first portal fails -> contributes 0 records; the other two succeed.
expect(records.length).toBe(FIXTURE_ROW_COUNT * 2);
});
it('returns [] for every portal when fetch always throws, without propagating (D-01 total-failure fallback)', async () => {
stubFetchThrowing();
const adapter = new NetServerAdapter();
const records = await adapter.fetchTenders('2026-07-23');
expect(records).toEqual([]);
});
it('returns [] without throwing when a portal responds non-2xx', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return { ok: false, status: 503 } as Response;
}),
);
const adapter = new NetServerAdapter();
const records = await adapter.fetchTenders('2026-07-23');
expect(records).toEqual([]);
});
});
it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => {
const source = readFileSync(
join(__dirname, 'netserver.adapter.ts'),
'utf8',
);
expect(source).not.toMatch(/from ['"]axios['"]/);
});
it('never interpolates a portal base URL from a variable/parameter (SSRF guard, T-13-04-01)', () => {
const source = readFileSync(
join(__dirname, 'netserver.adapter.ts'),
'utf8',
);
// NETSERVER_PORTALS values must be string literals, not template
// expressions referencing an external input.
expect(source).toMatch(/baseUrl: 'https:\/\/www\.tender24\.de'/);
expect(source).toMatch(/baseUrl: 'https:\/\/lhs-vpbw\.vmstart\.de'/);
expect(source).toMatch(/baseUrl: 'https:\/\/vergabe\.landbw\.de'/);
});
});