Files
frontend-app-authoring/src/search-modal/SearchUI.test.tsx
2024-11-07 09:48:19 -08:00

481 lines
23 KiB
TypeScript

import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import {
fireEvent,
render,
waitFor,
within,
type RenderResult,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
import type { Store } from 'redux';
import initializeStore from '../store';
import mockResult from './__mocks__/search-result.json';
import mockEmptyResult from './__mocks__/empty-search-result.json';
import mockTagsFacetResult from './__mocks__/facet-search.json';
import mockTagsFacetResultLevel0 from './__mocks__/facet-search-level0.json';
import mockTagsFacetResultLevel1 from './__mocks__/facet-search-level1.json';
import mockTagsKeywordSearchResult from './__mocks__/tags-keyword-search.json';
import SearchUI from './SearchUI';
import { getContentSearchConfigUrl } from '../search-manager/data/api';
// mockResult contains only a single result - this one:
const mockResultDisplayName = 'Test HTML Block';
let store: Store;
const queryClient = new QueryClient();
// Default props for <SearchUI />
const defaults = {
courseId: 'course-v1:org+test+123',
};
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
const facetSearchEndpoint = 'http://mock.meilisearch.local/indexes/studio/facet-search';
const tagsKeywordSearchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search';
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useNavigate: () => mockNavigate,
}));
const Wrap: React.FC<{ children:React.ReactNode }> = ({ children }) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
let axiosMock: MockAdapter;
const returnEmptyResult = (_url: string, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
mockEmptyResult.results[0].query = query;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockEmptyResult.results[0]?.hits.forEach((hit: any) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
};
describe('<SearchUI />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
// The API method to get the Meilisearch connection details uses Axios:
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
url: 'http://mock.meilisearch.local',
index_name: 'studio',
api_key: 'test-key',
});
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const query = requestData?.queries[0]?.q ?? '';
// We have to replace the query (search keywords) in the mock results with the actual query,
// because otherwise Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
mockResult.results[0].query = query;
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return mockResult;
});
fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult);
});
afterEach(async () => {
fetchMock.mockReset();
});
it('should render an empty state', async () => {
const { getByText } = render(<Wrap><SearchUI {...defaults} /></Wrap>);
// Before the results have even loaded, we see this message:
expect(getByText('Start searching to find content')).toBeInTheDocument();
// When this UI loads, we do a "placeholder" search to load the filter options
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
// And that message is still displayed even after the initial results/filters have loaded:
expect(getByText('Start searching to find content')).toBeInTheDocument();
});
it('should render an empty state if no result found', async () => {
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
const { getByText, getByRole } = render(<Wrap><SearchUI {...defaults} /></Wrap>);
// Return an empty result set:
// Before the results have even loaded, we see this message:
expect(getByText('Start searching to find content')).toBeInTheDocument();
// When this UI loads, the UI makes a search, to get the available "block type" facet values.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
// And that message is still displayed even after the initial results/filters have loaded:
expect(getByText('Start searching to find content')).toBeInTheDocument();
// Enter a keyword - search for 'noresults':
fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } });
// Wait for the new search request to load all the results:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(getByText('We didn\'t find anything matching your search')).toBeInTheDocument();
});
it('defaults to searching "All Courses" if used outside of any particular course', async () => {
const { getByText, queryByText, getByRole } = render(<Wrap><SearchUI {...defaults} courseId="" /></Wrap>);
// We default to searching all courses:
expect(getByText('All courses')).toBeInTheDocument();
expect(queryByText('This course')).toBeNull();
// Wait for the initial search request that loads all the filter options:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
// Enter a keyword - search for 'giraffe':
fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
// Wait for the new search request to load all the results:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
// The result:
expect(getByText('6 results found')).toBeInTheDocument();
expect(getByText(mockResultDisplayName)).toBeInTheDocument();
// Breadcrumbs showing where the result came from:
expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument();
});
it('defaults to searching "This Course" if used in a course', async () => {
const { getByText, queryByText, getByRole } = render(<Wrap><SearchUI {...defaults} /></Wrap>);
// We default to searching all courses:
expect(getByText('This course')).toBeInTheDocument();
expect(queryByText('All courses')).toBeNull();
// Wait for the initial search request that loads all the filter options:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
// Enter a keyword - search for 'giraffe':
fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
// Wait for the new search request to load all the results:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// And make sure the request was limited to this course:
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return requestedFilter?.[1] === 'type = "course_block"'
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
});
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
// The result:
expect(getByText('6 results found')).toBeInTheDocument();
expect(getByText(mockResultDisplayName)).toBeInTheDocument();
// Breadcrumbs showing where the result came from:
expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument();
});
describe('results', () => {
let rendered: RenderResult;
beforeEach(async () => {
rendered = render(<Wrap><SearchUI {...defaults} /></Wrap>);
const { getByRole } = rendered;
fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
});
test('click section result navigates to the context', async () => {
const { findAllByRole } = rendered;
const [resultItem] = await findAllByRole('button', { name: /Section 1/ });
// Clicking the "Open in new window" button should open the result in a new window:
const { open } = window;
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40chapter%2Bblock%40c7077c8cafcf420dbc0b440bf27bad04',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
expect(mockNavigate).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40chapter%2Bblock%40c7077c8cafcf420dbc0b440bf27bad04',
);
});
test('click subsection result navigates to the context', async () => {
const { findAllByRole } = rendered;
const [resultItem] = await findAllByRole('button', { name: /Subsection 1.1/ });
// Clicking the "Open in new window" button should open the result in a new window:
const { open } = window;
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40sequential%2Bblock%4092e3e9ca156c44fa8a735f0e9e7c854f',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
expect(mockNavigate).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40sequential%2Bblock%4092e3e9ca156c44fa8a735f0e9e7c854f',
);
});
test('click unit result navigates to the context', async () => {
const { findAllByRole } = rendered;
const [resultItem] = await findAllByRole('button', { name: /Unit 1.1.1/ });
// Clicking the "Open in new window" button should open the result in a new window:
const { open } = window;
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
expect(mockNavigate).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b',
);
});
test('click unit component result navigates to the context', async () => {
const { findAllByRole } = rendered;
const [resultItem] = await findAllByRole('button', { name: /Announcement/ });
// Clicking the "Open in new window" button should open the result in a new window:
const { open } = window;
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40html%2Bblock%400b2d1c0722f742489602b6d8645205f4',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
expect(mockNavigate).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40html%2Bblock%400b2d1c0722f742489602b6d8645205f4',
);
});
test('click lib component in unit result navigates to the context of encompassing lib component', async () => {
const { findAllByRole } = rendered;
const [resultItem] = await findAllByRole('button', { name: /Text block in Lib Component/ });
// Clicking the "Open in new window" button should open the result in a new window:
const { open } = window;
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40library_content%2Bblock%40427e5cd03fbe431d9d551c67d4e280ae',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
expect(mockNavigate).toHaveBeenCalledWith(
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40library_content%2Bblock%40427e5cd03fbe431d9d551c67d4e280ae',
);
});
test('click lib component result navigates to course-authoring/library', async () => {
const { findByRole } = rendered;
const resultItem = await findByRole('button', { name: /Library Content/ });
// Clicking the "Open in new window" button should open the result in a new window:
const { open } = window;
window.open = jest.fn();
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
expect(window.open).toHaveBeenCalledWith(
'/library/lib:org1:libafter1',
'_blank',
);
window.open = open;
// Clicking in the result should navigate to the result's URL:
fireEvent.click(resultItem);
expect(mockNavigate).toHaveBeenCalledWith(
'/library/lib:org1:libafter1',
);
});
});
describe('filters', () => {
let rendered: RenderResult;
beforeEach(async () => {
fetchMock.post(facetSearchEndpoint, (_path, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
switch (requestData.facetName) {
case 'tags.taxonomy': return mockTagsFacetResult;
case 'tags.level0': return mockTagsFacetResultLevel0;
case 'tags.level1': return mockTagsFacetResultLevel1;
default: throw new Error(`Facet ${requestData.facetName} not mocked for testing`);
}
});
rendered = render(<Wrap><SearchUI {...defaults} /></Wrap>);
const { getByRole, getByText } = rendered;
// Wait for the initial search request that loads all the filter options:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
// Enter a keyword - search for 'giraffe':
fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } });
// Wait for the new search request to load all the results and the filter options, based on the search so far:
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// And make sure the request was limited to this course:
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
// the filter is:
// ['', 'type = "course_block"', 'context_key = "course-v1:org+test+123"']
return (requestedFilter?.length === 3);
});
// Now we should see the results:
expect(getByText('6 results found')).toBeInTheDocument();
expect(getByText(mockResultDisplayName)).toBeInTheDocument();
});
it('can filter results by component/XBlock type', async () => {
const { getByRole, getByText } = rendered;
// Now open the filters menu:
fireEvent.click(getByRole('button', { name: 'Type' }), {});
// The dropdown menu has role="group"
await waitFor(() => { expect(getByRole('group')).toBeInTheDocument(); });
const problemFilterCheckbox = getByText(/Problem/i);
fireEvent.click(problemFilterCheckbox, {});
await waitFor(() => {
expect(rendered.getByRole('button', { name: /type: problem/i, hidden: true })).toBeInTheDocument();
});
// Now wait for the filter to be applied and the new results to be fetched.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
// Because we're mocking the results, there's no actual changes to the mock results,
// but we can verify that the filter was sent in the request
expect(fetchMock).toHaveLastFetched((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[
'block_type = problem',
'content.problem_types = choiceresponse',
'content.problem_types = multiplechoiceresponse',
'content.problem_types = numericalresponse',
'content.problem_types = optionresponse',
'content.problem_types = stringresponse',
],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
]);
});
});
it('can filter results by tag', async () => {
const { getByRole, getByLabelText } = rendered;
// Now open the filters menu:
fireEvent.click(getByRole('button', { name: 'Tags' }), {});
// The dropdown menu in this case doesn't have a role; let's just assume it's displayed.
const checkboxLabel = /^ESDC Skills and Competencies/i;
await waitFor(() => { expect(getByLabelText(checkboxLabel)).toBeInTheDocument(); });
// In addition to the checkbox, there is another button to show the child tags:
expect(getByLabelText(/Expand to show child tags of "ESDC Skills and Competencies"/i)).toBeInTheDocument();
const competentciesCheckbox = getByLabelText(checkboxLabel);
fireEvent.click(competentciesCheckbox, {});
// Now wait for the filter to be applied and the new results to be fetched.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
// Because we're mocking the results, there's no actual changes to the mock results,
// but we can verify that the filter was sent in the request
expect(fetchMock).toBeDone((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request
]);
});
});
it('can filter results by a child tag', async () => {
const { getByRole, getByLabelText, queryByLabelText } = rendered;
// Now open the filters menu:
fireEvent.click(getByRole('button', { name: 'Tags' }), {});
// The dropdown menu in this case doesn't have a role; let's just assume it's displayed.
const expandButtonLabel = /Expand to show child tags of "ESDC Skills and Competencies"/i;
await waitFor(() => { expect(getByLabelText(expandButtonLabel)).toBeInTheDocument(); });
// First, the child tag is not shown:
const childTagLabel = /^Abilities/i;
expect(queryByLabelText(childTagLabel)).toBeNull();
// Click on the button to show children
const expandButton = getByLabelText(expandButtonLabel);
fireEvent.click(expandButton, {});
// Now the child tag is visible:
await waitFor(() => { expect(queryByLabelText(childTagLabel)).toBeInTheDocument(); });
// Click on it:
const abilitiesTagFilterCheckbox = getByLabelText(childTagLabel);
fireEvent.click(abilitiesTagFilterCheckbox);
// Now wait for the filter to be applied and the new results to be fetched.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
// Because we're mocking the results, there's no actual changes to the mock results,
// but we can verify that the filter was sent in the request
expect(fetchMock).toBeDone((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
[],
'type = "course_block"',
'context_key = "course-v1:org+test+123"',
'tags.level0 = "ESDC Skills and Competencies > Abilities"',
]);
});
});
it('can do a keyword search of the tag options', async () => {
const { getByRole, getByLabelText, queryByLabelText } = rendered;
// Now open the filters menu:
fireEvent.click(getByRole('button', { name: 'Tags' }), {});
// The dropdown menu in this case doesn't have a role; let's just assume it's displayed.
const expandButtonLabel = /Expand to show child tags of "ESDC Skills and Competencies"/i;
await waitFor(() => { expect(getByLabelText(expandButtonLabel)).toBeInTheDocument(); });
const input = getByLabelText('Search tags');
fireEvent.change(input, { target: { value: 'Lightcast' } });
await waitFor(() => { expect(queryByLabelText(/^ESDC Skills and Competencies/i)).toBeNull(); });
expect(queryByLabelText(/^Lightcast/i)).toBeInTheDocument();
});
});
});