Files
tessera-ctl/apps/api/src/tenders/adapters/cosinex.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

272 lines
10 KiB
TypeScript

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-<abbr> 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 = '<html><body><p>Keine Treffer</p></body></html>';
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 = `
<table class="csx-new-table">
<tbody>
<tr>
<td class="text-center"><abbr title="22.07.2026 um 10:00 Uhr">22.07.2026</abbr></td>
<td class="text-center"><abbr title="nicht vorhanden">nv</abbr></td>
<td class="word-break">Ohne PID</td>
<td>VOB/A<br />Ausschreibung</td>
<td>Test-Vergabestelle</td>
<td class="text-center"></td>
</tr>
<tr>
<td class="text-center"><abbr title="22.07.2026 um 10:00 Uhr">22.07.2026</abbr></td>
<td class="text-center"><abbr title="nicht vorhanden">nv</abbr></td>
<td class="word-break">Mit PID</td>
<td>VOB/A<br />Ausschreibung</td>
<td>Test-Vergabestelle 2</td>
<td class="text-center"><a href="/Satellite/public/company/projectForwarding.do?pid=999">Projektraum</a></td>
</tr>
</tbody>
</table>
`;
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 &uuml; HTML entity) — Response.text() would UTF-8-decode this
// incorrectly per the WHATWG Fetch spec.
const html = `
<table class="csx-new-table">
<tbody>
<tr>
<td class="text-center"><abbr title="22.07.2026 um 10:00 Uhr">22.07.2026</abbr></td>
<td class="text-center"><abbr title="nicht vorhanden">nv</abbr></td>
<td class="word-break">Müller-Test</td>
<td>VOB/A<br />Ausschreibung</td>
<td>Landkreis München</td>
<td class="text-center"><a href="/Satellite/public/company/projectForwarding.do?pid=42">Projektraum</a></td>
</tr>
</tbody>
</table>
`;
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'/);
});
});