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('') }) })