feat(14-04): CoverageBanner "manuell beobachten" denylist block

- Add DenylistedPortal type + fetchDenylistedPortals() to
  tender-radar-api.ts, following the existing credentials:'include' fetch
  convention
- CoverageBanner fetches the denylisted-portals endpoint on mount and
  renders vergabe24/aumass with direct links (rel="noopener noreferrer",
  target="_blank"); block renders independently of the onlyDoe coverage
  note and fails silently on fetch error
- Add CoverageBanner.test.tsx asserting both portal hrefs, independence
  from the coverage note, and fail-silent behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:59:44 +02:00
parent 28c6c7fee1
commit 947325f6ad
3 changed files with 196 additions and 8 deletions
@@ -0,0 +1,113 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* CoverageBanner.test — Plan 14-04 (UI-06/D-12): proves the denylist
* "manuell beobachten" block renders both AGB-prohibited portals
* (vergabe24, aumass) with working direct links, and that the block is
* independent of the existing Oberschwelle/Unterschwelle coverage note
* (renders even when `onlyDoe` is false, and fails silently on fetch
* error — mirrors the existing fetch-on-mount fail-silent convention).
*/
const mockFetchCoverage = vi.fn();
const mockFetchDenylistedPortals = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
fetchCoverage: (...args: unknown[]) => mockFetchCoverage(...args),
fetchDenylistedPortals: (...args: unknown[]) => mockFetchDenylistedPortals(...args),
}));
afterEach(() => {
cleanup();
mockFetchCoverage.mockReset();
mockFetchDenylistedPortals.mockReset();
vi.restoreAllMocks();
});
describe('CoverageBanner — denylisted-portals block (Plan 14-04, UI-06/D-12)', () => {
it('renders both denylisted portals with hrefs to their canonical URLs when the fetch resolves', async () => {
mockFetchCoverage.mockResolvedValue({
total: 1,
sources: [{ sourcePortal: 'doe-opendata', count: 1 }],
});
mockFetchDenylistedPortals.mockResolvedValue({
portals: [
{ portal: 'vergabe24', url: 'https://www.vergabe24.de' },
{ portal: 'aumass', url: 'https://www.aumass.de' },
],
});
const { CoverageBanner } = await import('./CoverageBanner');
render(<CoverageBanner />);
await waitFor(() => {
expect(screen.getByText('vergabe24')).toBeInTheDocument();
});
const vergabe24Link = screen.getByRole('link', { name: 'vergabe24' });
const aumassLink = screen.getByRole('link', { name: 'aumass' });
expect(vergabe24Link).toHaveAttribute('href', 'https://www.vergabe24.de');
expect(aumassLink).toHaveAttribute('href', 'https://www.aumass.de');
expect(vergabe24Link).toHaveAttribute('target', '_blank');
expect(vergabe24Link).toHaveAttribute('rel', 'noopener noreferrer');
expect(aumassLink).toHaveAttribute('target', '_blank');
expect(aumassLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('renders the denylist block even when the coverage note is suppressed (onlyDoe is false)', async () => {
mockFetchCoverage.mockResolvedValue({
total: 2,
sources: [
{ sourcePortal: 'doe-opendata', count: 1 },
{ sourcePortal: 'cosinex-dtvp', count: 1 },
],
});
mockFetchDenylistedPortals.mockResolvedValue({
portals: [
{ portal: 'vergabe24', url: 'https://www.vergabe24.de' },
{ portal: 'aumass', url: 'https://www.aumass.de' },
],
});
const { CoverageBanner } = await import('./CoverageBanner');
render(<CoverageBanner />);
await waitFor(() => {
expect(screen.getByText('vergabe24')).toBeInTheDocument();
});
expect(screen.getByText('aumass')).toBeInTheDocument();
expect(screen.queryByText(/Datenabdeckung:/)).not.toBeInTheDocument();
});
it('fails silently (renders nothing) when the denylisted-portals fetch rejects', async () => {
mockFetchCoverage.mockResolvedValue({
total: 1,
sources: [{ sourcePortal: 'doe-opendata', count: 1 }],
});
mockFetchDenylistedPortals.mockRejectedValue(new Error('network error'));
const { CoverageBanner } = await import('./CoverageBanner');
const { container } = render(<CoverageBanner />);
await waitFor(() => {
expect(screen.getByText(/Datenabdeckung:/)).toBeInTheDocument();
});
expect(screen.queryByText(/Manuell beobachten:/)).not.toBeInTheDocument();
expect(container).not.toBeEmptyDOMElement();
});
it('renders nothing when both fetches fail', async () => {
mockFetchCoverage.mockRejectedValue(new Error('network error'));
mockFetchDenylistedPortals.mockRejectedValue(new Error('network error'));
const { CoverageBanner } = await import('./CoverageBanner');
const { container } = render(<CoverageBanner />);
await waitFor(() => {
expect(mockFetchCoverage).toHaveBeenCalledTimes(1);
expect(mockFetchDenylistedPortals).toHaveBeenCalledTimes(1);
});
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,7 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { fetchCoverage, type CoverageResponse } from '@/lib/tender-radar-api';
import {
fetchCoverage,
fetchDenylistedPortals,
type CoverageResponse,
type DenylistedPortal,
} from '@/lib/tender-radar-api';
/**
* CoverageBanner (D-12, UI-05) — explains data coverage (Oberschwelle vs.
@@ -13,9 +18,18 @@ import { fetchCoverage, type CoverageResponse } from '@/lib/tender-radar-api';
*
* Fails silently (renders nothing) on fetch error — the banner is a
* courtesy explanation, not a load-bearing part of the page.
*
* Plan 14-04 (UI-06/D-12): also renders a "manuell beobachten" block
* listing the AGB-denylisted portals (vergabe24, aumass) with a direct
* link each, so they read as an explicit, actionable hint instead of a
* silent coverage gap. This block is independent of the Oberschwelle/
* Unterschwelle coverage note above — it is not gated behind `onlyDoe`,
* since the manual-watch hint stays relevant regardless of how many
* public sources are ingested.
*/
export function CoverageBanner() {
const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
const [denylistedPortals, setDenylistedPortals] = useState<DenylistedPortal[]>([]);
useEffect(() => {
fetchCoverage()
@@ -23,17 +37,49 @@ export function CoverageBanner() {
.catch(() => setCoverage(null));
}, []);
if (!coverage) return null;
useEffect(() => {
fetchDenylistedPortals()
.then((res) => setDenylistedPortals(res.portals))
.catch(() => setDenylistedPortals([]));
}, []);
const onlyDoe = coverage.sources.every((s) => s.sourcePortal === 'doe-opendata');
if (!onlyDoe) return null;
const onlyDoe =
!!coverage && coverage.sources.every((s) => s.sourcePortal === 'doe-opendata');
const showCoverageNote = !!coverage && onlyDoe;
if (!showCoverageNote && denylistedPortals.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<strong className="text-foreground">Datenabdeckung:</strong> Aktuell
werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen
DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen
fehlen noch — eine kurze Trefferliste ist daher kein Fehler.
{showCoverageNote && (
<p>
<strong className="text-foreground">Datenabdeckung:</strong> Aktuell
werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen
DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen
fehlen noch — eine kurze Trefferliste ist daher kein Fehler.
</p>
)}
{denylistedPortals.length > 0 && (
<p className={showCoverageNote ? 'mt-2' : undefined}>
<strong className="text-foreground">Manuell beobachten:</strong>{' '}
Diese Portale lassen laut ihren Nutzungsbedingungen keinen
automatisierten Zugriff zu und müssen daher manuell geprüft werden:{' '}
{denylistedPortals.map((p, i) => (
<span key={p.portal}>
{i > 0 && ', '}
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
{p.portal}
</a>
</span>
))}
.
</p>
)}
</div>
);
}
+29
View File
@@ -83,6 +83,21 @@ export interface CoverageResponse {
sources: Array<{ sourcePortal: string; count: number }>;
}
/**
* A single AGB-denylisted portal (UI-06/D-12) — vergabe24/aumass must be
* watched manually. `url` is the canonical direct-link, sourced server-side
* from `PORTAL_URLS` (source-registry.ts), never hardcoded here.
*/
export interface DenylistedPortal {
portal: string;
url: string;
}
/** Response shape of GET /modules/tender-radar/denylisted-portals (UI-06/D-12). */
export interface DenylistedPortalsResponse {
portals: DenylistedPortal[];
}
// --- API functions ---
/**
@@ -149,6 +164,20 @@ export async function fetchCoverage(): Promise<CoverageResponse> {
return res.json();
}
/**
* Fetch the AGB-denylisted portals (vergabe24, aumass) with their canonical
* direct-link URLs (UI-06/D-12) — feeds CoverageBanner's "manuell
* beobachten" block.
* GET /modules/tender-radar/denylisted-portals
*/
export async function fetchDenylistedPortals(): Promise<DenylistedPortalsResponse> {
const res = await fetch(`${API_URL}/modules/tender-radar/denylisted-portals`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch denylisted portals');
return res.json();
}
/**
* Fetch a single tender's full detail (Plan 11-04, UI-02/D-07) for the
* `?tender=<id>` in-component detail view. `rawPayload` is 100% NULL in