Skip to content

Commit

Permalink
feat: fetch exam access token (#82)
Browse files Browse the repository at this point in the history
  • Loading branch information
varshamenon4 authored Mar 30, 2023
1 parent 76df9f2 commit b35792c
Show file tree
Hide file tree
Showing 11 changed files with 252 additions and 12 deletions.
16 changes: 16 additions & 0 deletions src/api.js
Original file line number Diff line number Diff line change
@@ -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());
}
7 changes: 7 additions & 0 deletions src/data/__factories__/examAccessToken.factory.js
Original file line number Diff line number Diff line change
@@ -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',
});
2 changes: 2 additions & 0 deletions src/data/__factories__/examState.factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/data/__factories__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ import './examState.factory';
import './exam.factory';
import './attempt.factory';
import './proctoringSettings.factory';
import './examAccessToken.factory';
27 changes: 27 additions & 0 deletions src/data/__snapshots__/redux.test.jsx.snap
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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": "",
Expand Down
8 changes: 8 additions & 0 deletions src/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions src/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
pingAttempt,
resetExam,
getAllowProctoringOptOut,
examRequiresAccessToken,
} from './thunks';

export { default as store } from './store';
Expand Down
162 changes: 153 additions & 9 deletions src/data/redux.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
});
11 changes: 9 additions & 2 deletions src/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export const examSlice = createSlice({
type: '',
},
apiErrorMsg: '',
examAccessToken: {
exam_access_token: '',
exam_access_token_expiration: '',
},
},
reducers: {
setAllowProctoringOptOut: (state, { payload }) => {
Expand All @@ -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;
},
Expand All @@ -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;
Loading

0 comments on commit b35792c

Please sign in to comment.