import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import SearchOverlay from '@/components/search/SearchOverlay'
process.env.NEXT_PUBLIC_DIRECTUS_URL = 'https://cms.achmad.dev'
process.env.NEXT_PUBLIC_DIRECTUS_TOKEN = 'test-token'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({ push: mockPush })),
}))
const mockFetch = vi.fn()
global.fetch = mockFetch
beforeEach(() => {
vi.resetAllMocks()
vi.useFakeTimers()
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ data: [] }),
})
mockPush.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
function openSearch() {
window.dispatchEvent(new CustomEvent('kotobane:open-search'))
}
describe('SearchOverlay', () => {
it('does not render when closed initially', () => {
const { container } = render()
expect(container.innerHTML).toBe('')
})
it('opens when custom event is dispatched', () => {
render()
act(() => {
openSearch()
})
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
})
it('opens when Ctrl+K / Cmd+K is pressed', () => {
render()
fireEvent.keyDown(window, { key: 'k', metaKey: true })
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
})
it('closes when Escape is pressed', () => {
render()
act(() => {
openSearch()
})
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByPlaceholderText('Search articles…')).not.toBeInTheDocument()
})
it('closes when clicking the backdrop', () => {
render()
act(() => {
openSearch()
})
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
const backdrop = document.querySelector('.bg-black\\/60')
expect(backdrop).toBeInTheDocument()
if (backdrop) fireEvent.click(backdrop)
expect(screen.queryByPlaceholderText('Search articles…')).not.toBeInTheDocument()
})
it('fetches search results after debounce delay', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
data: [
{
id: '1',
title: 'Search Result',
slug: 'search-result',
category: { slug: 'anime', name: 'Anime' },
},
],
}),
})
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'Frieren' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByText('Search Result')).toBeInTheDocument()
})
it('shows category name in search results', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
data: [
{
id: '1',
title: 'Result',
slug: 'result',
category: { slug: 'music', name: 'Music' },
},
],
}),
})
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'song' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByText('Music')).toBeInTheDocument()
})
it('shows loading indicator while searching', async () => {
mockFetch.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
json: () => Promise.resolve({ data: [] }),
}),
500,
),
),
)
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'test' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
// After the debounce fires, setLoading(true) is called synchronously
expect(screen.getByText('Searching…')).toBeInTheDocument()
})
it('shows no results message when search returns empty', async () => {
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'zzzz' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByText(/no results/i)).toBeInTheDocument()
})
it('navigates to article on click', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
data: [
{
id: '1',
title: 'Clickable Result',
slug: 'clickable-result',
category: { slug: 'anime', name: 'Anime' },
},
],
}),
})
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'item' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByText('Clickable Result')).toBeInTheDocument()
fireEvent.click(screen.getByText('Clickable Result'))
expect(mockPush).toHaveBeenCalledWith('/anime/clickable-result')
})
it('clears results when input is cleared', () => {
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'test' } })
fireEvent.change(input, { target: { value: '' } })
expect(screen.queryByText('No results')).not.toBeInTheDocument()
})
it('debounces search requests', () => {
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
act(() => {
vi.advanceTimersByTime(100)
})
// Should not have fired yet (debounce is 300ms)
expect(mockFetch).not.toHaveBeenCalled()
})
it('navigates on Enter key press', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
data: [
{
id: '1',
title: 'Enter Result',
slug: 'enter-result',
category: { slug: 'manga', name: 'Manga' },
},
],
}),
})
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'manga' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByText('Enter Result')).toBeInTheDocument()
fireEvent.keyDown(input, { key: 'Enter' })
expect(mockPush).toHaveBeenCalledWith('/manga/enter-result')
})
it('clears search results when overlay reopens via Escape', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
data: [
{
id: '1',
title: 'Temp Result',
slug: 'temp',
category: { slug: 'anime', name: 'Anime' },
},
],
}),
})
render()
act(() => {
openSearch()
})
const input = screen.getByPlaceholderText('Search articles…')
fireEvent.change(input, { target: { value: 'temp' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByText('Temp Result')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape' })
act(() => {
openSearch()
})
expect(screen.queryByText('Temp Result')).not.toBeInTheDocument()
expect(screen.getByPlaceholderText('Search articles…')).toHaveValue('')
})
})