test(11-01): add failing ResultsList spec + extend tender-radar-api client (RED)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(<ResultsList />);
|
||||||
|
|
||||||
|
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(<ResultsList />);
|
||||||
|
|
||||||
|
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(<ResultsList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Keine Ausschreibungen gefunden/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,44 @@ export interface SaveSourceConfigPayload {
|
|||||||
isActive: boolean;
|
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 ---
|
// --- API functions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,3 +103,36 @@ export async function saveSourceConfig(
|
|||||||
if (!res.ok) throw new Error('Failed to save tender-radar source config');
|
if (!res.ok) throw new Error('Failed to save tender-radar source config');
|
||||||
return res.json();
|
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?<params>
|
||||||
|
*/
|
||||||
|
export async function listTenders(
|
||||||
|
params: URLSearchParams,
|
||||||
|
): Promise<ListTendersResponse> {
|
||||||
|
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<CoverageResponse> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user