636fe0df8f
- 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
176 lines
6.6 KiB
TypeScript
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['"]/);
|
|
});
|
|
});
|