CoursewareContainer tests. (#108)

* Adding testing-library dependencies, and bumping frontend-build to be compatible with them.

* Adding a function to initialize the redux store

We need to use it in a few places.  Seems worth not-repeating, since they can easily get out of sync.  In general, tests should only test the parts of the store they care about, as well.

* Adding function to initialize a mock application.

Ultimately I’d like to move this to frontend-platform as an alternative to ‘initialize’ for tests.  ‘initialize’ is an async function which complicates matters.

* Using more explicit assertions for courseware reducer fields.

This removes the need for the snapshot file, and ensures our test is more resilient to unrelated changes in the store.

Also added a few more stages of assertions to some of the tests, showing that they have the right values over time.

* Adding a helper to build a simple course blocks response.

We can use this in the courseware data tests, and shortly in the tests for CoursewareContainer.

* Modifying sequenceMetadata factory to allow multiple units.

This will help us test sequence navigation’s behavior more fully by having multiple units in a sequence.

* A little linting and cleanup.

* Adding first round of tests for CoursewareContainer.

Tests loading, sequence navigation/unit rendering, and ‘denied’ states.

Subsequent tests will add tests for handlers.
This commit is contained in:
David Joy
2020-07-15 10:27:48 -04:00
committed by GitHub
parent bc30b20b0d
commit afb4b77250
14 changed files with 9125 additions and 7256 deletions

15573
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,10 @@
"regenerator-runtime": "^0.13.3"
},
"devDependencies": {
"@edx/frontend-build": "^3.0.0",
"@edx/frontend-build": "^5.0.6",
"@testing-library/dom": "^7.16.2",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.3.0",
"axios-mock-adapter": "^1.18.1",
"codecov": "^3.6.1",
"es-check": "^5.1.0",

View File

@@ -1,43 +1,22 @@
import { configureStore } from '@reduxjs/toolkit';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { configure, getAuthenticatedHttpClient, MockAuthService } from '@edx/frontend-platform/auth';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import * as thunks from './thunks';
import executeThunk from '../../utils';
import { reducer as courseHomeReducer } from './slice';
import { reducer as coursewareReducer } from '../../courseware/data/slice';
import { reducer as modelsReducer } from '../../generic/model-store';
import './__factories__';
import '../../courseware/data/__factories__/courseMetadata.factory';
import initializeMockApp from '../../setupTest';
import initializeStore from '../../store';
jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn() }));
mergeConfig({
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
});
configure(MockAuthService, {
config: getConfig(),
loggingService: {
logInfo: jest.fn(),
logError: jest.fn(),
},
});
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build(
@@ -58,15 +37,9 @@ describe('Data layer integration tests', () => {
beforeEach(() => {
axiosMock.reset();
logError.mockReset();
loggingService.logError.mockReset();
store = configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
},
});
store = initializeStore();
});
it('Should initialize store', () => {
@@ -83,7 +56,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
@@ -114,7 +87,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});

View File

@@ -14,7 +14,6 @@ export default function ProgressTab() {
enrollmentMode,
} = useModel('progress', courseId);
return (
<section>
<h2 className="mb-4">

View File

@@ -168,7 +168,7 @@ export default function CoursewareContainer() {
}, [routeSequenceId]);
// The courseId and sequenceId in the store are the entities we currently have loaded.
// We get these two IDs from the store because until fetchCourse and fetchSequence below have
// We get these two IDs from the store because until fetchCourse and fetchSequence above have
// finished their work, the IDs in the URL are not representative of what we should actually show.
// This is important particularly when switching sequences. Until a new sequence is fully loaded,
// there's information that we don't have yet - if we use the URL's sequence ID to tell the app

View File

@@ -0,0 +1,213 @@
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 './data/__factories__';
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();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('CoursewareContainer', () => {
let store;
let component;
beforeEach(() => {
axiosMock.reset();
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>`,
);
});
it('should successfully render sequence navigation and unit', async () => {
const courseMetadata = Factory.build('courseMetadata');
const courseId = courseMetadata.id;
const { courseBlocks, unitBlock, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ courseId },
{ unitBlock, sequenceBlock },
);
const courseMetadataUrl = `${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}`;
const unitId = unitBlock.id;
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id,
unitId: unitBlock.id,
});
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
// Print out any URLs that we didn't handle above - useful for debugging the test.
axiosMock.onAny().reply((config) => {
console.log(config.url);
return [200, {}];
});
// On page load, SequenceContext attempts to scroll to the top of the page.
global.scrollTo = jest.fn();
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'));
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);
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(3);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
// Prove this button is rendering an SVG book icon, meaning it's a unit.
expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-book');
expect(sequenceNavButtons[2]).toHaveTextContent('Next');
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId);
});
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, unitBlock, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ courseId },
{ unitBlock, 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}`);
});
});
});

View File

@@ -1,38 +1,19 @@
import { configureStore } from '@reduxjs/toolkit';
import MockAdapter from 'axios-mock-adapter';
import { configure, getAuthenticatedHttpClient, MockAuthService } from '@edx/frontend-platform/auth';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import * as thunks from './thunks';
import executeThunk from '../../../../utils';
import { reducer as modelsReducer } from '../../../../generic/model-store';
import initializeMockApp from '../../../../setupTest';
import initializeStore from '../../../../store';
jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn() }));
const authenticatedUser = {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
};
mergeConfig({
authenticatedUser,
});
configure(MockAuthService, {
config: getConfig(),
loggingService: {
logInfo: jest.fn(),
logError: jest.fn(),
},
});
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const unitId = 'unitId';
@@ -40,13 +21,9 @@ describe('Data layer integration tests', () => {
beforeEach(() => {
axiosMock.reset();
logError.mockReset();
loggingService.logError.mockReset();
store = configureStore({
reducer: {
models: modelsReducer,
},
});
store = initializeStore();
});
describe('Test addBookmark', () => {
@@ -57,7 +34,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
@@ -78,14 +55,14 @@ describe('Data layer integration tests', () => {
});
describe('Test removeBookmark', () => {
const deleteBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/${authenticatedUser.username},${unitId}/`;
const deleteBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/${getAuthenticatedUser().username},${unitId}/`;
it('Should fail to remove bookmark in case of error', async () => {
axiosMock.onDelete(deleteBookmarkURL).networkError();
await executeThunk(thunks.removeBookmark(unitId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,

View File

@@ -35,3 +35,45 @@ Factory.define('courseBlocks')
}),
)
.attr('root', ['course'], course => course.id);
/**
* Builds a course with a single chapter, sequence, and unit.
*/
export default function buildSimpleCourseBlocks(courseId, title) {
const unitBlock = Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
);
const sequenceBlock = Factory.build(
'block',
{ type: 'sequential', children: [unitBlock.id] },
{ courseId },
);
const sectionBlock = Factory.build(
'block',
{ type: 'chapter', children: [sequenceBlock.id] },
{ courseId },
);
const courseBlock = Factory.build(
'block',
{ type: 'course', display_name: title, children: [sectionBlock.id] },
{ courseId },
);
return {
courseBlocks: Factory.build(
'courseBlocks',
{ courseId },
{
unit: unitBlock,
sequence: sequenceBlock,
section: sectionBlock,
course: courseBlock,
},
),
unitBlock,
sequenceBlock,
sectionBlock,
courseBlock,
};
}

View File

@@ -4,16 +4,19 @@ import './block.factory';
Factory.define('sequenceMetadata')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('unitBlock', ['courseId'], courseId => Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
))
// An array of units
.option('unitBlocks', ['courseId'], courseId => ([
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
]))
.option(
'sequenceBlock', ['courseId', 'unitBlock'], (courseId, unitBlock) => (
'sequenceBlock', ['courseId', 'unitBlocks'], (courseId, unitBlocks) => (
Factory.build(
'block',
{ type: 'sequential', children: [unitBlock.id] },
{ type: 'sequential', children: unitBlocks.map(unitBlock => unitBlock.id) },
{ courseId },
)
),
@@ -29,8 +32,8 @@ Factory.define('sequenceMetadata')
prereq_section_name: null,
gated_section_name: sequenceBlock.display_name,
}))
.attr('items', ['unitBlock', 'sequenceBlock'], (unitBlock, sequenceBlock) => ([
{
.attr('items', ['unitBlocks', 'sequenceBlock'], (unitBlocks, sequenceBlock) => unitBlocks.map(
unitBlock => ({
href: '',
graded: unitBlock.graded,
id: unitBlock.id,
@@ -40,8 +43,8 @@ Factory.define('sequenceMetadata')
complete: null,
content: '',
page_title: unitBlock.display_name,
},
]))
}),
))
.attrs({
exclude_units: true,
position: null,

View File

@@ -1,265 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Test fetchCourse Should fetch, normalize, and save metadata 1`] = `
Object {
"courseware": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"sequenceId": null,
"sequenceStatus": "loading",
},
"models": Object {
"courses": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"canShowUpgradeSock": false,
"celebrations": undefined,
"contentTypeGatingEnabled": false,
"courseExpiredMessage": null,
"end": null,
"enrollmentEnd": null,
"enrollmentMode": null,
"enrollmentStart": null,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": null,
"isStaff": false,
"marketingUrl": null,
"notes": Object {
"enabled": false,
"visible": true,
},
"number": "DemoX",
"offerHtml": null,
"org": "edX",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
],
"showCalculator": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"priority": 0,
"slug": "courseware",
"title": "Course",
"type": "courseware",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"priority": 1,
"slug": "discussion",
"title": "Discussion",
"type": "discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"priority": 2,
"slug": "wiki",
"title": "Wiki",
"type": "wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"priority": 3,
"slug": "progress",
"title": "Progress",
"type": "progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"priority": 4,
"slug": "instructor",
"title": "Instructor",
"type": "instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
"verifiedMode": Object {
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"id": "block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/jump_to/block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
"unitIds": Array [
"block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
},
},
"units": Object {
"block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"graded": false,
"id": "block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/jump_to/block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"sequenceId": "block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
},
},
},
}
`;
exports[`Data layer integration tests Test fetchSequence Should fetch and normalize metadata, and then update existing models with sequence metadata 1`] = `
Object {
"courseware": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
"sequenceId": "block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"sequenceStatus": "loaded",
},
"models": Object {
"courses": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"canLoadCourseware": Object {
"additionalContextUserMessage": null,
"developerMessage": null,
"errorCode": null,
"hasAccess": true,
"userFragment": null,
"userMessage": null,
},
"canShowUpgradeSock": false,
"celebrations": undefined,
"contentTypeGatingEnabled": false,
"courseExpiredMessage": null,
"end": null,
"enrollmentEnd": null,
"enrollmentMode": null,
"enrollmentStart": null,
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isEnrolled": null,
"isStaff": false,
"marketingUrl": null,
"notes": Object {
"enabled": false,
"visible": true,
},
"number": "DemoX",
"offerHtml": null,
"org": "edX",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
],
"showCalculator": false,
"start": "2013-02-05T05:00:00Z",
"tabs": Array [
Object {
"priority": 0,
"slug": "courseware",
"title": "Course",
"type": "courseware",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"priority": 1,
"slug": "discussion",
"title": "Discussion",
"type": "discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"priority": 2,
"slug": "wiki",
"title": "Wiki",
"type": "wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"priority": 3,
"slug": "progress",
"title": "Progress",
"type": "progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"priority": 4,
"slug": "instructor",
"title": "Instructor",
"type": "instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
"verifiedMode": Object {
"currency": "USD",
"currencySymbol": "$",
"price": 149,
"sku": "8CF08E5",
"upgradeUrl": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
"sections": Object {
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
"sequenceIds": Array [
"block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"activeUnitIndex": 0,
"bannerText": null,
"gatedContent": Object {
"gated": false,
"gatedSectionName": "bcdabcdabcdabcdabcdabcdabcdabcd2",
"prereqId": null,
"prereqSectionName": null,
"prereqUrl": null,
},
"id": "block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"isTimeLimited": false,
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/jump_to/block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"position": 1,
"saveUnitPosition": true,
"sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
"showCompletion": true,
"title": "bcdabcdabcdabcdabcdabcdabcdabcd2",
"unitIds": Array [
"block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
},
},
"units": Object {
"block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"bookmarked": false,
"complete": null,
"contentType": "other",
"graded": false,
"id": "block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/jump_to/block-v1:edX+DemoX+Demo_Course_1+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"sequenceId": "block-v1:edX+DemoX+Demo_Course_1+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
},
},
},
}
`;

View File

@@ -1,41 +1,22 @@
import { configureStore } from '@reduxjs/toolkit';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { configure, getAuthenticatedHttpClient, MockAuthService } from '@edx/frontend-platform/auth';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import * as thunks from './thunks';
import executeThunk from '../../utils';
import { reducer as coursewareReducer } from './slice';
import { reducer as modelsReducer } from '../../generic/model-store';
import buildSimpleCourseBlocks from './__factories__/courseBlocks.factory';
import './__factories__';
import initializeMockApp from '../../setupTest';
import initializeStore from '../../store';
jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn() }));
mergeConfig({
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
});
configure(MockAuthService, {
config: getConfig(),
loggingService: {
logInfo: jest.fn(),
logError: jest.fn(),
},
});
const { loggingService } = initializeMockApp();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
@@ -44,44 +25,25 @@ describe('Data layer integration tests', () => {
// building minimum set of api responses to test all thunks
const courseMetadata = Factory.build('courseMetadata');
const courseId = courseMetadata.id;
const unitBlock = Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
);
const sequenceBlock = Factory.build(
'block',
{ type: 'sequential', children: [unitBlock.id] },
{ courseId },
);
const courseBlocks = Factory.build(
'courseBlocks',
{ courseId },
{ unit: unitBlock, sequence: sequenceBlock },
);
const { courseBlocks, unitBlock, sequenceBlock } = buildSimpleCourseBlocks(courseId);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ courseId },
{ unitBlock, sequenceBlock },
{ unitBlocks: [unitBlock], sequenceBlock },
);
const courseUrl = `${courseBaseUrl}/${courseId}`;
const sequenceId = sequenceMetadata.item_id;
const sequenceUrl = `${sequenceBaseUrl}/${sequenceMetadata.item_id}`;
const unitId = sequenceMetadata.items[0].id;
const sequenceId = sequenceBlock.id;
const unitId = unitBlock.id;
let store;
beforeEach(() => {
axiosMock.reset();
logError.mockReset();
loggingService.logError.mockReset();
store = configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
},
});
store = initializeStore();
});
describe('Test fetchCourse', () => {
@@ -91,7 +53,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseware).toEqual(expect.objectContaining({
courseId,
courseStatus: 'failed',
@@ -104,7 +66,9 @@ describe('Data layer integration tests', () => {
has_access: false,
},
});
const forbiddenCourseBlocks = Factory.build('courseBlocks', { courseId: forbiddenCourseMetadata.id });
const forbiddenCourseBlocks = Factory.build('courseBlocks', {
courseId: forbiddenCourseMetadata.id,
});
const forbiddenCourseUrl = `${courseBaseUrl}/${forbiddenCourseMetadata.id}`;
@@ -130,11 +94,12 @@ describe('Data layer integration tests', () => {
const state = store.getState();
expect(state.courseware.courseStatus).toEqual('loaded');
expect(state.courseware.courseId).toEqual(courseId);
expect(state.courseware.sequenceStatus).toEqual('loading');
expect(state.courseware.sequenceId).toEqual(null);
// check that at least one key camel cased, thus course data normalized
expect(state.models.courses[courseId].canLoadCourseware).not.toBeUndefined();
expect(state).toMatchSnapshot();
});
});
@@ -144,7 +109,7 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
});
@@ -158,25 +123,32 @@ describe('Data layer integration tests', () => {
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
// ensure that initial state has no additional sequence info
const initialState = store.getState();
expect(initialState.models.sequences).toEqual({
let state = store.getState();
expect(state.models.sequences).toEqual({
[sequenceBlock.id]: expect.not.objectContaining({
gatedContent: expect.any(Object),
activeUnitIndex: expect.any(Number),
}),
});
expect(initialState.models.units).toEqual({
expect(state.models.units).toEqual({
[unitBlock.id]: expect.not.objectContaining({
complete: null,
bookmarked: expect.any(Boolean),
}),
});
// Update our state variable again.
state = store.getState();
expect(state.courseware.courseStatus).toEqual('loaded');
expect(state.courseware.courseId).toEqual(courseId);
expect(state.courseware.sequenceStatus).toEqual('loading');
expect(state.courseware.sequenceId).toEqual(null);
await executeThunk(thunks.fetchSequence(sequenceBlock.id), store.dispatch);
const state = store.getState();
expect(state.courseware.sequenceStatus).toEqual('loaded');
// Update our state variable again.
state = store.getState();
// ensure that additional information appeared in store
expect(state.models.sequences).toEqual({
@@ -192,7 +164,10 @@ describe('Data layer integration tests', () => {
}),
});
expect(state).toMatchSnapshot();
expect(state.courseware.courseStatus).toEqual('loaded');
expect(state.courseware.courseId).toEqual(courseId);
expect(state.courseware.sequenceStatus).toEqual('loaded');
expect(state.courseware.sequenceId).toEqual(sequenceId);
});
});
@@ -216,7 +191,7 @@ describe('Data layer integration tests', () => {
store.getState,
);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(getCompletionURL);
});
@@ -248,7 +223,7 @@ describe('Data layer integration tests', () => {
store.getState,
);
expect(logError).toHaveBeenCalled();
expect(loggingService.logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(gotoPositionURL);
expect(store.getState().models.sequences[sequenceId].position).toEqual(oldPosition);
});

View File

@@ -25,12 +25,12 @@ import DatesTab from './course-home/dates-tab';
import ProgressTab from './course-home/progress-tab/ProgressTab';
import { TabContainer } from './tab-page';
import store from './store';
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
import initializeStore from './store';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={store}>
<AppProvider store={initializeStore()}>
<UserMessagesProvider>
<Switch>
<Route path="/redirect" component={CoursewareRedirect} />

View File

@@ -1,2 +1,44 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureI18n } from '@edx/frontend-platform/i18n';
import { configure as configureLogging } from '@edx/frontend-platform/logging';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import appMessages from './i18n';
class MockLoggingService {
logInfo = jest.fn();
logError = jest.fn();
}
export default function initializeMockApp() {
mergeConfig({
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
});
const loggingService = configureLogging(MockLoggingService, {
config: getConfig(),
});
const authService = configureAuth(MockAuthService, {
config: getConfig(),
loggingService,
});
// i18n doesn't have a service class to return.
configureI18n({
config: getConfig(),
loggingService,
messages: [appMessages],
});
return { loggingService, authService };
}

View File

@@ -3,12 +3,12 @@ import { reducer as courseHomeReducer } from './course-home/data';
import { reducer as coursewareReducer } from './courseware/data/slice';
import { reducer as modelsReducer } from './generic/model-store';
const store = configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
},
});
export default store;
export default function initializeStore() {
return configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
},
});
}