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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user