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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user