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 |