import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' import React from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useSearch } from './useSearch' import type { SearchRowDTO } from '../../api/adapters' import type { SearchFilters } from './useSearch' // ── Mock apiFetch ───────────────────────────────────────────────────────────── const mockApiFetch = vi.fn() vi.mock('../../api/client', () => ({ apiFetch: (...args: unknown[]) => mockApiFetch(...args), apiFetchVoid: vi.fn().mockResolvedValue(undefined), AuthError: class AuthError extends Error { name = 'AuthError' constructor(message: string) { super(message) } }, APIError: class APIError extends Error { name = 'APIError' status: number code: string constructor(message: string, status: number, code: string) { super(message) this.status = status this.code = code } }, })) // ── Test wrapper ────────────────────────────────────────────────────────────── function makeWrapper(queryClient: QueryClient) { return function Wrapper({ children }: { children: React.ReactNode }) { return React.createElement(QueryClientProvider, { client: queryClient }, children) } } // ── Helpers ─────────────────────────────────────────────────────────────────── function makeRowDTO(overrides?: Partial): SearchRowDTO { return { owner: '', tool: 'opencode', host: 'laptop', session_id: 'ses-1', working_dir: '/home/user', turn_id: 'turn-1', seq: 1, role: 'user', timestamp: 1000000, rank: 0.5, match_source: 'message', snippet: 'hello \x02world\x03', ...overrides, } } // ── Tests ───────────────────────────────────────────────────────────────────── describe('useSearch', () => { let queryClient: QueryClient beforeEach(() => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }) mockApiFetch.mockReset() }) it('constructs correct URL from filters (q, tool, host → query string)', async () => { mockApiFetch.mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 }) const filters: SearchFilters = { q: 'hello', tool: 'opencode', host: 'laptop' } const wrapper = makeWrapper(queryClient) renderHook(() => useSearch(filters, true), { wrapper }) await waitFor(() => { expect(mockApiFetch).toHaveBeenCalledTimes(1) }) const url = mockApiFetch.mock.calls[0][0] as string expect(url).toContain('/api/v1/search') expect(url).toContain('q=hello') expect(url).toContain('tool=opencode') expect(url).toContain('host=laptop') }) it('getNextPageParam returns next cursor from last page', async () => { mockApiFetch .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20, next_cursor: 'cursor-2' }) .mockResolvedValueOnce({ results: [makeRowDTO()], limit: 20 }) const filters: SearchFilters = { q: 'hello' } const wrapper = makeWrapper(queryClient) const { result } = renderHook(() => useSearch(filters, true), { wrapper }) // Wait for first page await waitFor(() => expect(result.current.isLoading).toBe(false)) // Fetch next page await act(() => result.current.fetchNextPage()) await waitFor(() => { expect(mockApiFetch).toHaveBeenCalledTimes(2) }) // Second call should have cursor param const secondUrl = mockApiFetch.mock.calls[1][0] as string expect(secondUrl).toContain('cursor=cursor-2') }) it('returns flat results array from all pages', async () => { mockApiFetch .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'a' })], limit: 20, next_cursor: 'cursor-2' }) .mockResolvedValueOnce({ results: [makeRowDTO({ snippet: 'b' })], limit: 20 }) const filters: SearchFilters = { q: 'hello' } const wrapper = makeWrapper(queryClient) const { result } = renderHook(() => useSearch(filters, true), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.results).toHaveLength(1) await act(() => result.current.fetchNextPage()) await waitFor(() => { expect(result.current.results).toHaveLength(2) }) }) it('returns empty results when q is empty string', async () => { const filters: SearchFilters = { q: '' } const wrapper = makeWrapper(queryClient) const { result } = renderHook(() => useSearch(filters, true), { wrapper }) expect(result.current.results).toEqual([]) expect(mockApiFetch).not.toHaveBeenCalled() }) it('does not fetch when disabled', async () => { const filters: SearchFilters = { q: 'hello' } const wrapper = makeWrapper(queryClient) renderHook(() => useSearch(filters, false), { wrapper }) // Give any potential fetch a moment await new Promise(r => setTimeout(r, 50)) expect(mockApiFetch).not.toHaveBeenCalled() }) it('propagates fetch errors', async () => { mockApiFetch.mockRejectedValueOnce(new Error('network error')) const filters: SearchFilters = { q: 'hello' } const wrapper = makeWrapper(queryClient) const { result } = renderHook(() => useSearch(filters, true), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.error).toBeDefined() expect(result.current.error).toBeInstanceOf(Error) }) })