From b55bb55c0e47570b707f649875e3fe9c78812621 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 15:53:04 +0200 Subject: [PATCH] test(11-01): add failing ResultsList spec + extend tender-radar-api client (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends tender-radar-api.ts with the Tender type, listTenders(params) and fetchCoverage() (plain fetch, credentials:'include', matching the established fetchSourceConfig pattern — TanStack Query is not installed). Adds the RED-first ResultsList.test.tsx: render items with title/buyerName/deadline/value, "keine Wertangabe" for null estimatedValue (D-05), and an empty-state message. ResultsList.tsx does not exist yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ResultsList.test.tsx | 97 +++++++++++++++++++ apps/web/src/lib/tender-radar-api.ts | 71 ++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx new file mode 100644 index 0000000..fca6e30 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx @@ -0,0 +1,97 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * ResultsList.test — RED-first (TDD) proof for Task 3 (FILTER-01, UI-01/05). + * Mirrors the SourceConfigForm.test.tsx pattern: mock the api client + * module, render, assert via waitFor. next/navigation is mocked because + * ResultsList reads/writes URL searchParams for filter + sort state + * (Research: URL-Param-getriebener Client). + */ + +const mockListTenders = vi.fn(); + +vi.mock('@/lib/tender-radar-api', () => ({ + listTenders: (...args: unknown[]) => mockListTenders(...args), +})); + +const mockReplace = vi.fn(); +let mockSearchParams = new URLSearchParams(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => mockSearchParams, +})); + +afterEach(() => { + cleanup(); + mockListTenders.mockReset(); + mockReplace.mockReset(); + mockSearchParams = new URLSearchParams(); + vi.restoreAllMocks(); +}); + +describe('ResultsList', () => { + it('fetches on mount and renders title, buyerName, deadline and value from listTenders items', async () => { + mockListTenders.mockResolvedValue({ + items: [ + { + id: 't1', + title: 'Sanierung Rathaus', + buyerName: 'Stadt Musterhausen', + deadlineAt: '2026-09-01T00:00:00.000Z', + estimatedValue: '150000.00', + publishedAt: '2026-07-01T00:00:00.000Z', + }, + ], + total: 1, + page: 1, + limit: 20, + }); + + const { ResultsList } = await import('./ResultsList'); + render(); + + await waitFor(() => { + expect(screen.getByText('Sanierung Rathaus')).toBeInTheDocument(); + }); + expect(screen.getByText('Stadt Musterhausen')).toBeInTheDocument(); + expect(mockListTenders).toHaveBeenCalledTimes(1); + }); + + it('shows "keine Wertangabe" for a row with estimatedValue null (D-05 graceful handling)', async () => { + mockListTenders.mockResolvedValue({ + items: [ + { + id: 't2', + title: 'Straßenbau Projekt X', + buyerName: 'Landkreis Beispiel', + deadlineAt: null, + estimatedValue: null, + publishedAt: '2026-07-01T00:00:00.000Z', + }, + ], + total: 1, + page: 1, + limit: 20, + }); + + const { ResultsList } = await import('./ResultsList'); + render(); + + await waitFor(() => { + expect(screen.getByText('keine Wertangabe')).toBeInTheDocument(); + }); + }); + + it('renders an empty-state message when listTenders returns no items', async () => { + mockListTenders.mockResolvedValue({ items: [], total: 0, page: 1, limit: 20 }); + + const { ResultsList } = await import('./ResultsList'); + render(); + + await waitFor(() => { + expect(screen.getByText(/Keine Ausschreibungen gefunden/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index 03a5d92..e4b8815 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -33,6 +33,44 @@ export interface SaveSourceConfigPayload { isActive: boolean; } +/** + * A single tender row from the global `Tender` catalog (Plan 11-01). + * `estimatedValue` arrives as a string (Prisma Decimal serializes via + * `toJSON()` -> `toString()`), NOT a number — never `Number()`-coerce it + * without a null check (D-05: 91.6% of rows are null, "keine Wertangabe"). + * Date fields arrive as ISO strings (JSON has no native Date type). + */ +export interface Tender { + id: string; + sourcePortal: string; + title: string; + buyerName: string | null; + cpvCodes: string[]; + region: string | null; + plz: string | null; + bundesland: string | null; + deadlineAt: string | null; + estimatedValue: string | null; + procedureType: string | null; + status: string; + sourceUrl: string | null; + publishedAt: string; +} + +/** Response shape of GET /modules/tender-radar. */ +export interface ListTendersResponse { + items: Tender[]; + total: number; + page: number; + limit: number; +} + +/** Response shape of GET /modules/tender-radar/coverage (D-12, UI-05). */ +export interface CoverageResponse { + total: number; + sources: Array<{ sourcePortal: string; count: number }>; +} + // --- API functions --- /** @@ -65,3 +103,36 @@ export async function saveSourceConfig( if (!res.ok) throw new Error('Failed to save tender-radar source config'); return res.json(); } + +/** + * List the global tender catalog with the given filter/sort/pagination + * params (Plan 11-01, FILTER-01/04/05, UI-01). `params` mirrors the + * TenderQueryDto query-string contract exactly — the URL is the single + * source of truth for filter state (Research: URL-Param-getriebener Client). + * GET /modules/tender-radar? + */ +export async function listTenders( + params: URLSearchParams, +): Promise { + const query = params.toString(); + const res = await fetch( + `${API_URL}/modules/tender-radar${query ? `?${query}` : ''}`, + { credentials: 'include' }, + ); + if (!res.ok) throw new Error('Failed to fetch tenders'); + return res.json(); +} + +/** + * Fetch the active-tender distribution by sourcePortal (D-12, UI-05) — + * feeds CoverageBanner so a thin/single-source result list is not + * misread as a defect. + * GET /modules/tender-radar/coverage + */ +export async function fetchCoverage(): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/coverage`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch tender-radar coverage'); + return res.json(); +}