Skip to content

Commit

Permalink
test: workflow test for editor
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed Sep 10, 2024
1 parent a455bf0 commit 3ef8fce
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 38 deletions.
9 changes: 9 additions & 0 deletions src/generic/data/api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,12 @@ export async function mockClipboardHtml(): Promise<api.ClipboardStatus> {
}
mockClipboardHtml.applyMock = () => jest.spyOn(api, 'getClipboard').mockImplementation(mockClipboardHtml);
mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml);

/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */
export function mockBroadcastChannel() {
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
}
21 changes: 5 additions & 16 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import {
waitFor,
within,
} from '../testUtils';
import { getContentSearchConfigUrl } from '../search-manager/data/api';
import mockResult from './__mocks__/library-search.json';
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './data/api.mocks';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { mockBroadcastChannel } from '../generic/data/api.mock';
import { LibraryLayout } from '.';

mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockLibraryBlockTypes.applyMock();
mockXBlockFields.applyMock();
mockBroadcastChannel();

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

Expand Down Expand Up @@ -55,26 +58,12 @@ const returnLowNumberResults = (_url, req) => {
return newMockResult;
};

const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};

(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);

const path = '/library/:libraryId/*';
const libraryTitle = mockContentLibrary.libraryData.title;

describe('<LibraryAuthoringPage />', () => {
beforeEach(() => {
const { axiosMock } = initializeMocks();

// The API method to get the Meilisearch connection details uses Axios:
axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
url: 'http://mock.meilisearch.local',
index_name: 'studio',
api_key: 'test-key',
});
initializeMocks();

// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
Expand Down
11 changes: 3 additions & 8 deletions src/library-authoring/add-content/AddContentContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,14 @@ import {
} from '../../testUtils';
import { mockContentLibrary } from '../data/api.mocks';
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import AddContentContainer from './AddContentContainer';

mockBroadcastChannel();

const { libraryId } = mockContentLibrary;
const renderOpts = { path: '/library/:libraryId/*', params: { libraryId } };

const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};

(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);

describe('<AddContentContainer />', () => {
it('should render content buttons', () => {
initializeMocks();
Expand Down
89 changes: 89 additions & 0 deletions src/library-authoring/add-content/AddContentWorkflow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Test the whole workflow of adding content, editing it, saving it
*/
import { snakeCaseObject } from '@edx/frontend-platform';
import {
fireEvent,
render,
waitFor,
screen,
initializeMocks,
} from '../../testUtils';
import mockResult from '../__mocks__/library-search.json';
import editorCmsApi from '../../editors/data/services/cms/api';
import * as textEditorHooks from '../../editors/containers/TextEditor/hooks';
import {
mockContentLibrary,
mockCreateLibraryBlock,
mockLibraryBlockTypes,
mockXBlockFields,
} from '../data/api.mocks';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
import LibraryLayout from '../LibraryLayout';

mockContentSearchConfig.applyMock();
mockLibraryBlockTypes.applyMock();
mockClipboardEmpty.applyMock();
mockBroadcastChannel();
mockContentLibrary.applyMock();
mockCreateLibraryBlock.applyMock();
mockSearchResult(mockResult);
// Mocking the redux APIs in the src/editors/ folder is a bit more involved:
jest.spyOn(editorCmsApi as any, 'fetchBlockById').mockImplementation(
async (args: { blockId: string }) => (
{ status: 200, data: snakeCaseObject(await mockXBlockFields(args.blockId)) }
),
);
jest.spyOn(textEditorHooks, 'getContent').mockImplementation(() => () => '<p>Edited HTML content</p>');
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));

const { libraryId } = mockContentLibrary;
const renderOpts = {
// Mount the <LibraryLayout /> on this route, to simulate how it's mounted in the real app:
path: '/library/:libraryId/*',
// And set the current URL to the following:
routerProps: { initialEntries: [`/library/${libraryId}/components`] },
};

describe('AddContentWorkflow test', () => {
beforeEach(() => {
initializeMocks();
});

it('can create an HTML component', async () => {
render(<LibraryLayout />, renderOpts);

// Click "New [Component]"
const newComponentButton = await screen.findByRole('button', { name: /New/ });
fireEvent.click(newComponentButton);

// Click "Text" to create a text component
fireEvent.click(await screen.findByRole('button', { name: /Text/ }));

// Then the editor should open
expect(await screen.findByRole('heading', { name: /New Text Component/ })).toBeInTheDocument();

// Edit the title
fireEvent.click(screen.getByRole('button', { name: /Edit Title/ }));
const titleInput = screen.getByPlaceholderText('Title');
fireEvent.change(titleInput, { target: { value: 'A customized title' } });
fireEvent.blur(titleInput);
await waitFor(() => expect(screen.queryByRole('heading', { name: /New Text Component/ })).not.toBeInTheDocument());
expect(screen.getByRole('heading', { name: /A customized title/ }));

// Note that TinyMCE doesn't really load properly in our test environment
// so we can't really edit the text, but we have getContent() mocked to simulate
// using TinyMCE to enter some new HTML.

// Mock the save() REST API method:
const saveSpy = jest.spyOn(editorCmsApi as any, 'saveBlock').mockImplementationOnce(async () => ({
status: 200, data: { id: mockXBlockFields.usageKeyNewHtml },
}));

// Click Save
const saveButton = screen.getByLabelText('Save changes and return to learning context');
fireEvent.click(saveButton);
expect(saveSpy).toHaveBeenCalledTimes(1);
});
});
38 changes: 36 additions & 2 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,31 @@ mockContentLibrary.libraryIdReadOnly = 'lib:Axim:readOnly';
mockContentLibrary.libraryIdThatNeverLoads = 'lib:Axim:infiniteLoading';
mockContentLibrary.library404 = 'lib:Axim:error404';
mockContentLibrary.library500 = 'lib:Axim:error500';
mockContentLibrary.applyMock = () => { jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary); };
mockContentLibrary.applyMock = () => jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary);

/**
* Mock for `createLibraryBlock()`
*/
export async function mockCreateLibraryBlock(
args: api.CreateBlockDataRequest,
): ReturnType<typeof api.createLibraryBlock> {
if (args.blockType === 'html' && args.libraryId === mockContentLibrary.libraryId) {
return mockCreateLibraryBlock.newHtmlData;
}
throw new Error(`mockCreateLibraryBlock doesn't know how to mock ${JSON.stringify(args)}`);
}
mockCreateLibraryBlock.newHtmlData = {
id: 'lb:Axim:TEST:html:123',
defKey: '123',
blockType: 'html',
displayName: 'New Text Component',
hasUnpublishedChanges: true,
tagsCount: 0,
} satisfies api.CreateBlockDataResponse;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockCreateLibraryBlock.applyMock = () => (
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
);

/**
* Mock for `getXBlockFields()`
Expand All @@ -117,13 +141,23 @@ export async function mockXBlockFields(usageKey: string): Promise<api.XBlockFiel
const thisMock = mockXBlockFields;
switch (usageKey) {
case thisMock.usageKeyHtml: return thisMock.dataHtml;
case thisMock.usageKeyNewHtml: return thisMock.dataNewHtml;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
}
// Mock of a "regular" HTML (Text) block:
mockXBlockFields.usageKeyHtml = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
mockXBlockFields.dataHtml = {
displayName: 'Introduction to Testing',
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
metadata: { displayName: 'Introduction to Testing' },
} satisfies api.XBlockFields;
mockXBlockFields.applyMock = () => { jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields); };
// Mock of a blank/new HTML (Text) block:
mockXBlockFields.usageKeyNewHtml = 'lb:Axim:TEST:html:123';
mockXBlockFields.dataNewHtml = {
displayName: 'New Text Component',
data: '',
metadata: { displayName: 'New Text Component' },
} satisfies api.XBlockFields;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);
42 changes: 42 additions & 0 deletions src/search-manager/data/api.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* istanbul ignore file */
// eslint-disable-next-line import/no-extraneous-dependencies
import fetchMock from 'fetch-mock-jest';
import type { MultiSearchResponse } from 'meilisearch';
import * as api from './api';

/**
* Mock getContentSearchConfig()
*/
export async function mockContentSearchConfig(): ReturnType<typeof api.getContentSearchConfig> {
return {
url: 'http://mock.meilisearch.local',
indexName: 'studio',
apiKey: 'test-key',
};
}
mockContentSearchConfig.searchEndpointUrl = 'http://mock.meilisearch.local/multi-search';
mockContentSearchConfig.applyMock = () => (
jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig)
);

/**
* Mock all future Meilisearch searches with the given response.
*
* For a given test suite, this mock will stay in effect until you call it with
* a different mock response, or you call `fetchMock.mockReset()`
*/
export function mockSearchResult(mockResponse: MultiSearchResponse) {
fetchMock.post(mockContentSearchConfig.searchEndpointUrl, (_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.
const newMockResponse = { ...mockResponse };
newMockResponse.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
mockResponse.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResponse;
}, { overwriteRoutes: true });
}
40 changes: 28 additions & 12 deletions src/testUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderResult } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import {
MemoryRouter,
MemoryRouterProps,
Route,
Routes,
} from 'react-router-dom';

import initializeReduxStore from './store';

Expand All @@ -28,29 +33,40 @@ export interface RouteOptions {
path?: string;
/** The URL parameters, like {libraryId: 'lib:org:123'} */
params?: Record<string, string>;
/** and/or instead of specifying path and params, specify MemoryRouterProps */
routerProps?: MemoryRouterProps;
}

const RouterAndRoute: React.FC<RouteOptions> = ({ children, path = '/', params = {} }) => {
const RouterAndRoute: React.FC<RouteOptions> = ({
children,
path = '/',
params = {},
routerProps = {},
}) => {
if (Object.entries(params).length > 0 || path !== '/') {
// Substitute the params into the URL so '/library/:libraryId' becomes '/library/lib:org:123'
let pathWithParams = path;
for (const [key, value] of Object.entries(params)) {
pathWithParams = pathWithParams.replaceAll(`:${key}`, value);
}
if (pathWithParams.endsWith('/*')) {
// Some routes (that contain child routes) need to end with /* in the <Route> but not in the router
pathWithParams = pathWithParams.substring(0, pathWithParams.length - 1);
const newRouterProps = { ...routerProps };
if (!routerProps.initialEntries) {
// Substitute the params into the URL so '/library/:libraryId' becomes '/library/lib:org:123'
let pathWithParams = path;
for (const [key, value] of Object.entries(params)) {
pathWithParams = pathWithParams.replaceAll(`:${key}`, value);
}
if (pathWithParams.endsWith('/*')) {
// Some routes (that contain child routes) need to end with /* in the <Route> but not in the router
pathWithParams = pathWithParams.substring(0, pathWithParams.length - 1);
}
newRouterProps.initialEntries = [pathWithParams];
}
return (
<MemoryRouter initialEntries={[pathWithParams]}>
<MemoryRouter {...newRouterProps}>
<Routes>
<Route path={path} element={children} />
</Routes>
</MemoryRouter>
);
}
return (
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter {...routerProps}>{children}</MemoryRouter>
);
};

Expand Down

0 comments on commit 3ef8fce

Please sign in to comment.