import AdmZip from 'adm-zip'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildDoeNoticeUrl, DoeOpenDataAdapter } from './doe-opendata.adapter'; /** * Real, trimmed DÖE day-export fixtures (8 notices, captured live from * oeffentlichevergabe.de/api/notice-exports for pubDay=2026-07-19, trimmed * to a representative sample spanning D-02's tag classes): * - 4x tag=["tender"] -> must survive the D-02 filter * - 2x tag=["award"] -> must be excluded * - 1x tag=["planning"] -> must be excluded * - 1x no tag, populated awards -> must be excluded (Pitfall C) */ const FIXTURES_DIR = join(__dirname, '..', '__fixtures__'); const EFORMS_FIXTURE = join(FIXTURES_DIR, 'doe-eforms-sample.zip'); const OCDS_FIXTURE = join(FIXTURES_DIR, 'doe-ocds-sample.zip'); const EXPECTED_TENDER_TAGGED_COUNT = 4; const TEST_PUBDAY = '2026-07-19'; function stubFetchWithFixtures(): void { const eformsBuffer = readFileSync(EFORMS_FIXTURE); const ocdsBuffer = readFileSync(OCDS_FIXTURE); vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { const isEforms = url.includes('format=eforms.zip'); const buffer = isEforms ? eformsBuffer : ocdsBuffer; return { ok: true, status: 200, arrayBuffer: async () => buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength, ), } as Response; }), ); } function stub400Fetch(): void { vi.stubGlobal( 'fetch', vi.fn(async () => { return { ok: false, status: 400 } as Response; }), ); } describe('DoeOpenDataAdapter', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('has sourceType doe-opendata', () => { const adapter = new DoeOpenDataAdapter(); expect(adapter.sourceType).toBe('doe-opendata'); }); it('parses the fixture ZIPs into RawTenderRecord[], D-02 filtered to only tag=["tender"] notices', async () => { stubFetchWithFixtures(); const adapter = new DoeOpenDataAdapter(); const records = await adapter.fetchTenders(TEST_PUBDAY); // (a) parsed records exist, (b) D-02 filter keeps only tag=["tender"], // exact count assertion against the real fixture's known composition. expect(records).toHaveLength(EXPECTED_TENDER_TAGGED_COUNT); for (const record of records) { expect(record.sourceType).toBe('doe-opendata'); expect(record.ocdsPayload).toBeTruthy(); expect(record.eformsPayload).toBeTruthy(); } }); it('excludes award/planning/untagged-with-awards notices from the returned records', async () => { stubFetchWithFixtures(); const adapter = new DoeOpenDataAdapter(); const records = await adapter.fetchTenders(TEST_PUBDAY); const noticeIds = records.map((r) => r.sourceNoticeId); // Known award-tagged notice ids from the fixture — must NOT appear. expect(noticeIds).not.toContain('006803d3-5b37-4362-bea9-124a44de67cb'); expect(noticeIds).not.toContain('00a66578-d011-4a35-9628-eadd057228a0'); // Known planning-tagged notice id — must NOT appear. expect(noticeIds).not.toContain('0891b60a-846a-47c0-8d90-11bbde87d560'); // Known untagged-with-populated-awards notice id — must NOT appear (Pitfall C). expect(noticeIds).not.toContain('01726f63-0dbc-4456-b355-67082823199f'); }); it('returns [] without throwing when the DÖE endpoint responds 400 (today/future pubDay, expected no-op)', async () => { stub400Fetch(); const adapter = new DoeOpenDataAdapter(); const records = await adapter.fetchTenders('2026-07-21'); expect(records).toEqual([]); }); it('rejects an archive whose declared uncompressed size exceeds the decompression-bomb ceiling, before extracting entries (T-10-07)', async () => { // Highly compressible synthetic archive: real (not corrupted) zip whose // entries declare well over the ~50MB ceiling in their uncompressed // size header, while the on-disk/compressed archive itself stays tiny. const bomb = new AdmZip(); bomb.addFile('bomb.xml', Buffer.alloc(60 * 1024 * 1024, 0)); const bombBuffer = bomb.toBuffer(); vi.stubGlobal( 'fetch', vi.fn(async () => { return { ok: true, status: 200, arrayBuffer: async () => bombBuffer.buffer.slice( bombBuffer.byteOffset, bombBuffer.byteOffset + bombBuffer.byteLength, ), } as Response; }), ); const adapter = new DoeOpenDataAdapter(); await expect(adapter.fetchTenders(TEST_PUBDAY)).rejects.toThrow(); }); // Backlog item 2026-08-05: sourceUrl used to be the OCDS document's own // `uri`, which is the API address and answers with JSON. Users following a // link from the results list, the detail view or an alert mail landed on raw // JSON instead of the notice. it('points sourceUrl at the human-readable notice page, never at the API', async () => { stubFetchWithFixtures(); const adapter = new DoeOpenDataAdapter(); const records = await adapter.fetchTenders(TEST_PUBDAY); expect(records.length).toBe(EXPECTED_TENDER_TAGGED_COUNT); for (const record of records) { expect(record.sourceUrl).toBe( `https://oeffentlichevergabe.de/ui/de/search/details?noticeId=${record.sourceNoticeId}`, ); // The exact shape that was broken — an API address serving OCDS JSON. expect(record.sourceUrl).not.toContain('/api/notices/'); expect(record.sourceUrl).not.toContain('format=ocds'); } }); it('builds the notice URL from both id shapes the feed uses, escaping the id', () => { // Numeric and UUID ids both occur in the live feed; both were verified in // a browser on 2026-08-11 to render the correct notice. expect(buildDoeNoticeUrl('25673764')).toBe( 'https://oeffentlichevergabe.de/ui/de/search/details?noticeId=25673764', ); expect(buildDoeNoticeUrl('7085ba12-c7f4-4f8c-8599-2fe9e4e2737c')).toBe( 'https://oeffentlichevergabe.de/ui/de/search/details?noticeId=7085ba12-c7f4-4f8c-8599-2fe9e4e2737c', ); // An id is directory data, not something to paste into a URL unchecked. expect(buildDoeNoticeUrl('a b&c=d')).toBe( 'https://oeffentlichevergabe.de/ui/de/search/details?noticeId=a%20b%26c%3Dd', ); }); it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => { const source = readFileSync( join(__dirname, 'doe-opendata.adapter.ts'), 'utf8', ); expect(source).not.toMatch(/from ['"]axios['"]/); }); });