diff --git a/src/generic/user-messages/Alert.test.jsx b/src/generic/user-messages/Alert.test.jsx
new file mode 100644
index 00000000..0e08ffde
--- /dev/null
+++ b/src/generic/user-messages/Alert.test.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import {
+ render, screen, fireEvent, initializeMockApp,
+} from '../../setupTest';
+import { Alert, ALERT_TYPES } from './index';
+
+describe('Alert', () => {
+ const types = {
+ [ALERT_TYPES.ERROR]: {
+ alert_class: 'alert-warning',
+ icon: 'fa-exclamation-triangle',
+ },
+ [ALERT_TYPES.DANGER]: {
+ alert_class: 'alert-danger',
+ icon: 'fa-minus-circle',
+ },
+ [ALERT_TYPES.SUCCESS]: {
+ alert_class: 'alert-success',
+ icon: 'fa-check-circle',
+ },
+ [ALERT_TYPES.INFO]: {
+ alert_class: 'alert-info',
+ icon: 'fa-info-circle',
+ },
+ };
+
+ beforeAll(async () => {
+ // We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
+ await initializeMockApp();
+ });
+
+ Object.entries(types).forEach(([alert, properties]) => {
+ it(`renders ${alert} alert`, () => {
+ const alertContent = 'Test alert.';
+ const { container } = render(
{alertContent});
+
+ expect(container.firstChild).toHaveClass(properties.alert_class);
+ expect(container.querySelector('svg')).toHaveClass(properties.icon);
+ expect(screen.getByText(alertContent)).toBeInTheDocument();
+ });
+ });
+
+ it('is dismissible', () => {
+ const onDismiss = jest.fn();
+ const { container } = render(
);
+
+ expect(container.firstChild).toHaveClass('alert-dismissible');
+
+ const dismissButton = screen.getByRole('button');
+ expect(container.querySelector('svg')).toHaveClass('fa-exclamation-triangle');
+
+ fireEvent.click(dismissButton);
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/generic/user-messages/AlertList.test.jsx b/src/generic/user-messages/AlertList.test.jsx
new file mode 100644
index 00000000..8090ee56
--- /dev/null
+++ b/src/generic/user-messages/AlertList.test.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { initializeMockApp, render } from '../../setupTest';
+import { AlertList } from './index';
+
+describe('Alert List', () => {
+ beforeAll(async () => {
+ // We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
+ await initializeMockApp();
+ });
+
+ it('renders empty div by default', () => {
+ const { container } = render(
);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ // FIXME: Currently these alerts are tested in `OutlineTab.test` and `Course.test`, because creating
+ // `UserMessagesProvider` for testing would introduce a lot of boilerplate code that could get outdated quickly.
+});
diff --git a/src/instructor-toolbar/InstructorToolbar.jsx b/src/instructor-toolbar/InstructorToolbar.jsx
index 14b8f5f2..99dc0f96 100644
--- a/src/instructor-toolbar/InstructorToolbar.jsx
+++ b/src/instructor-toolbar/InstructorToolbar.jsx
@@ -43,7 +43,7 @@ export default function InstructorToolbar(props) {
const urlInsights = getInsightsUrl(courseId);
const urlLms = useSelector((state) => {
if (!unitId) {
- return {};
+ return undefined;
}
const activeUnit = state.models.units[props.unitId];
diff --git a/src/instructor-toolbar/InstructorToolbar.test.jsx b/src/instructor-toolbar/InstructorToolbar.test.jsx
new file mode 100644
index 00000000..515029e1
--- /dev/null
+++ b/src/instructor-toolbar/InstructorToolbar.test.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import { getConfig } from '@edx/frontend-platform';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import {
+ initializeTestStore, render, screen, waitFor, getByText, logUnhandledRequests,
+} from '../setupTest';
+import InstructorToolbar from './index';
+
+const originalConfig = jest.requireActual('@edx/frontend-platform').getConfig();
+jest.mock('@edx/frontend-platform', () => ({
+ ...jest.requireActual('@edx/frontend-platform'),
+ getConfig: jest.fn(),
+}));
+getConfig.mockImplementation(() => originalConfig);
+
+describe('Instructor Toolbar', () => {
+ let mockData;
+ let axiosMock;
+ let masqueradeUrl;
+
+ beforeAll(async () => {
+ const store = await initializeTestStore({ excludeFetchSequence: true });
+ const { courseware, models } = store.getState();
+ mockData = {
+ courseId: courseware.courseId,
+ unitId: Object.values(models.units)[0].id,
+ };
+
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ masqueradeUrl = `${getConfig().LMS_BASE_URL}/courses/${courseware.courseId}/masquerade`;
+ });
+
+ beforeEach(() => {
+ axiosMock.reset();
+ axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
+ logUnhandledRequests(axiosMock);
+ });
+
+ it('sends query to masquerade and does not display alerts by default', async () => {
+ render(
);
+
+ await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('displays masquerade error', async () => {
+ axiosMock.reset();
+ axiosMock.onGet(masqueradeUrl).reply(200, { success: false });
+ render(
);
+
+ await waitFor(() => expect(axiosMock.history.get).toHaveLength(1));
+ expect(screen.getByRole('alert')).toHaveTextContent('Unable to get masquerade options');
+ });
+
+ it('displays links to view course in different services', () => {
+ const config = { ...originalConfig };
+ config.INSIGHTS_BASE_URL = 'http://localhost:18100';
+ getConfig.mockImplementation(() => config);
+ render(
);
+
+ const linksContainer = screen.getByText('View course in:').parentElement;
+ ['Legacy experience', 'Studio', 'Insights'].forEach(service => {
+ expect(getByText(linksContainer, service).getAttribute('href')).toMatch(/http.*/);
+ });
+ });
+
+ it('does not display links if there are no services available', () => {
+ const config = { ...originalConfig };
+ config.STUDIO_BASE_URL = undefined;
+ getConfig.mockImplementation(() => config);
+ render(
);
+
+ expect(screen.queryByText('View course in:')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/setupTest.js b/src/setupTest.js
index 3406084c..349ed6d3 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -1,6 +1,8 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
+import '@testing-library/jest-dom/extend-expect';
+import 'jest-chain';
import './courseware/data/__factories__';
import './course-home/data/__factories__';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
@@ -34,7 +36,14 @@ window.getComputedStyle = jest.fn(() => ({
getPropertyValue: jest.fn(),
}));
-export default function initializeMockApp() {
+export const authenticatedUser = {
+ userId: 'abc123',
+ username: 'Mock User',
+ roles: [],
+ administrator: false,
+};
+
+export function initializeMockApp() {
mergeConfig({
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
@@ -79,6 +88,15 @@ export function loadUnit(message = messageEvent) {
window.postMessage(message, '*');
}
+// Helper function to log unhandled API requests to the console while running tests.
+export function logUnhandledRequests(axiosMock) {
+ axiosMock.onAny().reply((config) => {
+ // eslint-disable-next-line no-console
+ console.log(config.method, config.url);
+ return [200, {}];
+ });
+}
+
let globalStore;
export async function initializeTestStore(options = {}, overrideStore = true) {
@@ -110,11 +128,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
axiosMock.onGet(sequenceMetadataUrl).reply(200, metadata);
});
- axiosMock.onAny().reply((config) => {
- // eslint-disable-next-line no-console
- console.log(config.url);
- return [200, {}];
- });
+ logUnhandledRequests(axiosMock);
// eslint-disable-next-line no-unused-expressions
!options.excludeFetchCourse && await executeThunk(fetchCourse(courseMetadata.id), store.dispatch);
diff --git a/src/tab-page/LoadedTabPage.test.jsx b/src/tab-page/LoadedTabPage.test.jsx
new file mode 100644
index 00000000..b098f681
--- /dev/null
+++ b/src/tab-page/LoadedTabPage.test.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Factory } from 'rosie';
+import { initializeTestStore, render, screen } from '../setupTest';
+import LoadedTabPage from './LoadedTabPage';
+
+jest.mock('../course-header/CourseTabsNavigation', () => () =>
);
+jest.mock('../instructor-toolbar/InstructorToolbar', () => () =>
);
+
+describe('Loaded Tab Page', () => {
+ const mockData = { activeTabSlug: 'dummy' };
+
+ beforeAll(async () => {
+ const store = await initializeTestStore({ excludeFetchSequence: true });
+ mockData.courseId = store.getState().courseware.courseId;
+ });
+
+ it('renders correctly', () => {
+ render(
);
+
+ expect(screen.queryByTestId('CourseTabsNavigation')).toBeInTheDocument();
+ expect(screen.queryByTestId('InstructorToolbar')).not.toBeInTheDocument();
+ });
+
+ it('shows Instructor Toolbar if original user is staff', async () => {
+ const courseMetadata = Factory.build('courseMetadata', { original_user_is_staff: true });
+ const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
+ render(
, { store: testStore });
+
+ expect(screen.getByTestId('InstructorToolbar')).toBeInTheDocument();
+ });
+});
diff --git a/src/tab-page/TabContainer.test.jsx b/src/tab-page/TabContainer.test.jsx
new file mode 100644
index 00000000..ef973e59
--- /dev/null
+++ b/src/tab-page/TabContainer.test.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { history } from '@edx/frontend-platform';
+import { Route } from 'react-router';
+import { initializeTestStore, render, screen } from '../setupTest';
+import { TabContainer } from './index';
+
+const mockDispatch = jest.fn();
+const mockFetch = jest.fn().mockImplementation((x) => x);
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: () => mockDispatch,
+}));
+jest.mock('./TabPage', () => () =>
);
+
+describe('Tab Container', () => {
+ const mockData = {
+ children: [],
+ fetch: mockFetch,
+ tab: 'dummy',
+ };
+ let courseId;
+
+ beforeAll(async () => {
+ const store = await initializeTestStore({ excludeFetchSequence: true });
+ courseId = store.getState().courseware.courseId;
+ });
+
+ it('renders correctly', () => {
+ history.push(`/course/${courseId}`);
+ render(
+
+
+ ,
+ );
+
+ expect(mockFetch)
+ .toHaveBeenCalledTimes(1)
+ .toHaveBeenCalledWith(courseId);
+ expect(mockDispatch)
+ .toHaveBeenCalledTimes(1)
+ .toHaveBeenCalledWith(courseId);
+ expect(screen.getByTestId('TabPage')).toBeInTheDocument();
+ });
+});
diff --git a/src/tab-page/TabPage.test.jsx b/src/tab-page/TabPage.test.jsx
new file mode 100644
index 00000000..bc5793d1
--- /dev/null
+++ b/src/tab-page/TabPage.test.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { getConfig } from '@edx/frontend-platform';
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import {
+ initializeTestStore, logUnhandledRequests, render, screen,
+} from '../setupTest';
+import { TabPage } from './index';
+import executeThunk from '../utils';
+import * as thunks from '../course-home/data/thunks';
+
+// We should not test `LoadedTabPage` page here, as `TabPage` is used only for passing `passthroughProps`.
+jest.mock('./LoadedTabPage', () => () =>
);
+
+describe('Tab Page', () => {
+ const mockData = {
+ courseStatus: 'loaded',
+ };
+
+ beforeAll(async () => {
+ await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
+ });
+
+ it('displays loading message', () => {
+ render(
);
+ expect(screen.getByText('Loading course pageā¦')).toBeInTheDocument();
+ });
+
+ it('displays loading failure message', () => {
+ render(
);
+ expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument();
+ });
+
+ it('displays Learning Toast', async () => {
+ const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
+ render(
, { store: testStore });
+
+ const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onPost(resetUrl).reply(201, {
+ link: 'test-toast-link',
+ link_text: 'test-toast-body',
+ header: 'test-toast-header',
+ });
+ logUnhandledRequests(axiosMock);
+
+ const getTabDataMock = jest.fn(() => ({
+ type: 'MOCK_ACTION',
+ }));
+
+ await executeThunk(thunks.resetDeadlines('courseId', getTabDataMock), testStore.dispatch);
+
+ expect(screen.getByText('test-toast-header')).toBeInTheDocument();
+ expect(screen.getByText('test-toast-body')).toBeInTheDocument();
+ });
+
+ it('displays Loaded Tab Page', () => {
+ render(
);
+ expect(screen.getByTestId('LoadedTabPage')).toBeInTheDocument();
+ });
+});