From ca843ab134cefede8e034643251f9bafd1c19015 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 16:50:48 +0200 Subject: [PATCH] test(11-06): add failing spec for SavedSearchBar (FILTER-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/SavedSearchBar.test.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx new file mode 100644 index 0000000..0e95175 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); +});