For performance and long term simplification reasons, we want to take the work currently done by the Course Blocks API and split it up between the Learning Sequences API (course outline) and Sequence Metadata API (details about the Units in a Sequence). This will also make it easier to later support different kinds of Sequences, where we might not know all the details about it at the time we load the course-wide outline data. This starts moving over the responsibility for the high level outline and metadata to Learning Sequences. It requires that the waffle flag "learning_sequences.use_for_outlines" be active in the LMS. If that flag is not active, the Learning Sequences API call will return a 403 error, and this code will fall back to the older behavior. Some data could not be shifted over yet. Namely: * Sequence legacy URL is not currently output by the Learning Sequences API. This is simple to add, but I don't know if there's any point in adding it now that the Courseware MFE is functional for timed exams. * Unit metadata was not completely shifted over to the Sequence Metadata API because doing so would cause blocking requests and would cause a noticeable performance regression. This should not be moved over until the Sequence Metadata API can be made more performant. * Effort Estimation currently relies on content introspection of the underlying content in a way that the Learning Sequences API does not support. This is the last of a handful of PRs in support of TNL-8330.
351 lines
14 KiB
JavaScript
351 lines
14 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 { 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 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', {
|
|
course_access: {
|
|
has_access: false,
|
|
},
|
|
});
|
|
const forbiddenCourseBlocks = Factory.build('courseBlocks', {
|
|
courseId: forbiddenCourseMetadata.id,
|
|
});
|
|
let forbiddenCourseUrl = `${courseBaseUrl}/${forbiddenCourseMetadata.id}`;
|
|
forbiddenCourseUrl = appendBrowserTimezoneToUrl(forbiddenCourseUrl);
|
|
|
|
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.coursewareMeta[forbiddenCourseMetadata.id].courseAccess).not.toBeUndefined();
|
|
});
|
|
|
|
it('Should fetch, normalize, and save metadata', async () => {
|
|
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].courseAccess).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(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].courseAccess).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(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].courseAccess).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(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);
|
|
});
|
|
});
|
|
});
|