import { fireEvent, render, screen, act } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Message } from '../types'; const mockGetMessages = vi.fn<(...args: unknown[]) => Promise>(); vi.mock('../api', () => ({ api: { getMessages: (...args: unknown[]) => mockGetMessages(...args), }, isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError', })); import { SearchView } from '../components/SearchView'; function createSearchResult(overrides: Partial = {}): Message { return { id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'hello world', sender_timestamp: 1700000000, received_at: 1700000000, paths: null, txt_type: 0, signature: null, sender_key: null, outgoing: false, acked: 0, sender_name: 'Alice', ...overrides, }; } const defaultProps = { contacts: [], channels: [ { key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null }, ], onNavigateToMessage: vi.fn(), }; /** Type the query into the search input and wait for debounced results to render. */ async function typeAndWaitForResults(query: string) { const input = screen.getByLabelText('Search messages'); // Use fake timers only for the debounce, then switch to real timers for // React's async state updates and waitFor polling. vi.useFakeTimers(); await act(async () => { fireEvent.change(input, { target: { value: query } }); vi.advanceTimersByTime(350); }); vi.useRealTimers(); // Wait for the mock API promise to resolve and React to commit await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); } describe('SearchView', () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it('renders empty state with prompt text', () => { mockGetMessages.mockResolvedValue([]); render(); expect(screen.getByText('Type to search across all messages')).toBeInTheDocument(); }); it('focuses input on mount', () => { mockGetMessages.mockResolvedValue([]); render(); expect(screen.getByLabelText('Search messages')).toHaveFocus(); }); it('debounces search input', async () => { mockGetMessages.mockResolvedValue([]); vi.useFakeTimers(); render(); const input = screen.getByLabelText('Search messages'); await act(async () => { fireEvent.change(input, { target: { value: 'hello' } }); }); // Should not have called API yet (within debounce window) expect(mockGetMessages).not.toHaveBeenCalled(); // Advance past debounce timer await act(async () => { vi.advanceTimersByTime(350); }); vi.useRealTimers(); expect(mockGetMessages).toHaveBeenCalledTimes(1); expect(mockGetMessages).toHaveBeenCalledWith( expect.objectContaining({ q: 'hello' }), expect.any(AbortSignal) ); }); it('displays search results', async () => { mockGetMessages.mockResolvedValue([ createSearchResult({ id: 1, text: 'hello world', sender_name: 'Alice' }), createSearchResult({ id: 2, text: 'hello there', sender_name: 'Bob' }), ]); render(); await typeAndWaitForResults('hello'); // Text is split by highlightMatch into segments, so use container text content const buttons = screen.getAllByRole('button'); const texts = buttons.map((b) => b.textContent); expect(texts.some((t) => t?.includes('hello world') || t?.includes('world'))).toBe(true); expect(texts.some((t) => t?.includes('hello there') || t?.includes('there'))).toBe(true); }); it('shows no-results message when search returns empty', async () => { mockGetMessages.mockResolvedValue([]); render(); await typeAndWaitForResults('nonexistent'); expect(screen.getByText(/No messages found/)).toBeInTheDocument(); }); it('navigates to message on click', async () => { const result = createSearchResult({ id: 42, type: 'CHAN', conversation_key: 'ABC123', text: 'click me', }); mockGetMessages.mockResolvedValue([result]); const onNavigate = vi.fn(); render(); await typeAndWaitForResults('click'); const resultBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('me')); expect(resultBtn).toBeDefined(); fireEvent.click(resultBtn!); expect(onNavigate).toHaveBeenCalledWith({ id: 42, type: 'CHAN', conversation_key: 'ABC123', conversation_name: 'Public', }); }); it('navigates on Enter key', async () => { mockGetMessages.mockResolvedValue([createSearchResult({ id: 10, text: 'keyboard nav' })]); const onNavigate = vi.fn(); render(); await typeAndWaitForResults('keyboard'); const resultEl = screen.getByRole('button', { name: /keyboard nav/i }); fireEvent.keyDown(resultEl, { key: 'Enter' }); expect(onNavigate).toHaveBeenCalled(); }); it('shows load more button when results fill a page', async () => { const pageResults = Array.from({ length: 50 }, (_, i) => createSearchResult({ id: i + 1, text: `result ${i}` }) ); mockGetMessages.mockResolvedValueOnce(pageResults); render(); await typeAndWaitForResults('result'); expect(screen.getByText('Load more results')).toBeInTheDocument(); }); it('does not show load more when results are less than page size', async () => { mockGetMessages.mockResolvedValue([createSearchResult({ id: 1, text: 'only one' })]); render(); await typeAndWaitForResults('only'); const resultBtns = screen.getAllByRole('button'); expect(resultBtns.some((b) => b.textContent?.includes('one'))).toBe(true); expect(screen.queryByText('Load more results')).not.toBeInTheDocument(); }); it('resolves channel name from channels prop', async () => { mockGetMessages.mockResolvedValue([ createSearchResult({ id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'test' }), ]); render(); await typeAndWaitForResults('test'); expect(screen.getByText('Public')).toBeInTheDocument(); }); it('resolves contact name from contacts prop', async () => { const contactKey = 'aa'.repeat(32); mockGetMessages.mockResolvedValue([ createSearchResult({ id: 1, type: 'PRIV', conversation_key: contactKey, text: 'dm test', }), ]); render( ); await typeAndWaitForResults('dm'); expect(screen.getByText('Bob')).toBeInTheDocument(); }); });