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
256 lines
8.8 KiB
TypeScript
256 lines
8.8 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { NetServerAdapter } from './netserver.adapter';
|
|
|
|
/**
|
|
* Real, live-captured NetServer results table (6 rows, trimmed from a
|
|
* 161 KB live GET against
|
|
* `https://www.tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications`,
|
|
* 2026-07-23) — covers an umlaut buyer name ("Sterr-Kölln & Partner",
|
|
* exercising `&`-entity decoding), a populated deadline row, and
|
|
* several empty-deadline rows (already-awarded / no open deadline).
|
|
*/
|
|
const FIXTURE_PATH = join(
|
|
__dirname,
|
|
'..',
|
|
'__fixtures__',
|
|
'netserver-search.html',
|
|
);
|
|
const FIXTURE_HTML = readFileSync(FIXTURE_PATH, 'utf8');
|
|
const FIXTURE_ROW_COUNT = 6;
|
|
|
|
function stubFetchWithHtml(html: string): void {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => html,
|
|
} as Response;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function stubFetchThrowing(): void {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => {
|
|
throw new Error('network unreachable');
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe('NetServerAdapter', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('declares sourceType ai-netserver and the 3 AI-AG NetServer portals', () => {
|
|
const adapter = new NetServerAdapter();
|
|
expect(adapter.sourceType).toBe('ai-netserver');
|
|
expect(adapter.portals).toEqual(['tender24', 'lhs-vpbw', 'vergabe.landbw']);
|
|
});
|
|
|
|
describe('parseSearchResults (pure, fixture-driven)', () => {
|
|
it('parses all rows of the fixture into RawTenderRecord[], sourcePortal set per row', () => {
|
|
const adapter = new NetServerAdapter();
|
|
const fetchedAt = new Date();
|
|
|
|
const records = adapter.parseSearchResults(
|
|
FIXTURE_HTML,
|
|
'tender24',
|
|
fetchedAt,
|
|
);
|
|
|
|
expect(records.length).toBeGreaterThanOrEqual(1);
|
|
expect(records).toHaveLength(FIXTURE_ROW_COUNT);
|
|
for (const record of records) {
|
|
expect(record.sourceType).toBe('ai-netserver');
|
|
expect(record.sourcePortal).toBe('tender24');
|
|
expect(record.sourceNoticeId).toMatch(/^54321-NetTender-/);
|
|
expect(record.sourceUrl).toBe(
|
|
'https://www.tender24.de/NetServer/PublicationSearchControllerServlet?function=SearchPublications',
|
|
);
|
|
expect(record.fetchedAt).toBe(fetchedAt);
|
|
}
|
|
});
|
|
|
|
it('decodes an umlaut + &-entity buyer name correctly (Sterr-Kölln & Partner)', () => {
|
|
const adapter = new NetServerAdapter();
|
|
const records = adapter.parseSearchResults(
|
|
FIXTURE_HTML,
|
|
'tender24',
|
|
new Date(),
|
|
);
|
|
|
|
const row = records.find(
|
|
(r) => r.sourceNoticeId === '54321-NetTender-19f800ce3b1-36b275763103203a',
|
|
);
|
|
expect(row).toBeDefined();
|
|
const payload = row!.ocdsPayload as { buyerName: string | null };
|
|
expect(payload.buyerName).toBe('Sterr-Kölln & Partner');
|
|
});
|
|
|
|
it('uses the row data-oid attribute as sourceNoticeId', () => {
|
|
const adapter = new NetServerAdapter();
|
|
const records = adapter.parseSearchResults(
|
|
FIXTURE_HTML,
|
|
'lhs-vpbw',
|
|
new Date(),
|
|
);
|
|
|
|
const ids = records.map((r) => r.sourceNoticeId);
|
|
expect(ids).toContain('54321-NetTender-19f800ce3b1-36b275763103203a');
|
|
expect(ids).toContain('54321-NetTender-19e21c643b8-4268f33140e63c36');
|
|
// every id is unique — no accidental row-collapsing
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it('maps a populated deadline row to a Date and an empty-deadline row to null (never throws)', () => {
|
|
const adapter = new NetServerAdapter();
|
|
const records = adapter.parseSearchResults(
|
|
FIXTURE_HTML,
|
|
'tender24',
|
|
new Date(),
|
|
);
|
|
|
|
const withDeadline = records.find(
|
|
(r) => r.sourceNoticeId === '54321-NetTender-19f800ce3b1-36b275763103203a',
|
|
);
|
|
const withoutDeadline = records.find(
|
|
(r) => r.sourceNoticeId === '54321-NetTender-19e21c643b8-4268f33140e63c36',
|
|
);
|
|
expect(withDeadline).toBeDefined();
|
|
expect(withoutDeadline).toBeDefined();
|
|
|
|
const withDeadlinePayload = withDeadline!.ocdsPayload as {
|
|
deadlineAt: string | null;
|
|
};
|
|
const withoutDeadlinePayload = withoutDeadline!.ocdsPayload as {
|
|
deadlineAt: string | null;
|
|
};
|
|
// "20.08.2026 24:00" -> normalized to 2026-08-21T00:00Z (NetServer's 24:00 = next-day midnight)
|
|
expect(withDeadlinePayload.deadlineAt).toBe('2026-08-21T00:00:00.000Z');
|
|
expect(withoutDeadlinePayload.deadlineAt).toBeNull();
|
|
});
|
|
|
|
it('returns [] for empty HTML instead of throwing', () => {
|
|
const adapter = new NetServerAdapter();
|
|
expect(adapter.parseSearchResults('', 'tender24', new Date())).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('returns [] for HTML with no matching result rows instead of throwing', () => {
|
|
const adapter = new NetServerAdapter();
|
|
const broken = '<html><body><p>Keine Treffer</p></body></html>';
|
|
expect(
|
|
adapter.parseSearchResults(broken, 'tender24', new Date()),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('skips a row missing data-oid without throwing or affecting other rows', () => {
|
|
const adapter = new NetServerAdapter();
|
|
const html = `
|
|
<table>
|
|
<tr class="tableRow publicationDetail">
|
|
<td>22.07.2026</td>
|
|
<td class="tender">Ohne OID</td>
|
|
<td class="tenderAuthority">Test-Vergabestelle</td>
|
|
<td class="tenderType">Offenes Verfahren</td>
|
|
<td class="tenderType">VOB</td>
|
|
<td class="tenderDeadline"></td>
|
|
</tr>
|
|
<tr class="tableRow publicationDetail" data-oid="test-oid-1">
|
|
<td>22.07.2026</td>
|
|
<td class="tender">Mit OID</td>
|
|
<td class="tenderAuthority">Test-Vergabestelle 2</td>
|
|
<td class="tenderType">Offenes Verfahren</td>
|
|
<td class="tenderType">VOB</td>
|
|
<td class="tenderDeadline"></td>
|
|
</tr>
|
|
</table>
|
|
`;
|
|
const records = adapter.parseSearchResults(html, 'tender24', new Date());
|
|
expect(records).toHaveLength(1);
|
|
expect(records[0]?.sourceNoticeId).toBe('test-oid-1');
|
|
});
|
|
});
|
|
|
|
describe('fetchTenders (portal fan-out, fetch mocked)', () => {
|
|
it('fetches all 3 portals and aggregates their parsed records', async () => {
|
|
stubFetchWithHtml(FIXTURE_HTML);
|
|
const adapter = new NetServerAdapter();
|
|
|
|
const records = await adapter.fetchTenders('2026-07-23');
|
|
|
|
expect(records.length).toBe(FIXTURE_ROW_COUNT * 3);
|
|
const portals = new Set(records.map((r) => r.sourcePortal));
|
|
expect(portals).toEqual(new Set(['tender24', 'lhs-vpbw', 'vergabe.landbw']));
|
|
});
|
|
|
|
it('returns [] for a portal whose fetch throws, without aborting the other portals (D-01)', async () => {
|
|
const adapter = new NetServerAdapter();
|
|
let callCount = 0;
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => {
|
|
callCount += 1;
|
|
if (callCount === 1) throw new Error('portal 1 unreachable');
|
|
return { ok: true, status: 200, text: async () => FIXTURE_HTML } as Response;
|
|
}),
|
|
);
|
|
|
|
const records = await adapter.fetchTenders('2026-07-23');
|
|
|
|
// first portal fails -> contributes 0 records; the other two succeed.
|
|
expect(records.length).toBe(FIXTURE_ROW_COUNT * 2);
|
|
});
|
|
|
|
it('returns [] for every portal when fetch always throws, without propagating (D-01 total-failure fallback)', async () => {
|
|
stubFetchThrowing();
|
|
const adapter = new NetServerAdapter();
|
|
|
|
const records = await adapter.fetchTenders('2026-07-23');
|
|
|
|
expect(records).toEqual([]);
|
|
});
|
|
|
|
it('returns [] without throwing when a portal responds non-2xx', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => {
|
|
return { ok: false, status: 503 } as Response;
|
|
}),
|
|
);
|
|
const adapter = new NetServerAdapter();
|
|
|
|
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, 'netserver.adapter.ts'),
|
|
'utf8',
|
|
);
|
|
expect(source).not.toMatch(/from ['"]axios['"]/);
|
|
});
|
|
|
|
it('never interpolates a portal base URL from a variable/parameter (SSRF guard, T-13-04-01)', () => {
|
|
const source = readFileSync(
|
|
join(__dirname, 'netserver.adapter.ts'),
|
|
'utf8',
|
|
);
|
|
// NETSERVER_PORTALS values must be string literals, not template
|
|
// expressions referencing an external input.
|
|
expect(source).toMatch(/baseUrl: 'https:\/\/www\.tender24\.de'/);
|
|
expect(source).toMatch(/baseUrl: 'https:\/\/lhs-vpbw\.vmstart\.de'/);
|
|
expect(source).toMatch(/baseUrl: 'https:\/\/vergabe\.landbw\.de'/);
|
|
});
|
|
});
|