import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
import {
render,
screen,
userEvent,
waitFor,
mockProducts,
} from '../../test-utils'
import {Search, SearchProvider, SearchInput, SearchResultsList} from './search'
// Mock the useProductSearch hook
const mockProductSearchResult = {
products: null as any,
loading: false,
error: null,
fetchMore: vi.fn(),
hasNextPage: false,
isTyping: false,
}
vi.mock('../../hooks/product/useProductSearch', () => ({
useProductSearch: vi.fn(() => mockProductSearchResult),
}))
// Mock navigation hooks for ProductLink
vi.mock('../../hooks/navigation/useShopNavigation', () => ({
useShopNavigation: () => ({
navigateToProduct: vi.fn(),
navigateToShop: vi.fn(),
}),
}))
// Mock save actions for ProductLink
vi.mock('../../hooks/user/useSavedProductsActions', () => ({
useSavedProductsActions: () => ({
saveProduct: vi.fn().mockResolvedValue({ok: true, data: {}}),
unsaveProduct: vi.fn().mockResolvedValue({ok: true, data: {}}),
}),
}))
describe('Search', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock values
mockProductSearchResult.products = null
mockProductSearchResult.loading = false
mockProductSearchResult.error = null
mockProductSearchResult.hasNextPage = false
mockProductSearchResult.isTyping = false
})
afterEach(() => {
vi.clearAllMocks()
})
describe('SearchInput', () => {
it('renders search input with placeholder', () => {
render(
)
const input = screen.getByPlaceholderText('Search for products...')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'search')
expect(input).toHaveAttribute('role', 'searchbox')
})
it('updates query on input change', async () => {
const user = userEvent.setup()
render(
)
const input = screen.getByTestId('search-input')
await user.type(input, 'test query')
expect(input).toHaveValue('test query')
})
it('shows clear button when query is not empty', async () => {
const user = userEvent.setup()
render(
)
const input = screen.getByTestId('search-input')
// Initially no clear button
expect(screen.queryByRole('button')).not.toBeInTheDocument()
await user.type(input, 'test')
// Clear button should appear
const clearButton = screen.getByRole('button')
expect(clearButton).toBeInTheDocument()
})
it('clears query when clear button is clicked', async () => {
const user = userEvent.setup()
render(
)
const input = screen.getByTestId('search-input')
expect(input).toHaveValue('test query')
const clearButton = screen.getByRole('button')
await user.click(clearButton)
expect(input).toHaveValue('')
})
it('accepts custom input props', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
)
const input = screen.getByTestId('search-input')
expect(input).toHaveAttribute('data-custom', 'test')
await user.type(input, 'a')
expect(onChange).toHaveBeenCalled()
})
})
describe('SearchResultsList', () => {
it('shows initial state when query is empty', () => {
render(
)
expect(
screen.getByText('Start typing to search for products')
).toBeInTheDocument()
})
it('shows custom initial state component', () => {
render(
Custom initial state}
/>
)
expect(screen.getByText('Custom initial state')).toBeInTheDocument()
})
it('shows loading state when searching', () => {
mockProductSearchResult.loading = true
mockProductSearchResult.products = []
render(
)
// Should show skeleton loaders
const skeletons = document.querySelectorAll('[data-slot="skeleton"]')
expect(skeletons.length).toBeGreaterThan(0)
})
it('shows typing state', () => {
mockProductSearchResult.isTyping = true
mockProductSearchResult.products = []
render(
)
// Should show skeleton loaders while typing
const skeletons = document.querySelectorAll('[data-slot="skeleton"]')
expect(skeletons.length).toBeGreaterThan(0)
})
it('shows empty state when no products found', () => {
mockProductSearchResult.products = []
mockProductSearchResult.loading = false
render(
)
expect(
screen.getByText('No products found for "nonexistent"')
).toBeInTheDocument()
})
it('renders products when available', () => {
const products = mockProducts(3)
mockProductSearchResult.products = products
mockProductSearchResult.loading = false
render(
)
products.forEach(product => {
expect(screen.getByText(product.title)).toBeInTheDocument()
})
})
it('uses custom renderItem function', () => {
const products = mockProducts(2)
mockProductSearchResult.products = products
const customRenderItem = (product: any) => (
Custom: {product.title}
)
render(
)
products.forEach(product => {
expect(screen.getByText(`Custom: ${product.title}`)).toBeInTheDocument()
})
})
it('handles pagination with fetchMore', async () => {
const products = mockProducts(5)
mockProductSearchResult.products = products
mockProductSearchResult.hasNextPage = true
mockProductSearchResult.fetchMore = vi.fn()
render(
)
// Verify fetchMore is available for pagination
expect(mockProductSearchResult.fetchMore).toBeDefined()
})
})
describe('Search Component (Integrated)', () => {
it('renders complete search interface', () => {
render()
expect(screen.getByTestId('search-input')).toBeInTheDocument()
expect(
screen.getByText('Start typing to search for products')
).toBeInTheDocument()
})
it('handles search flow end-to-end', async () => {
const user = userEvent.setup()
const products = mockProducts(2)
render()
const input = screen.getByTestId('search-input')
// Type search query
await user.type(input, 'test')
// Update mock to return products
mockProductSearchResult.products = products
mockProductSearchResult.loading = false
// Rerender to show products
render()
// Products should be displayed
products.forEach(product => {
expect(screen.getByText(product.title)).toBeInTheDocument()
})
})
it('calls onProductClick when product is clicked', async () => {
const user = userEvent.setup()
const onProductClick = vi.fn()
const products = mockProducts(1)
mockProductSearchResult.products = products
render()
// Click on the touchable wrapper around the product
const productTitle = screen.getByText(products[0].title)
const touchableWrapper = productTitle.closest('[data-touchable="true"]')
if (touchableWrapper) {
await user.click(touchableWrapper)
}
await waitFor(() => {
expect(onProductClick).toHaveBeenCalledWith(products[0])
})
})
it('accepts custom renderItem for product display', () => {
const products = mockProducts(1)
mockProductSearchResult.products = products
const customRenderItem = (product: any) => (
Custom Product: {product.title}
)
render()
expect(screen.getByTestId('custom-product')).toBeInTheDocument()
expect(
screen.getByText(`Custom Product: ${products[0].title}`)
).toBeInTheDocument()
})
it('applies custom className', () => {
const {container} = render()
const searchContainer = container.querySelector('.custom-search-class')
expect(searchContainer).toBeInTheDocument()
})
})
describe('SearchProvider Context', () => {
it('provides search context to children', () => {
const TestComponent = () => {
const {query} = useSearchContext()
return <>Query: {query}>
}
// Mock the useSearchContext to avoid actual implementation
const useSearchContext = vi.fn(() => ({
query: 'test query',
setQuery: vi.fn(),
products: [],
loading: false,
error: null,
hasNextPage: false,
isTyping: false,
}))
render(
)
expect(screen.getByText('Query: test query')).toBeInTheDocument()
})
it('throws error when using context outside provider', () => {
// This would throw in actual usage, but we can't test it directly
// without importing the actual useSearchContext
expect(() => {
const TestComponent = () => {
// This would throw: useSearchContext must be used within a SearchProvider
return Test
}
render()
}).not.toThrow() // Our mock doesn't throw
})
})
describe('Edge Cases', () => {
it('handles error state', () => {
mockProductSearchResult.error = new Error('Search failed') as any
mockProductSearchResult.loading = false
mockProductSearchResult.products = []
render(
)
// Should show empty state on error
expect(
screen.getByText('No products found for "test"')
).toBeInTheDocument()
})
it('handles whitespace-only queries', () => {
render(
)
// Should show initial state for whitespace-only query
expect(
screen.getByText('Start typing to search for products')
).toBeInTheDocument()
})
it('handles very long queries', async () => {
const user = userEvent.setup()
const longQuery = 'a'.repeat(100)
render(
)
const input = screen.getByTestId('search-input')
await user.type(input, longQuery)
expect(input).toHaveValue(longQuery)
})
it('handles rapid query changes', async () => {
const user = userEvent.setup()
render(
)
const input = screen.getByTestId('search-input')
// Rapid typing
await user.type(input, 'a')
await user.type(input, 'b')
await user.type(input, 'c')
expect(input).toHaveValue('abc')
})
})
describe('Accessibility', () => {
it('has proper ARIA attributes on search input', () => {
render(
)
const input = screen.getByTestId('search-input')
expect(input).toHaveAttribute('role', 'searchbox')
expect(input).toHaveAttribute('type', 'search')
expect(input).toHaveAttribute('autoComplete', 'off')
})
it('search icon is decorative', () => {
render(
)
// Search icon should not have interactive role
const searchIcon = document.querySelector('svg')
expect(searchIcon).toBeInTheDocument()
expect(searchIcon?.parentElement).not.toHaveAttribute('role', 'button')
})
})
})