[BD-29] [TNL-7266] Learning MFE data integration tests (#95)

* Add test for fetchCourse

* Add tests for fetchDatesTab, fetchOutlineTab, fetchSequence and resetDeadlines

* Implement fetch tabs tests

* Add fail test case for fetchSequence

* Add success test for fetchSequence

* Add test for resetDeadlines

* Update test group name

* Add empty tests for courseware and bookmarks

* Fix wrong field in saveSequencePosition thunk

* Add tests for courseware data layer

* Temporary commit

* Split tests after rebase

* Revert "Fix wrong field in saveSequencePosition thunk"

This reverts commit 4394d363c58ad929f81e587ce4da2241528494b5.

* Fix test for position

* Move executeThunk into utils

* Add test for all reducers

* Add expect statements for logs

* Remove redundant snapshot tests and add some specific checks

* Polishing

* Remove redundant checks

* Fix bug in normalizer and update test

* Upgrade @edx/frontend-platform dependency

* Utilize MockAuthService instead of manual auth package mocking

* Update tests after breaking changes in master

* Remove redundant snapshot check
This commit is contained in:
Demid
2020-07-09 17:39:37 +03:00
committed by GitHub
parent 5332be8e65
commit eac9bf9c92
19 changed files with 1394 additions and 13 deletions

46
package-lock.json generated
View File

@@ -2529,9 +2529,9 @@
}
},
"@cospired/i18n-iso-languages": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-2.1.1.tgz",
"integrity": "sha512-dmlPmVw51tQzCJkYvtWWXxuvsAkNvCmUnvjMWyo7Qd318YeEZalCUInUgdhKU985gk2wkITtdakPYqk5GrJ99A=="
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@cospired/i18n-iso-languages/-/i18n-iso-languages-2.1.2.tgz",
"integrity": "sha512-XylKOsWRyQm9sNanZnppRORXTLaL34uThyBQpTFwOGAYvNg9PeYsyTTfLA1FTCh02RV+kiwt/O/y14DR/OqpWg=="
},
"@edx/eslint-config": {
"version": "1.1.4",
@@ -2665,13 +2665,13 @@
}
},
"@edx/frontend-platform": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.3.1.tgz",
"integrity": "sha512-RAJ6IciIX+ZwXhlBfxOOB0sjNNpBQaN/eDLsgztL9MxEmuAvQtbvQBBJAum6qsVZpKzOZYhtRYFyWoHTuyzFZA==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-1.5.1.tgz",
"integrity": "sha512-JUXxkqphBR64WKGBNK8Zv9uuBxB46r01UZtFjW63+Ufauo0v+rWHgHisSAKeFOhiuAgAPpd4vSjGYUXqH1ozMQ==",
"requires": {
"@cospired/i18n-iso-languages": "2.1.1",
"@cospired/i18n-iso-languages": "2.1.2",
"axios": "0.18.1",
"form-urlencoded": "4.1.3",
"form-urlencoded": "4.1.4",
"glob": "7.1.6",
"history": "4.10.1",
"i18n-iso-countries": "4.3.1",
@@ -4376,6 +4376,24 @@
"is-buffer": "^2.0.2"
}
},
"axios-mock-adapter": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.18.1.tgz",
"integrity": "sha512-kFBZsG1Ma5yxjRGHq5KuuL55mPb7WzFULhypquEhzPg8SH5CXICb+qwC2CCA5u+GQVpiqGPwKSRkd3mBCs6gdw==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"is-buffer": "^2.0.3"
},
"dependencies": {
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
}
}
},
"axobject-query": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.1.tgz",
@@ -9031,9 +9049,9 @@
}
},
"form-urlencoded": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-4.1.3.tgz",
"integrity": "sha512-z0YJtPuq0BSOrErlpj+o1KHTRaOH6LN16043ZVK2Wk5uUGpX308PJ5ZJDtU++ndoZkzASHVclUTD2mb1jHzqlA=="
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-4.1.4.tgz",
"integrity": "sha512-R7Vytos0gMYuPQTMwnNzvK9PBItNV+Qkm/pvghEZI3j2kMrzZmJlczAgHFmt12VV+IRYQXgTlSGP1PKAsMCIUA=="
},
"formidable": {
"version": "1.2.1",
@@ -16586,6 +16604,12 @@
"inherits": "^2.0.1"
}
},
"rosie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/rosie/-/rosie-2.0.1.tgz",
"integrity": "sha1-wlDEeHzkULcqqe/yZQn2hYmBT6I=",
"dev": true
},
"rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",

View File

@@ -36,7 +36,7 @@
"dependencies": {
"@edx/frontend-component-footer": "^10.0.6",
"@edx/frontend-component-header": "^2.0.3",
"@edx/frontend-platform": "^1.3.1",
"@edx/frontend-platform": "^1.5.1",
"@edx/paragon": "^7.2.1",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-brands-svg-icons": "^5.12.0",
@@ -60,11 +60,13 @@
},
"devDependencies": {
"@edx/frontend-build": "^3.0.0",
"axios-mock-adapter": "^1.18.1",
"codecov": "^3.6.1",
"es-check": "^5.1.0",
"glob": "^7.1.6",
"husky": "^3.1.0",
"jest": "^24.9.0",
"reactifex": "^1.1.1"
"reactifex": "^1.1.1",
"rosie": "^2.0.1"
}
}

View File

@@ -0,0 +1,23 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('courseHomeMetadata')
.sequence(
'course_id',
(courseId) => `course-v1:edX+DemoX+Demo_Course_${courseId}`,
)
.option('courseTabs', [])
.option('host', 'http://localhost:18000')
.attrs({
is_staff: false,
number: 'DemoX',
org: 'edX',
title: 'Demonstration Course',
is_self_paced: false,
})
.attr('tabs', ['courseTabs', 'host'], (courseTabs, host) => courseTabs.map(
tab => ({
tab_id: tab.slug,
title: tab.title,
url: `${host}${tab.url}`,
}),
));

View File

@@ -0,0 +1,25 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('datesTabData')
.attrs({
dates_banner_info: {
content_type_gating_enabled: false,
missed_gated_content: false,
missed_deadlines: false,
},
course_date_blocks: [
{
date: '2013-02-05T05:00:00Z',
date_type: 'course-start-date',
description: '',
learner_has_access: true,
link: '',
title: 'Course Starts',
},
],
missed_deadlines: false,
missed_gated_content: false,
learner_is_full_access: true,
user_timezone: null,
verified_upgrade_link: 'http://localhost:18130/basket/add/?sku=8CF08E5',
});

View File

@@ -0,0 +1,3 @@
import './courseHomeMetadata.factory';
import './datesTabData.factory';
import './outlineTabData.factory';

View File

@@ -0,0 +1,15 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import '../../../courseware/data/__factories__/courseBlocks.factory';
Factory.define('outlineTabData')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
.attr('course_tools', ['host', 'courseId'], (host, courseId) => ({
analytics_id: 'edx.bookmarks',
title: 'Bookmarks',
url: `${host}/courses/${courseId}/bookmarks/`,
}))
.attr('course_blocks', ['courseId'], courseId => ({
blocks: Factory.build('courseBlocks', { courseId }).blocks,
}));

View File

@@ -0,0 +1,204 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data layer integration tests Should initialize store 1`] = `
Object {
"courseHome": Object {
"courseId": null,
"courseStatus": "loading",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
},
"models": Object {},
}
`;
exports[`Data layer integration tests Test fetchDatesTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
},
"models": Object {
"courses": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"tabs": Array [
Object {
"slug": "courseware",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
],
"title": "Demonstration Course",
},
},
"dates": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseDateBlocks": Array [
Object {
"date": "2013-02-05T05:00:00Z",
"dateType": "course-start-date",
"description": "",
"learnerHasAccess": true,
"link": "",
"title": "Course Starts",
},
],
"datesBannerInfo": Object {
"contentTypeGatingEnabled": false,
"missedDeadlines": false,
"missedGatedContent": false,
},
"id": "course-v1:edX+DemoX+Demo_Course_1",
"learnerIsFullAccess": true,
"missedDeadlines": false,
"missedGatedContent": false,
"userTimezone": null,
"verifiedUpgradeLink": "http://localhost:18130/basket/add/?sku=8CF08E5",
},
},
},
}
`;
exports[`Data layer integration tests Test fetchOutlineTab Should fetch, normalize, and save metadata 1`] = `
Object {
"courseHome": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"courseStatus": "loaded",
},
"courseware": Object {
"courseId": null,
"courseStatus": "loading",
"sequenceId": null,
"sequenceStatus": "loading",
},
"models": Object {
"courses": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseId": "course-v1:edX+DemoX+Demo_Course_1",
"id": "course-v1:edX+DemoX+Demo_Course_1",
"isSelfPaced": false,
"isStaff": false,
"number": "DemoX",
"org": "edX",
"tabs": Array [
Object {
"slug": "courseware",
"title": "Course",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course/",
},
Object {
"slug": "discussion",
"title": "Discussion",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/discussion/forum/",
},
Object {
"slug": "wiki",
"title": "Wiki",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/course_wiki",
},
Object {
"slug": "progress",
"title": "Progress",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/progress",
},
Object {
"slug": "instructor",
"title": "Instructor",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course_1/instructor",
},
],
"title": "Demonstration Course",
},
},
"outline": Object {
"course-v1:edX+DemoX+Demo_Course_1": Object {
"courseBlocks": Object {
"courses": Object {
"block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object {
"id": "course-v1:edX+DemoX+Demo_Course_1",
"sectionIds": Array [
"block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd4",
},
},
"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+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
],
"title": "bcdabcdabcdabcdabcdabcdabcdabcd3",
},
},
"sequences": Object {
"block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object {
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+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+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
],
},
},
"units": Object {
"block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object {
"graded": false,
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
"sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2",
"title": "bcdabcdabcdabcdabcdabcdabcdabcd1",
},
},
},
"courseTools": Object {
"analyticsId": "edx.bookmarks",
"title": "Bookmarks",
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/bookmarks/",
},
"datesWidget": undefined,
"id": "course-v1:edX+DemoX+Demo_Course_1",
},
},
},
}
`;

View File

@@ -0,0 +1,155 @@
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 * 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';
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 axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const courseMetadata = Factory.build('courseMetadata');
const courseHomeMetadata = Factory.build(
'courseHomeMetadata', {
course_id: courseMetadata.id,
},
{ courseTabs: courseMetadata.tabs },
);
const courseId = courseMetadata.id;
const courseBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course`;
const courseMetadataBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata`;
const courseUrl = `${courseBaseUrl}/${courseId}`;
const courseMetadataUrl = `${courseMetadataBaseUrl}/${courseId}`;
let store;
beforeEach(() => {
axiosMock.reset();
logError.mockReset();
store = configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
},
});
});
it('Should initialize store', () => {
expect(store.getState()).toMatchSnapshot();
});
describe('Test fetchDatesTab', () => {
const datesBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates`;
it('Should fail to fetch if error occurs', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${datesBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const datesTabData = Factory.build('datesTabData');
const datesUrl = `${datesBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData);
await executeThunk(thunks.fetchDatesTab(courseId), store.dispatch);
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
});
});
describe('Test fetchOutlineTab', () => {
const outlineBaseUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline`;
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseMetadataUrl).networkError();
axiosMock.onGet(`${outlineBaseUrl}/${courseId}`).networkError();
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(store.getState().courseHome.courseStatus).toEqual('failed');
});
it('Should fetch, normalize, and save metadata', async () => {
const outlineTabData = Factory.build('outlineTabData', { courseId });
const outlineUrl = `${outlineBaseUrl}/${courseId}`;
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(outlineUrl).reply(200, outlineTabData);
await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch);
const state = store.getState();
expect(state.courseHome.courseStatus).toEqual('loaded');
expect(state).toMatchSnapshot();
});
});
describe('Test resetDeadlines', () => {
it('Should reset course deadlines', async () => {
const resetUrl = `${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`;
axiosMock.onPost(resetUrl).reply(201);
const getTabDataMock = jest.fn(() => ({
type: 'MOCK_ACTION',
}));
await executeThunk(thunks.resetDeadlines(courseId, getTabDataMock), store.dispatch);
expect(axiosMock.history.post[0].url).toEqual(resetUrl);
expect(axiosMock.history.post[0].data).toEqual(`{"course_key":"${courseId}"}`);
expect(getTabDataMock).toHaveBeenCalledWith(courseId);
});
});
});

View File

@@ -0,0 +1,107 @@
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 * as thunks from './thunks';
import executeThunk from '../../../../utils';
import { reducer as modelsReducer } from '../../../../generic/model-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 axiosMock = new MockAdapter(getAuthenticatedHttpClient());
describe('Data layer integration tests', () => {
const unitId = 'unitId';
let store;
beforeEach(() => {
axiosMock.reset();
logError.mockReset();
store = configureStore({
reducer: {
models: modelsReducer,
},
});
});
describe('Test addBookmark', () => {
const createBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
it('Should fail to create bookmark in case of error', async () => {
axiosMock.onPost(createBookmarkURL).networkError();
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(createBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
bookmarkedUpdateState: 'failed',
}));
});
it('Should create bookmark and update model state', async () => {
axiosMock.onPost(createBookmarkURL).reply(201);
await executeThunk(thunks.addBookmark(unitId), store.dispatch);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,
bookmarkedUpdateState: 'loaded',
}));
});
});
describe('Test removeBookmark', () => {
const deleteBookmarkURL = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/${authenticatedUser.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(axiosMock.history.delete[0].url).toEqual(deleteBookmarkURL);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: true,
bookmarkedUpdateState: 'failed',
}));
});
it('Should delete bookmark and update model state', async () => {
axiosMock.onDelete(deleteBookmarkURL).reply(201);
await executeThunk(thunks.removeBookmark(unitId), store.dispatch);
expect(store.getState().models.units[unitId]).toEqual(expect.objectContaining({
bookmarked: false,
bookmarkedUpdateState: 'loaded',
}));
});
});
});

View File

@@ -0,0 +1,54 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('block')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('host', 'http://localhost:18000')
// Generating block_id that is similar to md5 hash, but still deterministic
.sequence('block_id', id => ('abcd'.repeat(8) + id).slice(-32))
.attrs({
graded: false,
type: 'course',
children: [],
})
.attr('display_name', ['display_name', 'block_id'], (displayName, blockId) => {
if (displayName) {
return displayName;
}
return blockId;
})
.attr(
'id',
['id', 'block_id', 'type', 'courseId'],
(id, blockId, type, courseId) => {
if (id) {
return id;
}
const courseInfo = courseId.split(':')[1];
return `block-v1:${courseInfo}+type@${type}+block@${blockId}`;
},
)
.attr(
'student_view_url',
['student_view_url', 'host', 'id'],
(url, host, id) => {
if (url) {
return url;
}
return `${host}/xblock/${id}`;
},
)
.attr(
'lms_web_url',
['lms_web_url', 'host', 'courseId', 'id'],
(url, host, courseId, id) => {
if (url) {
return url;
}
return `${host}/courses/${courseId}/jump_to/${id}`;
},
);

View File

@@ -0,0 +1,37 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './block.factory';
Factory.define('courseBlocks')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('unit', ['courseId'], courseId => Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
))
.option('sequence', ['courseId', 'unit'], (courseId, child) => Factory.build(
'block',
{ type: 'sequential', children: [child.id] },
{ courseId },
))
.option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build(
'block',
{ type: 'chapter', children: [child.id] },
{ courseId },
))
.option('course', ['courseId', 'section'], (courseId, child) => Factory.build(
'block',
{ type: 'course', children: [child.id] },
{ courseId },
))
.attr(
'blocks',
['course', 'section', 'sequence', 'unit'],
(course, section, sequence, unit) => ({
[course.id]: course,
[section.id]: section,
[sequence.id]: sequence,
[unit.id]: unit,
}),
)
.attr('root', ['course'], course => course.id);

View File

@@ -0,0 +1,111 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './tab.factory';
Factory.define('courseMetadata')
.sequence('id', (id) => `course-v1:edX+DemoX+Demo_Course_${id}`)
.attrs({
can_show_upgrade_sock: false,
content_type_gating_enabled: false,
course_expired_message: null,
effort: null,
end: null,
enrollment_start: null,
enrollment_end: null,
name: 'Demonstration Course',
number: 'DemoX',
offer_html: null,
org: 'edX',
short_description: null,
start: '2013-02-05T05:00:00Z',
start_display: 'Feb. 5, 2013',
start_type: 'timestamp',
pacing: 'instructor',
enrollment: {
mode: null,
is_active: null,
},
verified_mode: {
currency: 'USD',
upgrade_url: 'http://localhost:18130/basket/add/?sku=8CF08E5',
sku: '8CF08E5',
price: 149,
currency_symbol: '$',
},
show_calculator: false,
is_staff: false,
can_load_courseware: {
has_access: true,
user_fragment: null,
developer_message: null,
user_message: null,
error_code: null,
additional_context_user_message: null,
},
notes: {
visible: true,
enabled: false,
},
marketing_url: null,
}).attr(
'tabs', ['tabs', 'id'], (passedTabs, id) => {
if (passedTabs) {
return passedTabs;
}
const tabs = [
Factory.build(
'tab',
{
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
},
{ courseId: id, path: 'course/' },
),
Factory.build(
'tab',
{
title: 'Discussion',
priority: 1,
slug: 'discussion',
type: 'discussion',
},
{ courseId: id, path: 'discussion/forum/' },
),
Factory.build(
'tab',
{
title: 'Wiki',
priority: 2,
slug: 'wiki',
type: 'wiki',
},
{ courseId: id, path: 'course_wiki' },
),
Factory.build(
'tab',
{
title: 'Progress',
priority: 3,
slug: 'progress',
type: 'progress',
},
{ courseId: id, path: 'progress' },
),
Factory.build(
'tab',
{
title: 'Instructor',
priority: 4,
slug: 'instructor',
type: 'instructor',
},
{ courseId: id, path: 'instructor' },
),
];
return tabs;
},
);

View File

@@ -0,0 +1,4 @@
import './block.factory';
import './courseBlocks.factory';
import './courseMetadata.factory';
import './sequenceMetadata.factory';

View File

@@ -0,0 +1,55 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './block.factory';
Factory.define('sequenceMetadata')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('unitBlock', ['courseId'], courseId => Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
))
.option(
'sequenceBlock', ['courseId', 'unitBlock'], (courseId, unitBlock) => (
Factory.build(
'block',
{ type: 'sequential', children: [unitBlock.id] },
{ courseId },
)
),
)
.attr('element_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.block_id)
.attr('item_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.id)
.attr('display_name', ['sequenceBlock'], sequenceBlock => sequenceBlock.display_name)
.attr('ajax_url', ['sequenceBlock'], sequenceBlock => `${sequenceBlock.student_view_url}/handler/xmodule_handler}`)
.attr('gated_content', ['sequenceBlock'], sequenceBlock => ({
gated: false,
prereq_url: null,
prereq_id: null,
prereq_section_name: null,
gated_section_name: sequenceBlock.display_name,
}))
.attr('items', ['unitBlock', 'sequenceBlock'], (unitBlock, sequenceBlock) => ([
{
href: '',
graded: unitBlock.graded,
id: unitBlock.id,
bookmarked: false,
path: `Chapter Display Name > ${sequenceBlock.display_name} > ${unitBlock.display_name}`,
type: 'other',
complete: null,
content: '',
page_title: unitBlock.display_name,
},
]))
.attrs({
exclude_units: true,
position: null,
next_url: null,
tag: 'sequential',
save_position: true,
prev_url: null,
is_time_limited: false,
show_completion: true,
banner_text: null,
});

View File

@@ -0,0 +1,16 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('tab')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('path', 'course/')
.attrs({
title: 'Course',
priority: 0,
slug: 'courseware',
type: 'courseware',
})
.attr(
'url',
['courseId', 'path'],
(courseId, path) => `/courses/${courseId}/${path}`,
);

View File

@@ -0,0 +1,265 @@
// 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

@@ -152,6 +152,7 @@ function normalizeSequenceMetadata(sequence) {
title: sequence.display_name,
gatedContent: camelCaseObject(sequence.gated_content),
isTimeLimited: sequence.is_time_limited,
position: sequence.position || 1,
// Position comes back from the server 1-indexed. Adjust here.
activeUnitIndex: sequence.position ? sequence.position - 1 : 0,
saveUnitPosition: sequence.save_position,

View File

@@ -0,0 +1,272 @@
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 * as thunks from './thunks';
import executeThunk from '../../utils';
import { reducer as coursewareReducer } from './slice';
import { reducer as modelsReducer } from '../../generic/model-store';
import './__factories__';
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 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/*`);
const sequenceBaseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence`;
// 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 sequenceMetadata = Factory.build(
'sequenceMetadata',
{ courseId },
{ unitBlock, sequenceBlock },
);
const courseUrl = `${courseBaseUrl}/${courseId}`;
const sequenceId = sequenceMetadata.item_id;
const sequenceUrl = `${sequenceBaseUrl}/${sequenceMetadata.item_id}`;
const unitId = sequenceMetadata.items[0].id;
let store;
beforeEach(() => {
axiosMock.reset();
logError.mockReset();
store = configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
},
});
});
describe('Test fetchCourse', () => {
it('Should fail to fetch course and blocks if request error happens', async () => {
axiosMock.onGet(courseUrl).networkError();
axiosMock.onGet(courseBlocksUrlRegExp).networkError();
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(store.getState().courseware).toEqual(expect.objectContaining({
courseId,
courseStatus: 'failed',
}));
});
it('Should fetch, normalize, and save metadata, but with denied status', async () => {
const forbiddenCourseMetadata = Factory.build('courseMetadata', {
can_load_courseware: {
has_access: false,
},
});
const forbiddenCourseBlocks = Factory.build('courseBlocks', { courseId: forbiddenCourseMetadata.id });
const forbiddenCourseUrl = `${courseBaseUrl}/${forbiddenCourseMetadata.id}`;
axiosMock.onGet(forbiddenCourseUrl).reply(200, forbiddenCourseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, forbiddenCourseBlocks);
await executeThunk(thunks.fetchCourse(forbiddenCourseMetadata.id), store.dispatch);
const state = store.getState();
expect(state.courseware.courseStatus).toEqual('denied');
// check that at least one key camel cased, thus course data normalized
expect(state.models.courses[forbiddenCourseMetadata.id].canLoadCourseware).not.toBeUndefined();
});
it('Should fetch, normalize, and save metadata', async () => {
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
const state = store.getState();
expect(state.courseware.courseStatus).toEqual('loaded');
// check that at least one key camel cased, thus course data normalized
expect(state.models.courses[courseId].canLoadCourseware).not.toBeUndefined();
expect(state).toMatchSnapshot();
});
});
describe('Test fetchSequence', () => {
it('Should result in fetch failure if error occurs', async () => {
axiosMock.onGet(sequenceUrl).networkError();
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
expect(logError).toHaveBeenCalled();
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
});
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
axiosMock.onGet(sequenceUrl).reply(200, sequenceMetadata);
// setting course with blocks before sequence to check that blocks receive
// additional information after fetchSequence call.
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({
[sequenceBlock.id]: expect.not.objectContaining({
gatedContent: expect.any(Object),
activeUnitIndex: expect.any(Number),
}),
});
expect(initialState.models.units).toEqual({
[unitBlock.id]: expect.not.objectContaining({
complete: null,
bookmarked: expect.any(Boolean),
}),
});
await executeThunk(thunks.fetchSequence(sequenceBlock.id), store.dispatch);
const state = store.getState();
expect(state.courseware.sequenceStatus).toEqual('loaded');
// ensure that additional information appeared in store
expect(state.models.sequences).toEqual({
[sequenceBlock.id]: expect.objectContaining({
gatedContent: expect.any(Object),
activeUnitIndex: expect.any(Number),
}),
});
expect(state.models.units).toEqual({
[unitBlock.id]: expect.objectContaining({
complete: null,
bookmarked: expect.any(Boolean),
}),
});
expect(state).toMatchSnapshot();
});
});
describe('Thunks that require fetched sequences', () => {
beforeEach(async () => {
// thunks tested in this block rely on fact, that store already has
// some info about sequence
axiosMock.onGet(sequenceUrl).reply(200, sequenceMetadata);
await executeThunk(thunks.fetchSequence(sequenceMetadata.item_id), store.dispatch);
});
describe('Test checkBlockCompletion', () => {
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/xmodule_handler/get_completion`;
it('Should fail to check completion and log error', async () => {
axiosMock.onPost(getCompletionURL).networkError();
await executeThunk(
thunks.checkBlockCompletion(courseId, sequenceId, unitId),
store.dispatch,
store.getState,
);
expect(logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(getCompletionURL);
});
it('Should update complete field of unit model', async () => {
axiosMock.onPost(getCompletionURL).reply(201, { complete: true });
await executeThunk(
thunks.checkBlockCompletion(courseId, sequenceId, unitId),
store.dispatch,
store.getState,
);
expect(store.getState().models.units[unitId].complete).toBeTruthy();
});
});
describe('Test saveSequencePosition', () => {
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/xmodule_handler/goto_position`;
it('Should change and revert sequence model position in case of error', async () => {
axiosMock.onPost(gotoPositionURL).networkError();
const oldPosition = store.getState().models.sequences[sequenceId].position;
const newPosition = 123;
await executeThunk(
thunks.saveSequencePosition(courseId, sequenceId, newPosition),
store.dispatch,
store.getState,
);
expect(logError).toHaveBeenCalled();
expect(axiosMock.history.post[0].url).toEqual(gotoPositionURL);
expect(store.getState().models.sequences[sequenceId].position).toEqual(oldPosition);
});
it('Should update sequence model position', async () => {
axiosMock.onPost(gotoPositionURL).reply(201, {});
const newPosition = 123;
await executeThunk(
thunks.saveSequencePosition(courseId, sequenceId, newPosition),
store.dispatch,
store.getState,
);
expect(axiosMock.history.post[0].url).toEqual(gotoPositionURL);
expect(store.getState().models.sequences[sequenceId].position).toEqual(newPosition);
});
});
});
});

8
src/utils.js Normal file
View File

@@ -0,0 +1,8 @@
// Helper, that is used to forcibly finalize all promises
// in thunk before running matcher against state.
const executeThunk = async (thunk, dispatch, getState) => {
await thunk(dispatch, getState);
await new Promise(setImmediate);
};
export default executeThunk;