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 (
Unit Contents {courseId} {id}
); } jest.mock( './course/sequence/Unit', () => MockUnit, ); initializeMockApp(); describe('CoursewareContainer', () => { let store; let component; let axiosMock; beforeEach(() => { axiosMock = new MockAdapter(getAuthenticatedHttpClient()); store = initializeStore(); component = ( ); }); it('should initialize to show a spinner', () => { history.push('/course/abc123'); render(component); const spinner = screen.getByRole('status'); expect(spinner.firstChild).toContainHTML( `${tabMessages.loading.defaultMessage}`, ); }); 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}`); }); }); });