test(11-06): add failing spec for SavedSearchBar (FILTER-06)

RED phase — serialization round-trip contract (URL searchParams <-> filters
JSON, page/tender excluded), save/load/rename/delete flows. Component does
not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 16:50:48 +02:00
parent 38fbf35c75
commit ca843ab134
@@ -0,0 +1,211 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
/**
* SavedSearchBar.test — RED-first (TDD) proof for Task 3 (FILTER-06, D-08/
* D-11). Mirrors the ResultsList.test.tsx pattern: mock the api client
* module, render, assert via waitFor/fireEvent. next/navigation is mocked
* because SavedSearchBar reads the current URL searchParams (to serialize
* "aktuelle Filter speichern") and writes them (to load a saved profile).
*
* Key contract under test (11-06-PLAN.md key_links): the filters object
* sent to createSavedSearch/read back from a profile MUST round-trip
* through the exact same URL param names FilterPanel/ResultsList use — q,
* plz, bundesland, region, cpv (multi-value), deadlineFrom/To, openOnly,
* valueMin/Max, includeNullValue, sort, favOnly.
*/
const mockListSavedSearches = vi.fn();
const mockCreateSavedSearch = vi.fn();
const mockUpdateSavedSearch = vi.fn();
const mockDeleteSavedSearch = vi.fn();
vi.mock('@/lib/tender-radar-api', () => ({
listSavedSearches: (...args: unknown[]) => mockListSavedSearches(...args),
createSavedSearch: (...args: unknown[]) => mockCreateSavedSearch(...args),
updateSavedSearch: (...args: unknown[]) => mockUpdateSavedSearch(...args),
deleteSavedSearch: (...args: unknown[]) => mockDeleteSavedSearch(...args),
}));
const mockReplace = vi.fn();
let mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => mockSearchParams,
}));
afterEach(() => {
cleanup();
mockListSavedSearches.mockReset();
mockCreateSavedSearch.mockReset();
mockUpdateSavedSearch.mockReset();
mockDeleteSavedSearch.mockReset();
mockReplace.mockReset();
mockSearchParams = new URLSearchParams();
vi.restoreAllMocks();
});
describe('SavedSearchBar', () => {
it('fetches on mount and renders each saved profile by name', async () => {
mockListSavedSearches.mockResolvedValue([
{ id: 'ss-1', name: 'Bau NRW', filters: { q: 'Bau' }, createdAt: '', updatedAt: '' },
]);
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
await waitFor(() => {
expect(screen.getByText('Bau NRW')).toBeInTheDocument();
});
expect(mockListSavedSearches).toHaveBeenCalledTimes(1);
});
it('renders an empty-state message when no profiles are saved', async () => {
mockListSavedSearches.mockResolvedValue([]);
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
await waitFor(() => {
expect(screen.getByText(/Noch keine Suchprofile gespeichert/i)).toBeInTheDocument();
});
});
it('"aktuelle Filter speichern" serializes the current URL searchParams into filters and calls createSavedSearch', async () => {
mockSearchParams = new URLSearchParams(
'q=Bau&plz=10&bundesland=Berlin&deadlineFrom=2026-08-01&deadlineTo=2026-09-01&openOnly=false&valueMin=1000&valueMax=50000&includeNullValue=false&sort=deadline&favOnly=true&cpv=45&cpv=71&page=3&tender=abc',
);
mockListSavedSearches.mockResolvedValue([]);
mockCreateSavedSearch.mockResolvedValue({
id: 'ss-new',
name: 'Mein Profil',
filters: {},
createdAt: '',
updatedAt: '',
});
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalled());
const input = screen.getByPlaceholderText('Name des Suchprofils…');
fireEvent.change(input, { target: { value: 'Mein Profil' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockCreateSavedSearch).toHaveBeenCalledTimes(1));
const [payload] = mockCreateSavedSearch.mock.calls[0];
expect(payload.name).toBe('Mein Profil');
// Filter keys are preserved verbatim (URL-param name === filters key).
expect(payload.filters).toMatchObject({
q: 'Bau',
plz: '10',
bundesland: 'Berlin',
deadlineFrom: '2026-08-01',
deadlineTo: '2026-09-01',
openOnly: 'false',
valueMin: '1000',
valueMax: '50000',
includeNullValue: 'false',
sort: 'deadline',
favOnly: 'true',
cpv: ['45', '71'],
});
// Navigation-only params (page, tender) are never persisted into a
// saved search profile — they are view state, not filter state.
expect(payload.filters).not.toHaveProperty('page');
expect(payload.filters).not.toHaveProperty('tender');
});
it('does not call createSavedSearch when the name field is empty', async () => {
mockListSavedSearches.mockResolvedValue([]);
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(mockCreateSavedSearch).not.toHaveBeenCalled();
});
it('clicking a saved profile writes its filters back into the URL via router.replace (round-trip contract)', async () => {
mockListSavedSearches.mockResolvedValue([
{
id: 'ss-1',
name: 'Bau NRW',
filters: { q: 'Bau', cpv: ['45', '71'], openOnly: 'false' },
createdAt: '',
updatedAt: '',
},
]);
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
const profileButton = await screen.findByRole('button', { name: 'Bau NRW' });
fireEvent.click(profileButton);
expect(mockReplace).toHaveBeenCalledTimes(1);
const [target] = mockReplace.mock.calls[0];
const params = new URLSearchParams(String(target).replace(/^\?/, ''));
expect(params.get('q')).toBe('Bau');
expect(params.get('openOnly')).toBe('false');
expect(params.getAll('cpv')).toEqual(['45', '71']);
});
it('renaming a profile calls updateSavedSearch(id, { name }) and reloads the list', async () => {
mockListSavedSearches
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Alt', filters: {}, createdAt: '', updatedAt: '' },
])
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Neu', filters: {}, createdAt: '', updatedAt: '' },
]);
mockUpdateSavedSearch.mockResolvedValue({
id: 'ss-1',
name: 'Neu',
filters: {},
createdAt: '',
updatedAt: '',
});
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
await screen.findByRole('button', { name: 'Alt' });
fireEvent.click(screen.getByRole('button', { name: 'Alt umbenennen' }));
const renameInput = screen.getByDisplayValue('Alt');
fireEvent.change(renameInput, { target: { value: 'Neu' } });
fireEvent.click(screen.getByRole('button', { name: 'Alt umbenennen bestätigen' }));
await waitFor(() => {
expect(mockUpdateSavedSearch).toHaveBeenCalledWith('ss-1', { name: 'Neu' });
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Neu' })).toBeInTheDocument();
});
});
it('deleting a profile calls deleteSavedSearch(id) and reloads the list', async () => {
mockListSavedSearches
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Weg damit', filters: {}, createdAt: '', updatedAt: '' },
])
.mockResolvedValueOnce([]);
mockDeleteSavedSearch.mockResolvedValue(undefined);
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
await screen.findByRole('button', { name: 'Weg damit' });
fireEvent.click(screen.getByRole('button', { name: 'Weg damit löschen' }));
await waitFor(() => expect(mockDeleteSavedSearch).toHaveBeenCalledWith('ss-1'));
await waitFor(() => {
expect(screen.getByText(/Noch keine Suchprofile gespeichert/i)).toBeInTheDocument();
});
});
});