diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx new file mode 100644 index 0000000..83f890a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(mockFetchCoverage).toHaveBeenCalledTimes(1); + expect(mockFetchDenylistedPortals).toHaveBeenCalledTimes(1); + }); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx index dae9ff4..d0c4672 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx @@ -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(null); + const [denylistedPortals, setDenylistedPortals] = useState([]); 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 (
- Datenabdeckung: 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 && ( +

+ Datenabdeckung: 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. +

+ )} + {denylistedPortals.length > 0 && ( +

+ Manuell beobachten:{' '} + Diese Portale lassen laut ihren Nutzungsbedingungen keinen + automatisierten Zugriff zu und müssen daher manuell geprüft werden:{' '} + {denylistedPortals.map((p, i) => ( + + {i > 0 && ', '} + + {p.portal} + + + ))} + . +

+ )}
); } diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index 8237960..3730803 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -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 { 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 { + 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=` in-component detail view. `rawPayload` is 100% NULL in