UNPKG

datajunction-ui

Version:
249 lines (233 loc) 7.96 kB
import React from 'react'; import { render, fireEvent, waitFor, act } from '@testing-library/react'; import Search from '../Search'; import DJClientContext from '../../providers/djclient'; const mockNodes = [ { name: 'default.test_node', display_name: 'Test Node', description: 'A test node for testing', type: 'transform', kind: 'node', }, { name: 'default.another_node', display_name: 'Another Node', description: '', type: 'metric', kind: 'node', }, { name: 'default.long_description_node', display_name: 'Long Description', description: 'This is a very long description that exceeds 100 characters and should be truncated to prevent display issues in the search results interface', type: 'dimension', kind: 'node', }, ]; const mockTags = [ { name: 'test_tag', display_name: 'Test Tag', description: 'A test tag', type: 'tag', tag_type: 'business', kind: 'tag', }, ]; const makeClient = overrides => ({ DataJunctionAPI: { globalSearch: vi .fn() .mockResolvedValue({ nodes: mockNodes, tags: mockTags }), ...(overrides || {}), }, }); const flushDebounce = async () => { // Real-timer wait that exceeds the debounce delay. Switching away from // vitest fake timers here because testing-library's findBy* polling // relies on real setTimeout to retry — fake timers deadlock it. await act(async () => { await new Promise(resolve => setTimeout(resolve, 200)); }); }; describe('<Search />', () => { beforeEach(() => { vi.clearAllMocks(); }); it('renders the search input with the idle placeholder', () => { const client = makeClient(); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); expect( getByPlaceholderText('Search nodes and tags...'), ).toBeInTheDocument(); }); it('does not query the API for an empty string', async () => { const client = makeClient(); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); fireEvent.change(getByPlaceholderText('Search nodes and tags...'), { target: { value: ' ' }, }); await flushDebounce(); expect(client.DataJunctionAPI.globalSearch).not.toHaveBeenCalled(); }); it('queries the API for single-character input (server handles prefix)', async () => { const client = makeClient(); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); fireEvent.change(getByPlaceholderText('Search nodes and tags...'), { target: { value: 'h' }, }); await flushDebounce(); expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledWith( 'h', expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); it('calls globalSearch after the debounce interval for a non-trivial query', async () => { const client = makeClient(); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); fireEvent.change(getByPlaceholderText('Search nodes and tags...'), { target: { value: 'test' }, }); await flushDebounce(); expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledWith( 'test', expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); it('renders both node and tag results returned by the server', async () => { const client = makeClient(); const { getByPlaceholderText, container, findByText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); fireEvent.change(getByPlaceholderText('Search nodes and tags...'), { target: { value: 'test' }, }); await flushDebounce(); await findByText(/Test Node/); expect( container.querySelector('a[href="/nodes/default.test_node"]'), ).toBeInTheDocument(); expect( container.querySelector('a[href="/tags/test_tag"]'), ).toBeInTheDocument(); }); it('truncates descriptions longer than 100 characters', async () => { const client = makeClient({ globalSearch: vi .fn() .mockResolvedValue({ nodes: [mockNodes[2]], tags: [] }), }); const { getByPlaceholderText, findByText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); fireEvent.change(getByPlaceholderText('Search nodes and tags...'), { target: { value: 'long' }, }); await flushDebounce(); await findByText(/\.\.\./); }); it('clears results when the input is cleared', async () => { const client = makeClient(); const { getByPlaceholderText, container, findByText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); const input = getByPlaceholderText('Search nodes and tags...'); fireEvent.change(input, { target: { value: 'test' } }); await flushDebounce(); await findByText(/Test Node/); fireEvent.change(input, { target: { value: '' } }); await waitFor(() => { expect(container.querySelector('.search-result-item')).toBeNull(); }); }); it('logs an error but does not throw when the request fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(); const client = makeClient({ globalSearch: vi.fn().mockRejectedValue(new Error('boom')), }); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); fireEvent.change(getByPlaceholderText('Search nodes and tags...'), { target: { value: 'test' }, }); await flushDebounce(); await waitFor(() => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'Search failed:', expect.any(Error), ); }); consoleErrorSpy.mockRestore(); }); it('serves a repeated query from cache without calling the server again', async () => { const client = makeClient(); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); const input = getByPlaceholderText('Search nodes and tags...'); fireEvent.change(input, { target: { value: 'test' } }); await flushDebounce(); expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledTimes(1); fireEvent.change(input, { target: { value: 'other' } }); await flushDebounce(); fireEvent.change(input, { target: { value: 'test' } }); await flushDebounce(); expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledTimes(2); }); it('aborts the in-flight request when a new query is typed', async () => { const aborts = []; const client = makeClient({ globalSearch: vi.fn((q, { signal }) => { return new Promise((resolve, reject) => { signal.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; aborts.push(q); reject(err); }); }); }), }); const { getByPlaceholderText } = render( <DJClientContext.Provider value={client}> <Search /> </DJClientContext.Provider>, ); const input = getByPlaceholderText('Search nodes and tags...'); fireEvent.change(input, { target: { value: 'aaa' } }); await flushDebounce(); fireEvent.change(input, { target: { value: 'bbb' } }); await flushDebounce(); await waitFor(() => expect(aborts).toContain('aaa')); expect(client.DataJunctionAPI.globalSearch).toHaveBeenCalledTimes(2); }); });