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