diff --git a/src/generic/data/api.mock.ts b/src/generic/data/api.mock.ts index a6ec5bf47a..cc01da38fd 100644 --- a/src/generic/data/api.mock.ts +++ b/src/generic/data/api.mock.ts @@ -38,3 +38,12 @@ export async function mockClipboardHtml(): Promise { } 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); +} diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 6a5cac0912..31747b505a 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -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'; @@ -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('', () => { 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) => { diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx index 69648180c3..5a386f01aa 100644 --- a/src/library-authoring/add-content/AddContentContainer.test.tsx +++ b/src/library-authoring/add-content/AddContentContainer.test.tsx @@ -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('', () => { it('should render content buttons', () => { initializeMocks(); diff --git a/src/library-authoring/add-content/AddContentWorkflow.test.tsx b/src/library-authoring/add-content/AddContentWorkflow.test.tsx new file mode 100644 index 0000000000..555174e2fd --- /dev/null +++ b/src/library-authoring/add-content/AddContentWorkflow.test.tsx @@ -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(() => () => '

Edited HTML content

'); +jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); + +const { libraryId } = mockContentLibrary; +const renderOpts = { + // Mount the 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(, 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); + }); +}); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 863d25607c..cb744173b8 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -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 { + 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()` @@ -117,13 +141,23 @@ export async function mockXBlockFields(usageKey: string): PromiseThis is a text component which uses HTML.

', 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); diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts new file mode 100644 index 0000000000..dfcc9584ae --- /dev/null +++ b/src/search-manager/data/api.mock.ts @@ -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 { + 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 ... 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 }); +}