Skip to content

Commit

Permalink
fix: add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Jun 7, 2024
1 parent e3ebc55 commit 72edfac
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 8 deletions.
4 changes: 1 addition & 3 deletions src/library-authoring/CreateLibrary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import SubHeader from '../generic/sub-header/SubHeader';

import messages from './messages';

/**
* @type {React.FC}
*/
/* istanbul ignore next This is only a placeholder component */
const CreateLibrary = () => (
<>
<Header isHiddenMainMenu />
Expand Down
1 change: 1 addition & 0 deletions src/library-authoring/EmptyStates.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Expand Down
10 changes: 7 additions & 3 deletions src/library-authoring/LibraryAuthoringPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/* eslint-disable react/prop-types */
import React, { useEffect } from 'react';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Container, Icon, IconButton, SearchField, Tab, Tabs,
} from '@openedx/paragon';
Expand Down Expand Up @@ -30,7 +30,12 @@ const TAB_LIST = {
const SubHeaderTitle = ({ title }) => (
<>
{title}
<IconButton src={InfoOutline} iconAs={Icon} alt="Info" onClick={() => {}} className="mr-2" />
<IconButton
src={InfoOutline}
iconAs={Icon}
alt={<FormattedMessage {...messages.headingInfoAlt} />}
className="mr-2"
/>
</>
);

Expand Down Expand Up @@ -90,7 +95,6 @@ const LibraryAuthoringPage = () => {
<SearchField
value={searchKeywords}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
onSubmit={(value) => setSearchKeywords(value)}
onChange={(value) => setSearchKeywords(value)}
className="w-50"
/>
Expand Down
237 changes: 237 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// @ts-check
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';

import initializeStore from '../store';
import { getContentSearchConfigUrl } from '../search-modal/data/api';
import mockResult from '../search-modal/__mocks__/search-result.json';
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import LibraryAuthoringPage from './LibraryAuthoringPage';
import { getContentLibraryApiUrl } from './data/api';

let store;
const mockUseParams = jest.fn();
let axiosMock;

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useParams: () => mockUseParams(),
}));

const searchEndpoint = 'http://mock.meilisearch.local/multi-search';

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

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 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) => { hit._formatted = { ...hit }; });
return mockEmptyResult;
};

const libraryData = {
id: 'lib:org1:lib1',
type: 'complex',
org: 'org1',
slug: 'lib1',
title: 'lib1',
description: 'lib1',
numBlocks: 2,
version: 0,
lastPublished: null,
allowLti: false,
allowPublic_learning: false,
allowPublic_read: false,
hasUnpublished_changes: true,
hasUnpublished_deletes: false,
license: '',
};

const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<LibraryAuthoringPage />
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);

describe('<LibraryAuthoringPage />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
mockUseParams.mockReturnValue({ libraryId: '1' });

// 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;
});
});

afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
fetchMock.mockReset();
queryClient.clear();
});

it('shows the spinner before the query is complete', () => {
mockUseParams.mockReturnValue({ libraryId: '1' });
// @ts-ignore Use unresolved promise to keep the Loading visible
axiosMock.onGet(getContentLibraryApiUrl('1')).reply(() => new Promise());
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});

it('shows an error component if no library returned', async () => {
mockUseParams.mockReturnValue({ libraryId: 'invalid' });
axiosMock.onGet(getContentLibraryApiUrl('invalid')).reply(400);

const { findByTestId } = render(<RootWrapper />);

expect(await findByTestId('notFoundAlert')).toBeInTheDocument();
});

it('shows an error component if no library param', async () => {
mockUseParams.mockReturnValue({ libraryId: '' });

const { findByTestId } = render(<RootWrapper />);

expect(await findByTestId('notFoundAlert')).toBeInTheDocument();
});

it('show library data', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

const {
getByRole, getByText, queryByText,
} = render(<RootWrapper />);

// Ensure the search endpoint is called
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });

expect(getByText('Content library')).toBeInTheDocument();
expect(getByText(libraryData.title)).toBeInTheDocument();

expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();

expect(getByText('Recently Modified')).toBeInTheDocument();
expect(getByText('Collections (0)')).toBeInTheDocument();
expect(getByText('Components (6)')).toBeInTheDocument();
expect(getByText('There are 6 components in this library')).toBeInTheDocument();

// Navigate to the components tab
fireEvent.click(getByRole('tab', { name: 'Components' }));
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
expect(queryByText('Components (6)')).not.toBeInTheDocument();
expect(getByText('There are 6 components in this library')).toBeInTheDocument();

// Navigate to the collections tab
fireEvent.click(getByRole('tab', { name: 'Collections' }));
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
expect(queryByText('Components (6)')).not.toBeInTheDocument();
expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument();
expect(getByText('Coming soon!')).toBeInTheDocument();

// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(getByRole('tab', { name: 'Home' }));
expect(getByText('Recently Modified')).toBeInTheDocument();
expect(getByText('Collections (0)')).toBeInTheDocument();
expect(getByText('Components (6)')).toBeInTheDocument();
expect(getByText('There are 6 components in this library')).toBeInTheDocument();
});

it('show library without components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });

const { findByText, getByText } = render(<RootWrapper />);

expect(await findByText('Content library')).toBeInTheDocument();
expect(await findByText(libraryData.title)).toBeInTheDocument();

// Ensure the search endpoint is called
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });

expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
});

it('show library without search results', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });

const { findByText, getByRole, getByText } = render(<RootWrapper />);

expect(await findByText('Content library')).toBeInTheDocument();
expect(await findByText(libraryData.title)).toBeInTheDocument();

// Ensure the search endpoint is called
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });

fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } });

// Ensure the search endpoint is called again
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });

expect(getByText('No matching components found in this library.')).toBeInTheDocument();

// Navigate to the components tab
fireEvent.click(getByRole('tab', { name: 'Components' }));
expect(getByText('No matching components found in this library.')).toBeInTheDocument();

// Go back to Home tab
// This step is necessary to avoid the url change leak to other tests
fireEvent.click(getByRole('tab', { name: 'Home' }));
});
});
3 changes: 1 addition & 2 deletions src/library-authoring/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
* Get the URL for the content library API.
* @param {string} libraryId - The ID of the library to fetch.
*/
const getContentLibraryApiUrl = (libraryId) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`;
export const getContentLibraryApiUrl = (libraryId) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`;

/**
* Fetch a content library by its ID.
* @param {string} [libraryId] - The ID of the library to fetch.
* @returns {Promise<import("./types.mjs").ContentLibrary>}
*/
/* eslint-disable import/prefer-default-export */
export async function getContentLibrary(libraryId) {
if (!libraryId) {
throw new Error('libraryId is required');
Expand Down
5 changes: 5 additions & 0 deletions src/library-authoring/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const messages = defineMessages({
defaultMessage: 'Content library',
description: 'The page heading for the library page.',
},
headingInfoAlt: {
id: 'course-authoring.library-authoring.heading-info-alt',
defaultMessage: 'Info',
description: 'Alt text for the info icon next to the page heading.',
},
searchPlaceholder: {
id: 'course-authoring.library-authoring.search',
defaultMessage: 'Search...',
Expand Down

0 comments on commit 72edfac

Please sign in to comment.