import { readFileSync } from 'node:fs'; import { join } from 'node: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 `&`-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 + &-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 = '

Keine Treffer

'; 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 = `
22.07.2026 Ohne OID Test-Vergabestelle Offenes Verfahren VOB
22.07.2026 Mit OID Test-Vergabestelle 2 Offenes Verfahren VOB
`; 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'/); }); });