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>): 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)
})
})