import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CosinexAdapter } from './cosinex.adapter'; /** * Real, live-captured cosinex/DTVP "Aktuelle Bekanntmachungen" results * table (20 rows, from a live GET against * `https://www.dtvp.de/Satellite/company/welcome.do`, 2026-07-23), stored * as plain UTF-8 on disk — the live response itself is * `charset=ISO-8859-1` (raw Latin-1 bytes, not HTML entities), which is * exercised separately below via the encoding-conversion tests, so the * on-disk fixture is transcoded to UTF-8 for a simple `readFileSync(..., * 'utf8')` (matching netserver-search.html's convention). */ const FIXTURE_PATH = join( __dirname, '..', '__fixtures__', 'cosinex-search.html', ); const FIXTURE_HTML = readFileSync(FIXTURE_PATH, 'utf8'); const FIXTURE_ROW_COUNT = 20; /** Encodes `html` as Latin-1 bytes and stubs `fetch` to resolve with them via `arrayBuffer()` — mirrors the adapter's real decode path (`TextDecoder('iso-8859-1')`). */ function stubFetchWithHtml(html: string): void { const bytes = Buffer.from(html, 'latin1'); const arrayBuffer = bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, ); vi.stubGlobal( 'fetch', vi.fn(async () => { return { ok: true, status: 200, arrayBuffer: async () => arrayBuffer, } as unknown as Response; }), ); } function stubFetchThrowing(): void { vi.stubGlobal( 'fetch', vi.fn(async () => { throw new Error('network unreachable'); }), ); } describe('CosinexAdapter', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('declares sourceType cosinex-dtvp and the single cosinex-dtvp portal', () => { const adapter = new CosinexAdapter(); expect(adapter.sourceType).toBe('cosinex-dtvp'); expect(adapter.portals).toEqual(['cosinex-dtvp']); }); describe('parseSearchResults (pure, fixture-driven)', () => { it('parses all rows of the fixture into RawTenderRecord[], sourceType/sourcePortal set per row', () => { const adapter = new CosinexAdapter(); const fetchedAt = new Date(); const records = adapter.parseSearchResults(FIXTURE_HTML, fetchedAt); expect(records.length).toBeGreaterThanOrEqual(1); expect(records).toHaveLength(FIXTURE_ROW_COUNT); for (const record of records) { expect(record.sourceType).toBe('cosinex-dtvp'); expect(record.sourcePortal).toBe('cosinex-dtvp'); expect(record.sourceUrl).toMatch( /^https:\/\/www\.dtvp\.de\/Satellite\/public\/company\/projectForwarding\.do\?pid=\d+$/, ); expect(record.fetchedAt).toBe(fetchedAt); } }); it('uses the row detail-link pid as sourceNoticeId, unique per row', () => { const adapter = new CosinexAdapter(); const records = adapter.parseSearchResults(FIXTURE_HTML, new Date()); const ids = records.map((r) => r.sourceNoticeId); expect(ids).toContain('335929'); expect(ids).toContain('335920'); expect(new Set(ids).size).toBe(ids.length); }); it('parses a populated publishedAt and an "nv" (nicht vorhanden) deadline to null', () => { const adapter = new CosinexAdapter(); const records = adapter.parseSearchResults(FIXTURE_HTML, new Date()); const row = records.find((r) => r.sourceNoticeId === '335929'); expect(row).toBeDefined(); expect(row!.publishedAt).toEqual(new Date('2026-07-23T09:07:00.000Z')); expect(row!.sourceUrl).toBe( 'https://www.dtvp.de/Satellite/public/company/projectForwarding.do?pid=335929', ); const payload = row!.ocdsPayload as { title: string; buyerName: string | null; legalFramework: string | null; procedureType: string | null; deadlineAt: string | null; }; expect(payload.deadlineAt).toBeNull(); expect(payload.buyerName).toBe('Stadt Wolfsburg'); expect(payload.legalFramework).toBe('UVgO'); expect(payload.procedureType).toBe('Vergebener Auftrag'); expect(payload.title).toMatch(/^26-0311 Beschaffung Ultraschallsysteme/); }); it('parses a populated deadline row to a non-null ISO deadlineAt', () => { const adapter = new CosinexAdapter(); const records = adapter.parseSearchResults(FIXTURE_HTML, new Date()); const row = records.find((r) => r.sourceNoticeId === '335920'); expect(row).toBeDefined(); const payload = row!.ocdsPayload as { deadlineAt: string | null }; expect(payload.deadlineAt).toBe('2026-08-12T13:30:00.000Z'); }); it('extracts a nested- procedure-type abbreviation (e.g. "TNW") via text, not HTML', () => { const adapter = new CosinexAdapter(); const records = adapter.parseSearchResults(FIXTURE_HTML, new Date()); const row = records.find((r) => r.sourceNoticeId === '335752'); expect(row).toBeDefined(); const payload = row!.ocdsPayload as { legalFramework: string | null; procedureType: string | null; }; expect(payload.legalFramework).toBe('VgV'); expect(payload.procedureType).toBe('TNW'); }); it('decodes a real umlaut buyer name correctly (Münster AöR)', () => { const adapter = new CosinexAdapter(); const records = adapter.parseSearchResults(FIXTURE_HTML, new Date()); const row = records.find((r) => r.sourceNoticeId === '328078'); expect(row).toBeDefined(); const payload = row!.ocdsPayload as { buyerName: string | null }; expect(payload.buyerName).toBe('Studierendenwerk Münster AöR'); }); it('returns [] for empty HTML instead of throwing', () => { const adapter = new CosinexAdapter(); expect(adapter.parseSearchResults('', new Date())).toEqual([]); }); it('returns [] for HTML with no matching result table instead of throwing (e.g. future JS-rendered redesign)', () => { const adapter = new CosinexAdapter(); const broken = '

Keine Treffer

'; expect(adapter.parseSearchResults(broken, new Date())).toEqual([]); }); it('skips a row without a pid-bearing detail link without throwing or affecting other rows', () => { const adapter = new CosinexAdapter(); const html = `
22.07.2026 nv Ohne PID VOB/A
Ausschreibung
Test-Vergabestelle
22.07.2026 nv Mit PID VOB/A
Ausschreibung
Test-Vergabestelle 2 Projektraum
`; const records = adapter.parseSearchResults(html, new Date()); expect(records).toHaveLength(1); expect(records[0]?.sourceNoticeId).toBe('999'); }); }); describe('fetchTenders (single-portal fetch, ISO-8859-1 decoding, fetch mocked)', () => { it('fetches and parses the listing, decoding the real fixture bytes correctly', async () => { stubFetchWithHtml(FIXTURE_HTML); const adapter = new CosinexAdapter(); const records = await adapter.fetchTenders('2026-07-23'); expect(records).toHaveLength(FIXTURE_ROW_COUNT); expect(records.every((r) => r.sourcePortal === 'cosinex-dtvp')).toBe( true, ); const umlautRow = records.find((r) => r.sourceNoticeId === '328078'); const payload = umlautRow!.ocdsPayload as { buyerName: string | null }; expect(payload.buyerName).toBe('Studierendenwerk Münster AöR'); }); it('decodes raw Latin-1 umlaut bytes correctly (Rule 1 fix: arrayBuffer + TextDecoder(iso-8859-1), NOT res.text())', async () => { // Raw Latin-1 byte 0xFC for 'ü' (as cosinex actually serves it, NOT // an ü HTML entity) — Response.text() would UTF-8-decode this // incorrectly per the WHATWG Fetch spec. const html = `
22.07.2026 nv Müller-Test VOB/A
Ausschreibung
Landkreis München Projektraum
`; stubFetchWithHtml(html); const adapter = new CosinexAdapter(); const records = await adapter.fetchTenders('2026-07-23'); expect(records).toHaveLength(1); const payload = records[0]!.ocdsPayload as { buyerName: string | null }; expect(payload.buyerName).toBe('Landkreis München'); expect(records[0]!.ocdsPayload).toMatchObject({ title: 'Müller-Test' }); }); it('returns [] when fetch throws, without propagating (D-01 total-failure fallback)', async () => { stubFetchThrowing(); const adapter = new CosinexAdapter(); const records = await adapter.fetchTenders('2026-07-23'); expect(records).toEqual([]); }); it('returns [] without throwing when the listing responds non-2xx', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => { return { ok: false, status: 503 } as Response; }), ); const adapter = new CosinexAdapter(); 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, 'cosinex.adapter.ts'), 'utf8'); expect(source).not.toMatch(/from ['"]axios['"]/); }); it('never interpolates the base URL from a variable/parameter (SSRF guard, T-13-05-01)', () => { const source = readFileSync(join(__dirname, 'cosinex.adapter.ts'), 'utf8'); expect(source).toMatch(/COSINEX_BASE_URL = 'https:\/\/www\.dtvp\.de'/); }); });