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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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.
|
* 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
|
* Fails silently (renders nothing) on fetch error — the banner is a
|
||||||
* courtesy explanation, not a load-bearing part of the page.
|
* 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() {
|
export function CoverageBanner() {
|
||||||
const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
|
const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
|
||||||
|
const [denylistedPortals, setDenylistedPortals] = useState<DenylistedPortal[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCoverage()
|
fetchCoverage()
|
||||||
@@ -23,17 +37,49 @@ export function CoverageBanner() {
|
|||||||
.catch(() => setCoverage(null));
|
.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');
|
const onlyDoe =
|
||||||
if (!onlyDoe) return null;
|
!!coverage && coverage.sources.every((s) => s.sourcePortal === 'doe-opendata');
|
||||||
|
const showCoverageNote = !!coverage && onlyDoe;
|
||||||
|
|
||||||
|
if (!showCoverageNote && denylistedPortals.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{showCoverageNote && (
|
||||||
|
<p>
|
||||||
<strong className="text-foreground">Datenabdeckung:</strong> Aktuell
|
<strong className="text-foreground">Datenabdeckung:</strong> Aktuell
|
||||||
werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen
|
werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen
|
||||||
DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen
|
DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen
|
||||||
fehlen noch — eine kurze Trefferliste ist daher kein Fehler.
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ export interface CoverageResponse {
|
|||||||
sources: Array<{ sourcePortal: string; count: number }>;
|
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 ---
|
// --- API functions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +164,20 @@ export async function fetchCoverage(): Promise<CoverageResponse> {
|
|||||||
return res.json();
|
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
|
* 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
|
* `?tender=<id>` in-component detail view. `rawPayload` is 100% NULL in
|
||||||
|
|||||||
Reference in New Issue
Block a user