diff --git a/frontend/__tests__/unit/components/MultiSearch.test.tsx b/frontend/__tests__/unit/components/MultiSearch.test.tsx
new file mode 100644
index 0000000000..2c5e29ff9b
--- /dev/null
+++ b/frontend/__tests__/unit/components/MultiSearch.test.tsx
@@ -0,0 +1,760 @@
+import { sendGAEvent } from '@next/third-parties/google'
+import { screen, render, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { useRouter } from 'next/navigation'
+import { fetchAlgoliaData } from 'server/fetchAlgoliaData'
+import { Chapter } from 'types/chapter'
+import { Event } from 'types/event'
+import { Organization } from 'types/organization'
+import { Project } from 'types/project'
+import { User } from 'types/user'
+import MultiSearchBar from 'components/MultiSearch'
+
+jest.mock('next/navigation', () => ({
+ useRouter: jest.fn(),
+}))
+
+jest.mock('@next/third-parties/google', () => ({
+ sendGAEvent: jest.fn(),
+}))
+
+jest.mock('server/fetchAlgoliaData', () => ({
+ fetchAlgoliaData: jest.fn(),
+}))
+
+jest.mock('lodash', () => ({
+ debounce: jest.fn((fn: (...args: unknown[]) => unknown) => {
+ const debouncedFn = (...args: unknown[]) => fn(...args)
+ debouncedFn.cancel = jest.fn()
+ return debouncedFn
+ }),
+}))
+
+// Mock FontAwesome
+jest.mock('@fortawesome/react-fontawesome', () => ({
+ FontAwesomeIcon: ({ icon, className }: { icon: { iconName: string }; className?: string }) => (
+
+ ),
+}))
+
+// Mock window.open globally
+const mockWindowOpen = jest.fn()
+Object.defineProperty(window, 'open', {
+ value: mockWindowOpen,
+ writable: true,
+})
+
+const mockPush = jest.fn()
+const mockFetchAlgoliaData = fetchAlgoliaData as jest.MockedFunction
+const mockSendGAEvent = sendGAEvent as jest.MockedFunction
+const mockUseRouter = useRouter as jest.MockedFunction
+
+// Sample test data
+const mockChapter: Chapter = {
+ key: 'test-chapter',
+ name: 'Test Chapter',
+} as Chapter
+
+const mockEvent: Event = {
+ name: 'Test Event',
+ url: 'https://example.com/event',
+} as Event
+
+const mockUser: User = {
+ key: 'test-user',
+ name: 'Test User',
+} as User
+
+const mockProject: Project = {
+ key: 'test-project',
+ name: 'Test Project',
+} as Project
+
+const mockOrganization: Organization = {
+ login: 'test-org',
+ name: 'Test Organization',
+} as Organization
+
+const defaultProps = {
+ isLoaded: true,
+ placeholder: 'Search...',
+ indexes: ['chapters', 'users', 'projects'],
+ initialValue: '',
+ eventData: [],
+}
+
+beforeEach(() => {
+ mockUseRouter.mockReturnValue({
+ push: mockPush,
+ replace: jest.fn(),
+ back: jest.fn(),
+ forward: jest.fn(),
+ refresh: jest.fn(),
+ prefetch: jest.fn(),
+ })
+
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [],
+ totalPages: 0,
+ })
+})
+
+afterEach(() => {
+ jest.clearAllMocks()
+})
+
+describe('Rendering', () => {
+ it('renders successfully with minimal required props', () => {
+ render()
+
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
+ expect(screen.getByTestId('font-awesome-icon')).toBeInTheDocument()
+ })
+
+ it('renders loading state when not loaded', () => {
+ render()
+
+ const loadingSkeleton = document.querySelector(
+ '.animate-pulse.h-12.w-full.rounded-lg.bg-gray-200'
+ )
+ expect(loadingSkeleton).toBeInTheDocument()
+ expect(loadingSkeleton).toHaveClass(
+ 'animate-pulse',
+ 'bg-gray-200',
+ 'h-12',
+ 'w-full',
+ 'rounded-lg'
+ )
+ })
+
+ it('renders with initial value', () => {
+ render()
+
+ expect(screen.getByDisplayValue('initial search')).toBeInTheDocument()
+ })
+
+ it('renders custom placeholder', () => {
+ render()
+
+ expect(screen.getByPlaceholderText('Custom Placeholder')).toBeInTheDocument()
+ })
+
+ it('applies correct css classes for input', () => {
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ expect(input).toHaveClass(
+ 'h-12',
+ 'w-full',
+ 'rounded-lg',
+ 'border',
+ 'border-gray-300',
+ 'pl-10',
+ 'pr-10',
+ 'text-lg',
+ 'text-black'
+ )
+ })
+
+ describe('Search input behavior', () => {
+ it('updates search query on input change', async () => {
+ const user = userEvent.setup()
+
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test query')
+
+ expect(input).toHaveValue('test query')
+ })
+
+ it('shows clear button when search query exists', async () => {
+ const user = userEvent.setup()
+
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('clears search when clear button is clicked', async () => {
+ const user = userEvent.setup()
+
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+ const clearButton = screen.getByRole('button')
+ await user.click(clearButton)
+ expect(input).toHaveValue('')
+ })
+
+ it('focuses input on mount', () => {
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ expect(input).toHaveFocus()
+ })
+ })
+
+ describe('Search Functionality', () => {
+ it('calls fetchAlgoliaData when search query is entered', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(mockFetchAlgoliaData).toHaveBeenCalledWith('chapters', 'test', 1, 3)
+ expect(mockFetchAlgoliaData).toHaveBeenCalledWith('users', 'test', 1, 3)
+ expect(mockFetchAlgoliaData).toHaveBeenCalledWith('projects', 'test', 1, 3)
+ })
+ })
+
+ it('sends Google Analytics event on search', async () => {
+ const user = userEvent.setup()
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test query')
+ await waitFor(() => {
+ expect(mockSendGAEvent).toHaveBeenCalledWith({
+ event: 'homepageSearch',
+ path: expect.any(String), // Don't rely on specific window.location.pathname
+ value: 'test query',
+ })
+ })
+ })
+
+ it('does not send GA event for empty queries', async () => {
+ const user = userEvent.setup()
+ render()
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, ' ')
+
+ await waitFor(() => {
+ expect(mockSendGAEvent).not.toHaveBeenCalled()
+ })
+ })
+
+ it('filters event data based on query', async () => {
+ const eventData = [
+ { name: 'JavaScript Conference', url: 'https://example.com/js' },
+ { name: 'Python Workshop', url: 'https://example.com/py' },
+ { name: 'React Meetup', url: 'https://example.com/react' },
+ ] as Event[]
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+
+ // Test filtering for "JavaScript"
+ await user.type(input, 'JavaScript')
+
+ await waitFor(() => {
+ expect(screen.getByText('JavaScript Conference')).toBeInTheDocument()
+ expect(screen.queryByText('Python Workshop')).not.toBeInTheDocument()
+ expect(screen.queryByText('React Meetup')).not.toBeInTheDocument()
+ })
+
+ // Clear and test different filter
+ await user.clear(input)
+ await user.type(input, 'Python')
+
+ await waitFor(() => {
+ expect(screen.getByText('Python Workshop')).toBeInTheDocument()
+ expect(screen.queryByText('JavaScript Conference')).not.toBeInTheDocument()
+ expect(screen.queryByText('React Meetup')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Suggestions Display', () => {
+ beforeEach(() => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockChapter],
+ totalPages: 1,
+ })
+ })
+
+ it('shows suggestions when search results are available', async () => {
+ const user = userEvent.setup()
+
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ const suggestions = screen.getAllByText('Test Chapter')
+ expect(suggestions.length).toBeGreaterThan(0)
+ expect(suggestions[0]).toBeInTheDocument()
+ })
+ })
+
+ it('displays correct icons for different index types', async () => {
+ mockFetchAlgoliaData
+ .mockResolvedValueOnce({ hits: [mockChapter], totalPages: 1 })
+ .mockResolvedValueOnce({ hits: [mockUser], totalPages: 1 })
+ .mockResolvedValueOnce({ hits: [mockProject], totalPages: 1 })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ const icons = screen.getAllByTestId('font-awesome-icon')
+ expect(icons.length).toBeGreaterThan(1)
+ })
+ })
+
+ it('hides suggestions when clicking outside', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ const suggestions = screen.getAllByText('Test Chapter')
+ expect(suggestions.length).toBeGreaterThan(0)
+ suggestions.forEach((suggestion) => {
+ expect(suggestion).toBeInTheDocument()
+ })
+ })
+
+ await user.click(document.body)
+
+ await waitFor(() => {
+ expect(screen.queryAllByText('Test Chapter')).toHaveLength(0)
+ })
+ })
+
+ it('handles input focus and blur correctly', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.click(input)
+ expect(input).toHaveFocus()
+ })
+
+ describe('Keyboard Navigation', () => {
+ beforeEach(() => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockChapter, mockUser],
+ totalPages: 1,
+ })
+ })
+
+ it('highlights first suggestion on arrow down', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+ await waitFor(() => {
+ const suggestionButtons = screen.getAllByRole('button')
+ expect(suggestionButtons.length).toBeGreaterThan(0)
+ })
+ await user.keyboard('{ArrowDown}')
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems[0]).toHaveClass('bg-gray-100')
+ })
+ })
+
+ it('moves highlight down on subsequent arrow down presses', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+ await waitFor(() => {
+ const testChapters = screen.getAllByText('Test Chapter')
+ expect(testChapters.length).toBeGreaterThan(0)
+ })
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{ArrowDown}')
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems[1]).toHaveClass('bg-gray-100')
+ })
+ })
+
+ it('moves highlight up on arrow up', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ const testChapters = screen.getAllByText('Test Chapter')
+ expect(testChapters.length).toBeGreaterThan(0)
+ })
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{ArrowDown}')
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems[1]).toHaveClass('bg-gray-100')
+ })
+ await user.keyboard('{ArrowUp}')
+
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems[0]).toHaveClass('bg-gray-100')
+ })
+ })
+
+ it('closes suggestions on Escape key', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ // Wait for suggestion list items to appear
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems.length).toBeGreaterThan(0)
+ })
+
+ await user.keyboard('{Escape}')
+
+ // Check that no list items remain
+ await waitFor(() => {
+ const listItems = screen.queryAllByRole('listitem')
+ expect(listItems).toHaveLength(0)
+ })
+ })
+
+ it('selects highlighted suggestion on Enter', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems.length).toBeGreaterThan(0)
+ })
+
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{Enter}')
+
+ expect(mockPush).toHaveBeenCalledWith('/chapters/test-chapter')
+ })
+ })
+ })
+ describe('Navigation Handling', () => {
+ it('navigates to chapter page when chapter is clicked', async () => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockChapter],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(screen.getAllByText('Test Chapter')).toHaveLength(3)
+ })
+
+ const chapterElements = screen.getAllByText('Test Chapter')
+ await user.click(chapterElements[0])
+
+ expect(mockPush).toHaveBeenCalledWith('/chapters/test-chapter')
+ })
+
+ it('opens event URL in new tab when event is clicked', async () => {
+ const eventData = [mockEvent]
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'Test Event')
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Event')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByText('Test Event'))
+
+ expect(mockWindowOpen).toHaveBeenCalledWith('https://example.com/event', '_blank')
+ })
+
+ it('navigates to organization page when organization is clicked', async () => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockOrganization],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ // Changed 'organization' to 'organizations' (plural)
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Organization')).toBeInTheDocument()
+ })
+
+ const organizationButton = screen.getByRole('button', { name: /Test Organization/i })
+ await user.click(organizationButton)
+
+ expect(mockPush).toHaveBeenCalledWith('/organizations/test-org')
+ })
+
+ it('navigates to project page when project is clicked', async () => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockProject],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Project')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByText('Test Project'))
+
+ expect(mockPush).toHaveBeenCalledWith('/projects/test-project')
+ })
+
+ it('navigates to user page when user is clicked', async () => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockUser],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(screen.getByText('Test User')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByText('Test User'))
+
+ expect(mockPush).toHaveBeenCalledWith('/members/test-user')
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('handles empty search results gracefully', async () => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [],
+ totalPages: 0,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'nonexistent')
+
+ await waitFor(() => {
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+ })
+ })
+
+ it('handles organization without login property', async () => {
+ const orgWithoutLogin = { name: 'Org Without Login' } as Organization
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [orgWithoutLogin],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(screen.getByText('Org Without Login')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByText('Org Without Login'))
+
+ expect(mockPush).not.toHaveBeenCalled()
+ })
+
+ it('handles items without name property', async () => {
+ const itemWithLogin = { login: 'test-login' } as Organization
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [itemWithLogin],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ expect(screen.getByText('test-login')).toBeInTheDocument()
+ })
+ })
+
+ it('does not send GA events for whitespace-only queries', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, ' ')
+
+ await waitFor(() => {
+ expect(mockFetchAlgoliaData).toHaveBeenCalled()
+ })
+
+ expect(mockSendGAEvent).not.toHaveBeenCalled()
+ })
+
+ it('handles rapid successive searches', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+
+ await user.type(input, 'test1')
+ await user.clear(input)
+ await user.type(input, 'test2')
+ await user.clear(input)
+ await user.type(input, 'test3')
+
+ expect(input).toHaveValue('test3')
+ })
+
+ it('handles empty eventData array', () => {
+ render()
+
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
+ })
+
+ it('handles undefined eventData', () => {
+ render()
+
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('has proper ARIA attributes', () => {
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ expect(input).toHaveAttribute('type', 'text')
+ })
+
+ it('maintains focus management', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.keyboard('{Escape}')
+ expect(input).not.toHaveFocus()
+ })
+
+ it('has keyboard-accessible buttons', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ const clearButton = screen.getByRole('button')
+ expect(clearButton).toBeInTheDocument()
+
+ await user.click(clearButton)
+ expect(input).toHaveValue('')
+ })
+ })
+
+ describe('State Management', () => {
+ it('resets highlighted index when search query changes', async () => {
+ mockFetchAlgoliaData.mockResolvedValue({
+ hits: [mockChapter, mockUser],
+ totalPages: 1,
+ })
+
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ await waitFor(() => {
+ const chapterElements = screen.getAllByText('Test Chapter')
+ expect(chapterElements.length).toBeGreaterThan(0)
+ })
+
+ await user.keyboard('{ArrowDown}')
+
+ await waitFor(() => {
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems[0]).toHaveClass('bg-gray-100')
+ })
+
+ await user.clear(input)
+ await user.type(input, 'new query')
+
+ await waitFor(() => {
+ expect(mockFetchAlgoliaData).toHaveBeenCalledWith('chapters', 'new query', 1, 3)
+ })
+
+ await waitFor(() => {
+ const suggestions = screen.getAllByRole('listitem')
+ expect(suggestions.length).toBeGreaterThan(0)
+ expect(suggestions[0]).not.toHaveClass('bg-gray-100')
+ })
+ })
+
+ it('clears all state when clear button is clicked', async () => {
+ const user = userEvent.setup()
+ render()
+
+ const input = screen.getByPlaceholderText('Search...')
+ await user.type(input, 'test')
+
+ const clearButton = screen.getByRole('button')
+ await user.click(clearButton)
+
+ expect(input).toHaveValue('')
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Component Cleanup', () => {
+ it('cancels debounced search on unmount', () => {
+ const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener')
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { debounce } = require('lodash')
+ const { unmount } = render()
+
+ unmount()
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function))
+
+ const debouncedFn = debounce.mock.results[0]?.value
+ expect(debouncedFn?.cancel).toHaveBeenCalled()
+
+ removeEventListenerSpy.mockRestore()
+ })
+ })
+})