Files
tessera-ctl/apps/api/src/tenders/adapters/doe-opendata.adapter.spec.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

176 lines
6.6 KiB
TypeScript

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['"]/);
});
});