Skip to content

Commit

Permalink
test: update tests
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed Apr 18, 2024
1 parent 2e2cdf7 commit 2cdbcd8
Show file tree
Hide file tree
Showing 5 changed files with 78 additions and 39 deletions.
68 changes: 43 additions & 25 deletions src/search-modal/SearchUI.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
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,
Expand All @@ -19,7 +21,10 @@ import initializeStore from '../store';
import mockResult from './__mocks__/search-result.json';
// @ts-ignore
import mockEmptyResult from './__mocks__/empty-search-result.json';
// @ts-ignore
import mockTagsFacetResult from './__mocks__/facet-search.json';
import SearchUI from './SearchUI';
import { getContentSearchConfigUrl } from './data/api';

// mockResult contains only a single result - this one:
const mockResultDisplayName = 'Test HTML Block';
Expand All @@ -29,12 +34,10 @@ const queryClient = new QueryClient();

// Default props for <SearchUI />
const defaults = {
url: 'http://mock.meilisearch.local/',
apiKey: 'test-key',
indexName: 'studio',
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 mockNavigate = jest.fn();

Expand All @@ -53,15 +56,15 @@ const Wrap = ({ children }) => (
</IntlProvider>
</AppProvider>
);
let axiosMock;

const returnEmptyResult = (_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.
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
mockEmptyResult.results[0].query = query;
// And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required.
// 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) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
Expand All @@ -78,14 +81,22 @@ describe('<SearchUI />', () => {
},
});
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 create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required.
// 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;
Expand All @@ -100,8 +111,8 @@ describe('<SearchUI />', () => {
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, Instantsearch makes two queries. I think one to load the facets and one "blank" search.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// 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();
});
Expand All @@ -112,14 +123,14 @@ describe('<SearchUI />', () => {
// 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, Instantsearch makes two queries. I think one to load the facets and one "blank" search.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// 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(3, searchEndpoint, 'post'); });
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
expect(getByText('We didn\'t find anything matching your search')).toBeInTheDocument();
});

Expand All @@ -129,11 +140,11 @@ describe('<SearchUI />', () => {
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(2, searchEndpoint, 'post'); });
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(3, searchEndpoint, 'post'); });
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
// Now we should see the results:
expect(queryByText('Enter a keyword')).toBeNull();
// The result:
Expand Down Expand Up @@ -165,11 +176,11 @@ describe('<SearchUI />', () => {
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(2, searchEndpoint, 'post'); });
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(3, searchEndpoint, 'post'); });
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() ?? '');
Expand All @@ -189,14 +200,16 @@ describe('<SearchUI />', () => {
/** @type {import('@testing-library/react').RenderResult} */
let rendered;
beforeEach(async () => {
fetchMock.post(facetSearchEndpoint, mockTagsFacetResult);

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(2, searchEndpoint, 'post'); });
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(3, searchEndpoint, 'post'); });
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() ?? '');
Expand All @@ -217,16 +230,17 @@ describe('<SearchUI />', () => {
const popupMenu = getByRole('group');
const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i);
fireEvent.click(problemFilterCheckbox, {});
await waitFor(() => { expect(rendered.getByText('Type: Problem')).toBeInTheDocument(); });
// Now wait for the filter to be applied and the new results to be fetched.
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); });
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([
'context_key = "course-v1:org+test+123"',
['"block_type"="problem"'], // <-- the newly added filter, sent with the request
['block_type = problem'], // <-- the newly added filter, sent with the request
]);
});
});
Expand All @@ -236,18 +250,22 @@ describe('<SearchUI />', () => {
// 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 competentciesCheckbox = getByLabelText(/ESDC Skills and Competencies/i);
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(4, searchEndpoint, 'post'); });
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) => {
expect(fetchMock).toBeDone((_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const requestedFilter = requestData?.queries[0].filter;
const requestedFilter = requestData?.queries?.[0]?.filter;
return JSON.stringify(requestedFilter) === JSON.stringify([
'context_key = "course-v1:org+test+123"',
['"tags.taxonomy"="ESDC Skills and Competencies"'], // <-- the newly added filter, sent with the request
'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request
]);
});
});
Expand Down
16 changes: 12 additions & 4 deletions src/search-modal/__mocks__/empty-search-result.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@
"comment": "This is a mock of the empty response from Meilisearch, based on an actual search in Studio.",
"results": [
{
"indexUid": "tutor_studio_content",
"indexUid": "studio",
"hits": [],
"query": "noresult",
"processingTimeMs": 0,
"limit": 21,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 0
},
{
"indexUid": "studio",
"hits": [],
"query": "noresult",
"processingTimeMs": 0,
"limit": 0,
"offset": 0,
"estimatedTotalHits": 0,
"facetDistribution": {
"block_type": {},
"tags.taxonomy": {}
"block_type": {}
},
"facetStats": {}
}
Expand Down
12 changes: 12 additions & 0 deletions src/search-modal/__mocks__/facet-search.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"facetHits": [
{ "value": "ESDC Skills and Competencies", "count": 7 },
{ "value": "FlatTaxonomy", "count": 7 },
{ "value": "HierarchicalTaxonomy", "count": 6 },
{ "value": "Lightcast Open Skills Taxonomy", "count": 6 },
{ "value": "MultiOrgTaxonomy", "count": 7 },
{ "value": "TwoLevelTaxonomy", "count": 7 }
],
"facetQuery": "",
"processingTimeMs": 0
}
19 changes: 10 additions & 9 deletions src/search-modal/__mocks__/search-result.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,22 @@
"processingTimeMs": 1,
"limit": 2,
"offset": 0,
"estimatedTotalHits": 2,
"estimatedTotalHits": 2
},
{
"indexUid": "studio",
"hits": [],
"query": "learn",
"processingTimeMs": 1,
"limit": 0,
"offset": 0,
"estimatedTotalHits": 0,
"facetDistribution": {
"block_type": {
"html": 2,
"problem": 16,
"vertical": 2,
"video": 1
},
"tags.taxonomy": {
"ESDC Skills and Competencies": 1,
"FlatTaxonomy": 2,
"HierarchicalTaxonomy": 1,
"Lightcast Open Skills Taxonomy": 1,
"MultiOrgTaxonomy": 1,
"TwoLevelTaxonomy": 2
}
},
"facetStats": {}
Expand Down
2 changes: 1 addition & 1 deletion src/search-modal/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const messages = defineMessages({
},
childTagsCollapse: {
id: 'course-authoring.course-search.child-tags-collapse',
defaultMessage: 'Collapse to hige child tags of "{tagName}"',
defaultMessage: 'Collapse to hide child tags of "{tagName}"',
description: 'This text describes the ▲ collapse toggle button to non-visual users.',
},
clearFilters: {
Expand Down

0 comments on commit 2cdbcd8

Please sign in to comment.