datajunction-ui
Version:
DataJunction UI
249 lines (233 loc) • 7.96 kB
JSX
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);
});
});