This is the first step toward clearing out the redundant metadata from the coursewareMetadata and getting it from a common source - the courseHomeMetadata. remove username from coursewareMetadata Remove courseAccess from coursewareMetadata. Fix all unit tests Modify classes that use metadataModel to use courseHomeMetadata for common data. metadataModel still exists as a mechanism to distinguish if a component is under courseware or courseHome, and it will be renamed or removed in a later refactor.
364 lines
15 KiB
JavaScript
364 lines
15 KiB
JavaScript
import { Factory } from 'rosie';
|
|
import MockAdapter from 'axios-mock-adapter';
|
|
|
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
|
import { getConfig } from '@edx/frontend-platform';
|
|
|
|
import * as thunks from './thunks';
|
|
|
|
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
|
|
|
import { buildSimpleCourseBlocks } from '../../shared/data/__factories__/courseBlocks.factory';
|
|
import { buildOutlineFromBlocks } from './__factories__/learningSequencesOutline.factory';
|
|
import { initializeMockApp } from '../../setupTest';
|
|
import initializeStore from '../../store';
|
|
|
|
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/*`);
|
|
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
|
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 courseHomeMetadata = Factory.build('courseHomeMetadata');
|
|
const { courseBlocks, unitBlocks, sequenceBlocks } = buildSimpleCourseBlocks(courseId);
|
|
const sequenceMetadata = Factory.build(
|
|
'sequenceMetadata',
|
|
{},
|
|
{ courseId, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
|
);
|
|
const simpleOutline = buildOutlineFromBlocks(courseBlocks);
|
|
|
|
let courseUrl = `${courseBaseUrl}/${courseId}`;
|
|
courseUrl = appendBrowserTimezoneToUrl(courseUrl);
|
|
|
|
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(
|
|
`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`,
|
|
);
|
|
const sequenceUrl = `${sequenceBaseUrl}/${sequenceMetadata.item_id}`;
|
|
const sequenceId = sequenceBlocks[0].id;
|
|
const unitId = unitBlocks[0].id;
|
|
|
|
let store;
|
|
|
|
beforeEach(() => {
|
|
axiosMock.reset();
|
|
loggingService.logError.mockReset();
|
|
|
|
store = initializeStore();
|
|
});
|
|
|
|
describe('Test fetchCourse', () => {
|
|
it('Should fail to fetch course and blocks if request error happens', async () => {
|
|
axiosMock.onGet(courseUrl).networkError();
|
|
axiosMock.onGet(courseBlocksUrlRegExp).networkError();
|
|
axiosMock.onGet(learningSequencesUrlRegExp).networkError();
|
|
|
|
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
|
|
|
expect(loggingService.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');
|
|
const forbiddenCourseHomeMetadata = Factory.build('courseHomeMetadata', {
|
|
course_access: {
|
|
has_access: false,
|
|
},
|
|
});
|
|
const forbiddenCourseHomeUrl = appendBrowserTimezoneToUrl(
|
|
`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`,
|
|
);
|
|
const forbiddenCourseBlocks = Factory.build('courseBlocks', {
|
|
courseId: forbiddenCourseMetadata.id,
|
|
});
|
|
let forbiddenCourseUrl = `${courseBaseUrl}/${forbiddenCourseMetadata.id}`;
|
|
forbiddenCourseUrl = appendBrowserTimezoneToUrl(forbiddenCourseUrl);
|
|
|
|
axiosMock.onGet(forbiddenCourseHomeUrl).reply(200, forbiddenCourseHomeMetadata);
|
|
axiosMock.onGet(forbiddenCourseUrl).reply(200, forbiddenCourseMetadata);
|
|
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, forbiddenCourseBlocks);
|
|
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
|
|
|
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.courseHomeMeta[forbiddenCourseMetadata.id].courseAccess).not.toBeUndefined();
|
|
});
|
|
|
|
it('Should fetch, normalize, and save metadata', async () => {
|
|
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
|
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
|
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
|
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
|
|
|
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
|
|
|
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.coursewareMeta[courseId].verifiedMode).not.toBeUndefined();
|
|
});
|
|
|
|
it('Should fetch, normalize, and save metadata; filtering has no effect', async () => {
|
|
// Very similar to previous test, but pass back an outline for filtering
|
|
// (even though it won't actually filter down in this case).
|
|
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
|
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
|
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
|
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, simpleOutline);
|
|
|
|
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
|
|
|
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.coursewareMeta[courseId].verifiedMode).not.toBeUndefined();
|
|
expect(state.models.sequences.length === 1);
|
|
|
|
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 1));
|
|
});
|
|
|
|
it('Should fetch, normalize, and save metadata; filtering removes sequence', async () => {
|
|
// Very similar to previous test, but pass back an outline for filtering
|
|
// (even though it won't actually filter down in this case).
|
|
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
|
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
|
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
|
|
|
// Create an outline with basic matching metadata, but then empty it out...
|
|
const emptyOutline = buildOutlineFromBlocks(courseBlocks);
|
|
emptyOutline.sequences = {};
|
|
emptyOutline.sections = [];
|
|
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, emptyOutline);
|
|
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
|
|
|
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.coursewareMeta[courseId].verifiedMode).not.toBeUndefined();
|
|
expect(state.models.sequences === null);
|
|
|
|
Object.values(state.models.sections).forEach(section => expect(section.sequenceIds.length === 0));
|
|
});
|
|
});
|
|
|
|
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(loggingService.logError).toHaveBeenCalled();
|
|
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
|
|
});
|
|
|
|
it('Should result in fetch failure if a non-sequential block is returned', async () => {
|
|
const sectionMetadata = {
|
|
...sequenceMetadata,
|
|
// 'chapter' is the block_type of a Section, which the sequence metadata
|
|
// API will happily return if requested, since SectionBlock is implemented
|
|
// as a subclass of SequenceBlock.
|
|
tag: 'chapter',
|
|
};
|
|
axiosMock.onGet(sequenceUrl).reply(200, sectionMetadata);
|
|
|
|
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
|
|
|
|
expect(loggingService.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(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
|
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
|
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
|
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
|
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
|
|
let state = store.getState();
|
|
expect(state.models.sequences).toEqual({
|
|
[sequenceId]: expect.not.objectContaining({
|
|
gatedContent: expect.any(Object),
|
|
activeUnitIndex: expect.any(Number),
|
|
}),
|
|
});
|
|
expect(state.models.units).toEqual({
|
|
[unitId]: 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(sequenceId), store.dispatch);
|
|
|
|
// Update our state variable again.
|
|
state = store.getState();
|
|
|
|
// ensure that additional information appeared in store
|
|
expect(state.models.sequences).toEqual({
|
|
[sequenceId]: expect.objectContaining({
|
|
gatedContent: expect.any(Object),
|
|
activeUnitIndex: expect.any(Number),
|
|
}),
|
|
});
|
|
expect(state.models.units).toEqual({
|
|
[unitId]: expect.objectContaining({
|
|
complete: null,
|
|
bookmarked: expect.any(Boolean),
|
|
}),
|
|
});
|
|
|
|
expect(state.courseware.courseStatus).toEqual('loaded');
|
|
expect(state.courseware.courseId).toEqual(courseId);
|
|
expect(state.courseware.sequenceStatus).toEqual('loaded');
|
|
expect(state.courseware.sequenceId).toEqual(sequenceId);
|
|
});
|
|
});
|
|
|
|
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/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(loggingService.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/goto_position`;
|
|
|
|
it('Should change and revert sequence model activeUnitIndex in case of error', async () => {
|
|
axiosMock.onPost(gotoPositionURL).networkError();
|
|
|
|
const oldPosition = store.getState().models.sequences[sequenceId].activeUnitIndex;
|
|
const newPosition = 123;
|
|
|
|
await executeThunk(
|
|
thunks.saveSequencePosition(courseId, sequenceId, newPosition),
|
|
store.dispatch,
|
|
store.getState,
|
|
);
|
|
|
|
expect(loggingService.logError).toHaveBeenCalled();
|
|
expect(axiosMock.history.post[0].url).toEqual(gotoPositionURL);
|
|
expect(store.getState().models.sequences[sequenceId].activeUnitIndex).toEqual(oldPosition);
|
|
});
|
|
|
|
it('Should update sequence model activeUnitIndex', 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].activeUnitIndex).toEqual(newPosition);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('test saveIntegritySignature', () => {
|
|
it('Should update userNeedsIntegritySignature upon success', async () => {
|
|
const courseMetadataNeedSignature = Factory.build('courseMetadata', {
|
|
user_needs_integrity_signature: true,
|
|
});
|
|
|
|
let courseUrlNeedSignature = `${courseBaseUrl}/${courseMetadataNeedSignature.id}`;
|
|
courseUrlNeedSignature = appendBrowserTimezoneToUrl(courseUrlNeedSignature);
|
|
|
|
axiosMock.onGet(courseUrlNeedSignature).reply(200, courseMetadataNeedSignature);
|
|
|
|
await executeThunk(thunks.fetchCourse(courseMetadataNeedSignature.id), store.dispatch);
|
|
expect(
|
|
store.getState().models.coursewareMeta[courseMetadataNeedSignature.id].userNeedsIntegritySignature,
|
|
).toEqual(true);
|
|
|
|
const integritySignatureUrl = `${getConfig().LMS_BASE_URL}/api/agreements/v1/integrity_signature/${courseMetadataNeedSignature.id}`;
|
|
axiosMock.onPost(integritySignatureUrl).reply(200, {});
|
|
await executeThunk(
|
|
thunks.saveIntegritySignature(courseMetadataNeedSignature.id),
|
|
store.dispatch,
|
|
store.getState,
|
|
);
|
|
expect(
|
|
store.getState().models.coursewareMeta[courseMetadataNeedSignature.id].userNeedsIntegritySignature,
|
|
).toEqual(false);
|
|
});
|
|
});
|
|
});
|