This is effectively fixing merge conflicts between: https://github.com/edx/frontend-app-learning/pull/128 and: https://github.com/edx/frontend-app-learning/pull/97
379 lines
15 KiB
JavaScript
379 lines
15 KiB
JavaScript
import { getConfig, history } from '@edx/frontend-platform';
|
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
import { AppProvider } from '@edx/frontend-platform/react';
|
|
import { waitForElementToBeRemoved } from '@testing-library/dom';
|
|
import '@testing-library/jest-dom/extend-expect';
|
|
import { render, screen } from '@testing-library/react';
|
|
import React from 'react';
|
|
import { Route, Switch } from 'react-router';
|
|
import { Factory } from 'rosie';
|
|
import MockAdapter from 'axios-mock-adapter';
|
|
|
|
import { UserMessagesProvider } from '../generic/user-messages';
|
|
import tabMessages from '../tab-page/messages';
|
|
import initializeMockApp from '../setupTest';
|
|
|
|
import CoursewareContainer from './CoursewareContainer';
|
|
import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory';
|
|
import initializeStore from '../store';
|
|
|
|
// NOTE: Because the unit creates an iframe, we choose to mock it out as its rendering isn't
|
|
// pertinent to this test. Instead, we render a simple div that displays the properties we expect
|
|
// to have been passed into the component. Separate tests can handle unit rendering, but this
|
|
// proves that the component is rendered and receives the correct props. We probably COULD render
|
|
// Unit.jsx and its iframe in this test, but it's already complex enough.
|
|
function MockUnit({ courseId, id }) { // eslint-disable-line react/prop-types
|
|
return (
|
|
<div className="fake-unit">Unit Contents {courseId} {id}</div>
|
|
);
|
|
}
|
|
|
|
jest.mock(
|
|
'./course/sequence/Unit',
|
|
() => MockUnit,
|
|
);
|
|
|
|
initializeMockApp();
|
|
|
|
describe('CoursewareContainer', () => {
|
|
let store;
|
|
let component;
|
|
let axiosMock;
|
|
|
|
beforeEach(() => {
|
|
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
|
|
|
store = initializeStore();
|
|
|
|
component = (
|
|
<AppProvider store={store}>
|
|
<UserMessagesProvider>
|
|
<Switch>
|
|
<Route
|
|
path={[
|
|
'/course/:courseId/:sequenceId/:unitId',
|
|
'/course/:courseId/:sequenceId',
|
|
'/course/:courseId',
|
|
]}
|
|
component={CoursewareContainer}
|
|
/>
|
|
</Switch>
|
|
</UserMessagesProvider>
|
|
</AppProvider>
|
|
);
|
|
});
|
|
|
|
it('should initialize to show a spinner', () => {
|
|
history.push('/course/abc123');
|
|
render(component);
|
|
|
|
const spinner = screen.getByRole('status');
|
|
|
|
expect(spinner.firstChild).toContainHTML(
|
|
`<span class="sr-only">${tabMessages.loading.defaultMessage}</span>`,
|
|
);
|
|
});
|
|
|
|
describe('when receiving successful course data', () => {
|
|
let courseId;
|
|
let courseMetadata;
|
|
let courseBlocks;
|
|
let sequenceMetadata;
|
|
|
|
let sequenceBlock;
|
|
let unitBlocks;
|
|
|
|
function assertLoadedHeader(container) {
|
|
const courseHeader = container.querySelector('.course-header');
|
|
// Ensure the course number and org appear - this proves we loaded course metadata properly.
|
|
expect(courseHeader).toHaveTextContent(courseMetadata.number);
|
|
expect(courseHeader).toHaveTextContent(courseMetadata.org);
|
|
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
|
|
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
|
|
}
|
|
|
|
function assertSequenceNavigation(container) {
|
|
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
|
|
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
|
expect(sequenceNavButtons).toHaveLength(5);
|
|
|
|
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
|
|
// Prove this button is rendering an SVG tasks icon, meaning it's a unit/vertical.
|
|
expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-tasks');
|
|
expect(sequenceNavButtons[4]).toHaveTextContent('Next');
|
|
}
|
|
|
|
function setupMockRequests() {
|
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`).reply(200, courseMetadata);
|
|
axiosMock.onGet(new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`)).reply(200, courseBlocks);
|
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`).reply(200, sequenceMetadata);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
// On page load, SequenceContext attempts to scroll to the top of the page.
|
|
global.scrollTo = jest.fn();
|
|
|
|
courseMetadata = Factory.build('courseMetadata');
|
|
courseId = courseMetadata.id;
|
|
|
|
const customUnitBlocks = [
|
|
Factory.build(
|
|
'block',
|
|
{ type: 'vertical' },
|
|
{ courseId },
|
|
),
|
|
Factory.build(
|
|
'block',
|
|
{ type: 'vertical' },
|
|
{ courseId },
|
|
),
|
|
Factory.build(
|
|
'block',
|
|
{ type: 'vertical' },
|
|
{ courseId },
|
|
),
|
|
];
|
|
|
|
const result = buildSimpleCourseBlocks(courseId, courseMetadata.name, { unitBlocks: customUnitBlocks });
|
|
courseBlocks = result.courseBlocks;
|
|
unitBlocks = result.unitBlocks;
|
|
// eslint-disable-next-line prefer-destructuring
|
|
sequenceBlock = result.sequenceBlock[0];
|
|
|
|
sequenceMetadata = Factory.build(
|
|
'sequenceMetadata',
|
|
{},
|
|
{ courseId, unitBlocks, sequenceBlock },
|
|
);
|
|
|
|
setupMockRequests();
|
|
});
|
|
|
|
describe('when the URL only contains a course ID', () => {
|
|
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
|
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
|
|
sectionId: sequenceBlock.id,
|
|
unitId: unitBlocks[1].id,
|
|
});
|
|
|
|
history.push(`/course/${courseId}`);
|
|
const { container } = render(component);
|
|
|
|
// This is an important line that ensures the spinner has been removed - and thus our main
|
|
// content has been loaded - prior to proceeding with our expectations.
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
assertLoadedHeader(container);
|
|
assertSequenceNavigation(container);
|
|
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].id);
|
|
});
|
|
|
|
it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
|
|
// OVERRIDE SEQUENCE METADATA:
|
|
// set the position to the third unit so we can prove activeUnitIndex is working
|
|
sequenceMetadata = Factory.build(
|
|
'sequenceMetadata',
|
|
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
|
|
{ courseId, unitBlocks, sequenceBlock },
|
|
);
|
|
|
|
// Re-call the mock setup now that sequenceMetadata is different.
|
|
setupMockRequests();
|
|
// Note how there is no sectionId/unitId returned in this mock response!
|
|
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
|
|
|
|
history.push(`/course/${courseId}`);
|
|
const { container } = render(component);
|
|
|
|
// This is an important line that ensures the spinner has been removed - and thus our main
|
|
// content has been loaded - prior to proceeding with our expectations.
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
assertLoadedHeader(container);
|
|
assertSequenceNavigation(container);
|
|
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
|
});
|
|
});
|
|
|
|
describe('when the URL contains a course ID and sequence ID', () => {
|
|
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
|
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
|
const { container } = render(component);
|
|
|
|
// This is an important line that ensures the spinner has been removed - and thus our main
|
|
// content has been loaded - prior to proceeding with our expectations.
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
assertLoadedHeader(container);
|
|
assertSequenceNavigation(container);
|
|
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].id);
|
|
});
|
|
|
|
it('should use activeUnitIndex to pick a unit from the sequence', async () => {
|
|
// OVERRIDE SEQUENCE METADATA:
|
|
sequenceMetadata = Factory.build(
|
|
'sequenceMetadata',
|
|
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
|
|
{ courseId, unitBlocks, sequenceBlock },
|
|
);
|
|
|
|
// Re-call the mock setup now that sequenceMetadata is different.
|
|
setupMockRequests();
|
|
|
|
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
|
const { container } = render(component);
|
|
|
|
// This is an important line that ensures the spinner has been removed - and thus our main
|
|
// content has been loaded - prior to proceeding with our expectations.
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
assertLoadedHeader(container);
|
|
assertSequenceNavigation(container);
|
|
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
|
});
|
|
});
|
|
|
|
describe('when the URL contains a course, sequence, and unit ID', () => {
|
|
it('should load the specified unit', async () => {
|
|
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
|
const { container } = render(component);
|
|
|
|
// This is an important line that ensures the spinner has been removed - and thus our main
|
|
// content has been loaded - prior to proceeding with our expectations.
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
assertLoadedHeader(container);
|
|
assertSequenceNavigation(container);
|
|
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
|
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
|
});
|
|
});
|
|
|
|
describe('when the current sequence is an exam', () => {
|
|
const { location } = window;
|
|
|
|
beforeEach(() => {
|
|
delete window.location;
|
|
window.location = {
|
|
assign: jest.fn(),
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.location = location;
|
|
});
|
|
|
|
it('should redirect to the sequence lmsWebUrl', async () => {
|
|
// OVERRIDE SEQUENCE METADATA:
|
|
sequenceMetadata = Factory.build(
|
|
'sequenceMetadata',
|
|
{ is_time_limited: true }, // position index is 1-based and is converted to 0-based for activeUnitIndex
|
|
{ courseId, unitBlocks, sequenceBlock },
|
|
);
|
|
|
|
// Re-call the mock setup now that sequenceMetadata is different.
|
|
setupMockRequests();
|
|
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
|
render(component);
|
|
|
|
// This is an important line that ensures the spinner has been removed - and thus our main
|
|
// content has been loaded - prior to proceeding with our expectations.
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.lms_web_url);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when receiving a can_load_courseware error_code', () => {
|
|
let courseMetadata;
|
|
|
|
function setupWithDeniedStatus(errorCode) {
|
|
courseMetadata = Factory.build('courseMetadata', {
|
|
can_load_courseware: {
|
|
has_access: false,
|
|
error_code: errorCode,
|
|
additional_context_user_message: 'uhoh oh no', // only used by audit_expired
|
|
},
|
|
});
|
|
const courseId = courseMetadata.id;
|
|
const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
|
|
const sequenceMetadata = Factory.build(
|
|
'sequenceMetadata',
|
|
{},
|
|
{ courseId, unitBlocks, sequenceBlock },
|
|
);
|
|
|
|
const forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
|
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
|
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`;
|
|
|
|
axiosMock.onGet(forbiddenCourseUrl).reply(200, courseMetadata);
|
|
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
|
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
|
|
|
|
history.push(`/course/${courseId}`);
|
|
}
|
|
|
|
it('should go to course home for an enrollment_required error code', async () => {
|
|
setupWithDeniedStatus('enrollment_required');
|
|
|
|
render(component);
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
|
});
|
|
|
|
it('should go to course home for an authentication_required error code', async () => {
|
|
setupWithDeniedStatus('authentication_required');
|
|
|
|
render(component);
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
|
|
});
|
|
|
|
it('should go to dashboard for an unfulfilled_milestones error code', async () => {
|
|
setupWithDeniedStatus('unfulfilled_milestones');
|
|
|
|
render(component);
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
|
|
});
|
|
|
|
it('should go to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
|
|
setupWithDeniedStatus('audit_expired');
|
|
|
|
render(component);
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
|
|
});
|
|
|
|
it('should go to the dashboard with a notlive start date for a course_not_started error code', async () => {
|
|
setupWithDeniedStatus('course_not_started');
|
|
|
|
render(component);
|
|
await waitForElementToBeRemoved(screen.getByRole('status'));
|
|
|
|
const startDate = '2/5/2013'; // This date is based on our courseMetadata factory's sample data.
|
|
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);
|
|
});
|
|
});
|
|
});
|