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