diff --git a/src/api.js b/src/api.js new file mode 100644 index 00000000..0399fe28 --- /dev/null +++ b/src/api.js @@ -0,0 +1,16 @@ +import { examRequiresAccessToken, store } from './data'; + +export function isExam() { + const { exam } = store.getState().examState; + return exam.id !== null; +} + +export function getExamAccess() { + const { examAccessToken } = store.getState().examState; + return examAccessToken.exam_access_token; +} + +export async function fetchExamAccess() { + const { dispatch } = store; + return dispatch(examRequiresAccessToken()); +} diff --git a/src/data/__factories__/examAccessToken.factory.js b/src/data/__factories__/examAccessToken.factory.js new file mode 100644 index 00000000..d66ca3e4 --- /dev/null +++ b/src/data/__factories__/examAccessToken.factory.js @@ -0,0 +1,7 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('examAccessToken') + .attrs({ + exam_access_token: 'Z173480948902JK34432', + exam_access_token_expiration: '60', + }); diff --git a/src/data/__factories__/examState.factory.js b/src/data/__factories__/examState.factory.js index 235f81bc..af775148 100644 --- a/src/data/__factories__/examState.factory.js +++ b/src/data/__factories__/examState.factory.js @@ -2,10 +2,12 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dep import './exam.factory'; import './proctoringSettings.factory'; +import './examAccessToken.factory'; Factory.define('examState') .attr('proctoringSettings', Factory.build('proctoringSettings')) .attr('exam', Factory.build('exam')) + .attr('examAccessToken', Factory.build('examAccessToken')) .attrs({ isLoading: false, activeAttempt: null, diff --git a/src/data/__factories__/index.js b/src/data/__factories__/index.js index ba77e3bc..d6a802f0 100644 --- a/src/data/__factories__/index.js +++ b/src/data/__factories__/index.js @@ -2,3 +2,4 @@ import './examState.factory'; import './exam.factory'; import './attempt.factory'; import './proctoringSettings.factory'; +import './examAccessToken.factory'; diff --git a/src/data/__snapshots__/redux.test.jsx.snap b/src/data/__snapshots__/redux.test.jsx.snap index d6d72fe6..8157c9dd 100644 --- a/src/data/__snapshots__/redux.test.jsx.snap +++ b/src/data/__snapshots__/redux.test.jsx.snap @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Data layer integration tests Test examRequiresAccessToken for exams IDA url Should get exam access token 1`] = ` +Object { + "exam_access_token": "Z173480948902JK34432", + "exam_access_token_expiration": "60", +} +`; + exports[`Data layer integration tests Test exams IDA url Should call the exams service to fetch attempt data 1`] = ` Object { "examState": Object { @@ -62,6 +69,10 @@ Object { "total_time": "30 minutes", "type": "timed", }, + "examAccessToken": Object { + "exam_access_token": "", + "exam_access_token_expiration": "", + }, "isLoading": false, "proctoringSettings": Object { "contact_us": "", @@ -107,6 +118,10 @@ Object { "allowProctoringOptOut": false, "apiErrorMsg": "", "exam": Object {}, + "examAccessToken": Object { + "exam_access_token": "", + "exam_access_token_expiration": "", + }, "isLoading": false, "proctoringSettings": Object { "contact_us": "", @@ -191,6 +206,10 @@ Object { "total_time": "30 minutes", "type": "timed", }, + "examAccessToken": Object { + "exam_access_token": "", + "exam_access_token_expiration": "", + }, "isLoading": false, "proctoringSettings": Object { "contact_us": "", @@ -236,6 +255,10 @@ Object { "allowProctoringOptOut": false, "apiErrorMsg": "", "exam": Object {}, + "examAccessToken": Object { + "exam_access_token": "", + "exam_access_token_expiration": "", + }, "isLoading": false, "proctoringSettings": Object { "contact_us": "", @@ -387,6 +410,10 @@ Object { "total_time": "30 minutes", "type": "timed", }, + "examAccessToken": Object { + "exam_access_token": "", + "exam_access_token_expiration": "", + }, "isLoading": false, "proctoringSettings": Object { "contact_us": "", diff --git a/src/data/api.js b/src/data/api.js index 67491851..8e731f87 100644 --- a/src/data/api.js +++ b/src/data/api.js @@ -144,3 +144,11 @@ export async function fetchProctoringSettings(examId) { const { data } = await getAuthenticatedHttpClient().get(url.href); return data; } + +export async function fetchExamAccessToken(examId) { + const url = new URL( + `${getConfig().EXAMS_BASE_URL}/api/v1/access_tokens/exam_id/${examId}/`, + ); + const { data } = await getAuthenticatedHttpClient().get(url.href); + return data; +} diff --git a/src/data/index.js b/src/data/index.js index c665dc5f..9c6cb1a3 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -14,6 +14,7 @@ export { pingAttempt, resetExam, getAllowProctoringOptOut, + examRequiresAccessToken, } from './thunks'; export { default as store } from './store'; diff --git a/src/data/redux.test.jsx b/src/data/redux.test.jsx index 80530f31..25c40a48 100644 --- a/src/data/redux.test.jsx +++ b/src/data/redux.test.jsx @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getConfig, mergeConfig } from '@edx/frontend-platform'; +import { isExam, fetchExamAccess, getExamAccess } from '../api'; import * as thunks from './thunks'; import executeThunk from '../utils'; @@ -647,35 +648,112 @@ describe('Data layer integration tests', () => { describe('Test getLatestAttemptData', () => { it('Should get, and save latest attempt', async () => { const attemptDataUrl = `${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}?is_learning_mfe=true`; - axiosMock.onGet(attemptDataUrl).reply(200, { exam: {}, active_attempt: attempt }); + axiosMock.onGet(attemptDataUrl) + .reply(200, { + exam: {}, + active_attempt: attempt, + }); await executeThunk(thunks.getLatestAttemptData(courseId), store.dispatch); const state = store.getState(); - expect(state).toMatchSnapshot(); + expect(state) + .toMatchSnapshot(); }); }); - describe('Test exams IDA url', () => { + describe('Test examRequiresAccessToken without exams url', () => { + it('Should not fetch exam access token', async () => { + axiosMock.onGet(fetchExamAttemptsDataUrl).reply(200, { exam, active_attempt: attempt }); + await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); + await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState); + + const state = store.getState(); + expect(state.examState.exam.id).toBe(exam.id); + expect(state.examState.examAccessToken.exam_access_token).toBe(''); + }); + }); + + describe('Test examRequiresAccessToken for exams IDA url', () => { beforeAll(async () => { mergeConfig({ EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, }); }); - it('Should call the exams service for create attempt', async () => { + it('Should get exam access token', async () => { + const createExamAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt`; + const examURL = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${courseId}/content_id/${contentId}`; + const activeAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt/latest`; + const fetchExamAccessUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/access_tokens/exam_id/${exam.id}/`; + const examAccessToken = Factory.build('examAccessToken'); + + axiosMock.onGet(examURL).reply(200, { exam }); + axiosMock.onGet(activeAttemptURL).reply(200, {}); + axiosMock.onPost(createExamAttemptURL).reply(200, { exam_attempt_id: 1111111 }); + axiosMock.onGet(fetchExamAccessUrl).reply(200, examAccessToken); + + await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); + await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState); + await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState); + + const state = store.getState(); + expect(state.examState.examAccessToken).toMatchSnapshot(); + }); + + it('Should fail to fetch if no exam id', async () => { + const fetchExamAccessUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/access_tokens/exam_id/${exam.id}/`; + axiosMock.onGet(fetchExamAccessUrl).reply(200, {}); + await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState); + + const state = store.getState(); + expect(state.examState.examAccessToken.exam_access_token).toBe(''); + }); + + it('Should fail to fetch if API error occurs', async () => { const createExamAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt`; const examURL = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${courseId}/content_id/${contentId}`; const activeAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt/latest`; + const fetchExamAccessUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/access_tokens/exam_id/${exam.id}/`; axiosMock.onGet(examURL).reply(200, { exam }); axiosMock.onGet(activeAttemptURL).reply(200, {}); axiosMock.onPost(createExamAttemptURL).reply(200, { exam_attempt_id: 1111111 }); + axiosMock.onGet(fetchExamAccessUrl).reply(400, { detail: 'Exam access token not granted' }); await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState); + await executeThunk(thunks.examRequiresAccessToken(), store.dispatch, store.getState); + + const state = store.getState(); + expect(state.examState.examAccessToken.exam_access_token).toBe(''); + }); + }); - expect(axiosMock.history.post[0].url).toEqual(createExamAttemptURL); + describe('Test exams IDA url', () => { + beforeAll(async () => { + mergeConfig({ + EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, + }); + }); + + it('Should call the exams service for create attempt', async () => { + const createExamAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt`; + const examURL = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${courseId}/content_id/${contentId}`; + const activeAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt/latest`; + + axiosMock.onGet(examURL) + .reply(200, { exam }); + axiosMock.onGet(activeAttemptURL) + .reply(200, {}); + axiosMock.onPost(createExamAttemptURL) + .reply(200, { exam_attempt_id: 1111111 }); + + await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); + await executeThunk(thunks.startTimedExam(), store.dispatch, store.getState); + + expect(axiosMock.history.post[0].url) + .toEqual(createExamAttemptURL); }); it('Should call the exams service for update attempt', async () => { @@ -689,7 +767,8 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); await executeThunk(thunks.stopExam(), store.dispatch, store.getState); - expect(axiosMock.history.put[0].url).toEqual(updateExamAttemptURL); + expect(axiosMock.history.put[0].url) + .toEqual(updateExamAttemptURL); }); it('Should call the exams service to fetch attempt data', async () => { @@ -701,11 +780,14 @@ describe('Data layer integration tests', () => { await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); - expect(axiosMock.history.get[0].url).toEqual(examURL); - expect(axiosMock.history.get[1].url).toEqual(activeAttemptURL); + expect(axiosMock.history.get[0].url) + .toEqual(examURL); + expect(axiosMock.history.get[1].url) + .toEqual(activeAttemptURL); const state = store.getState(); - expect(state).toMatchSnapshot(); + expect(state) + .toMatchSnapshot(); }); it('Should call the exams service to get latest attempt data', async () => { @@ -737,3 +819,65 @@ describe('Data layer integration tests', () => { }); }); }); + +describe('External API integration tests', () => { + let store; + + describe('Test isExam', () => { + it('Should return false if exam is not set', async () => { + expect(isExam()).toBe(false); + }); + }); + + describe('Test getExamAccess', () => { + it('Should return empty string if no access token', async () => { + expect(getExamAccess()).toBe(''); + }); + }); + + describe('Test fetchExamAccess', () => { + beforeAll(async () => { + mergeConfig({ + EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, + }); + }); + + it('Should dispatch get exam access token', async () => { + const mockDispatch = jest.fn(() => store.dispatch); + const mockState = jest.fn(() => store.getState); + const dispatchReturn = fetchExamAccess(mockDispatch, mockState); + expect(dispatchReturn).toBeInstanceOf(Promise); + }); + }); +}); + +describe('External API integration tests', () => { + let store; + + describe('Test isExam', () => { + it('Should return false if exam is not set', async () => { + expect(isExam()).toBe(false); + }); + }); + + describe('Test getExamAccess', () => { + it('Should return empty string if no access token', async () => { + expect(getExamAccess()).toBe(''); + }); + }); + + describe('Test fetchExamAccess', () => { + beforeAll(async () => { + mergeConfig({ + EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, + }); + }); + + it('Should dispatch get exam access token', async () => { + const mockDispatch = jest.fn(() => store.dispatch); + const mockState = jest.fn(() => store.getState); + const dispatchReturn = fetchExamAccess(mockDispatch, mockState); + expect(dispatchReturn).toBeInstanceOf(Promise); + }); + }); +}); diff --git a/src/data/slice.js b/src/data/slice.js index e724e343..b573b734 100644 --- a/src/data/slice.js +++ b/src/data/slice.js @@ -64,6 +64,10 @@ export const examSlice = createSlice({ type: '', }, apiErrorMsg: '', + examAccessToken: { + exam_access_token: '', + exam_access_token_expiration: '', + }, }, reducers: { setAllowProctoringOptOut: (state, { payload }) => { @@ -83,6 +87,9 @@ export const examSlice = createSlice({ setProctoringSettings: (state, { payload }) => { state.proctoringSettings = payload.proctoringSettings; }, + setExamAccessToken: (state, { payload }) => { + state.examAccessToken = payload.examAccessToken; + }, expireExamAttempt: (state) => { state.timeIsOver = true; }, @@ -97,8 +104,8 @@ export const examSlice = createSlice({ export const { setIsLoading, setExamState, expireExamAttempt, - setActiveAttempt, setProctoringSettings, setReviewPolicy, - setApiError, setAllowProctoringOptOut, + setActiveAttempt, setProctoringSettings, setExamAccessToken, + setReviewPolicy, setApiError, setAllowProctoringOptOut, } = examSlice.actions; export default examSlice.reducer; diff --git a/src/data/thunks.js b/src/data/thunks.js index d88ca3fe..0bc34e5b 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -1,4 +1,5 @@ import { logError } from '@edx/frontend-platform/logging'; +import { getConfig } from '@edx/frontend-platform'; import { fetchExamAttemptsData, fetchLatestAttempt, @@ -13,6 +14,7 @@ import { resetAttempt, declineAttempt, endExamWithFailure, + fetchExamAccessToken, } from './api'; import { isEmpty } from '../helpers'; import { @@ -21,6 +23,7 @@ import { expireExamAttempt, setActiveAttempt, setProctoringSettings, + setExamAccessToken, setReviewPolicy, setApiError, setAllowProctoringOptOut, @@ -60,7 +63,6 @@ function updateAttemptAfter(courseId, sequenceId, promiseToBeResolvedFirst = nul if (!noLoading) { dispatch(setIsLoading({ isLoading: false })); } } } - try { const attemptData = await fetchExamAttemptsData(courseId, sequenceId); dispatch(setExamState({ @@ -116,6 +118,25 @@ export function getProctoringSettings() { }; } +export function examRequiresAccessToken() { + return async (dispatch, getState) => { + if (!getConfig().EXAMS_BASE_URL) { + return; + } + const { exam } = getState().examState; + if (!exam.id) { + logError('Failed to get exam access token. No exam id.'); + return; + } + try { + const examAccessToken = await fetchExamAccessToken(exam.id); + dispatch(setExamAccessToken({ examAccessToken })); + } catch (error) { + logError('Exam access token was not granted.'); + } + }; +} + /** * Start a timed exam */ diff --git a/src/index.jsx b/src/index.jsx index 33c9a16c..5196adae 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,2 +1,8 @@ export { default } from './core/SequenceExamWrapper'; export { default as OuterExamTimer } from './core/OuterExamTimer'; +export { + getExamAccess, + isExam, + fetchExamAccess, +} from './api'; +export { store } from './data';