From 71a6c1cf61ce66e21a730b574a9f268bd079eb88 Mon Sep 17 00:00:00 2001 From: alangsto <46360176+alangsto@users.noreply.github.com> Date: Fri, 17 Mar 2023 09:22:13 -0400 Subject: [PATCH] feat: separate endpoint calls for fetchExamAttemptsData (#88) --- src/core/OuterExamTimer.jsx | 4 +- src/core/OuterExamTimer.test.jsx | 6 +- src/data/__snapshots__/redux.test.jsx.snap | 182 +++++++++++++++++++++ src/data/api.js | 51 +++++- src/data/index.js | 1 + src/data/redux.test.jsx | 54 +++++- src/data/thunks.js | 18 ++ 7 files changed, 299 insertions(+), 17 deletions(-) diff --git a/src/core/OuterExamTimer.jsx b/src/core/OuterExamTimer.jsx index 28188f42..fac2dc2c 100644 --- a/src/core/OuterExamTimer.jsx +++ b/src/core/OuterExamTimer.jsx @@ -12,7 +12,7 @@ const ExamTimer = ({ courseId }) => { const { activeAttempt, showTimer, stopExam, submitExam, expireExam, pollAttempt, apiErrorMsg, pingAttempt, - getExamAttemptsData, + getLatestAttemptData, } = state; // if user is not authenticated they cannot have active exam, so no need for timer @@ -22,7 +22,7 @@ const ExamTimer = ({ courseId }) => { } useEffect(() => { - getExamAttemptsData(courseId); + getLatestAttemptData(courseId); }, [courseId]); return ( diff --git a/src/core/OuterExamTimer.test.jsx b/src/core/OuterExamTimer.test.jsx index 1b0cd9e2..92fdbe81 100644 --- a/src/core/OuterExamTimer.test.jsx +++ b/src/core/OuterExamTimer.test.jsx @@ -2,13 +2,13 @@ import '@testing-library/jest-dom'; import { Factory } from 'rosie'; import React from 'react'; import OuterExamTimer from './OuterExamTimer'; -import { store, getExamAttemptsData } from '../data'; +import { store, getLatestAttemptData } from '../data'; import { render } from '../setupTest'; import { ExamStatus } from '../constants'; jest.mock('../data', () => ({ store: {}, - getExamAttemptsData: jest.fn(), + getLatestAttemptData: jest.fn(), Emitter: { on: () => jest.fn(), once: () => jest.fn(), @@ -16,7 +16,7 @@ jest.mock('../data', () => ({ emit: () => jest.fn(), }, })); -getExamAttemptsData.mockReturnValue(jest.fn()); +getLatestAttemptData.mockReturnValue(jest.fn()); store.subscribe = jest.fn(); store.dispatch = jest.fn(); diff --git a/src/data/__snapshots__/redux.test.jsx.snap b/src/data/__snapshots__/redux.test.jsx.snap index d4fb427d..2a62833a 100644 --- a/src/data/__snapshots__/redux.test.jsx.snap +++ b/src/data/__snapshots__/redux.test.jsx.snap @@ -1,5 +1,140 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Data layer integration tests Test exams IDA url Should call the exams service to fetch attempt data 1`] = ` +Object { + "examState": Object { + "activeAttempt": Object { + "accessibility_time_string": "you have 30 minutes remaining", + "attempt_code": "", + "attempt_id": 1, + "attempt_status": "started", + "course_id": "course-v1:test+special+exam", + "critically_low_threshold_sec": 90, + "desktop_application_js_url": "", + "exam_display_name": "timed", + "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", + "exam_type": "a timed exam", + "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", + "in_timed_exam": true, + "low_threshold_sec": 360, + "software_download_url": "", + "taking_as_proctored": false, + "time_remaining_seconds": 1799.9, + "total_time": "30 minutes", + }, + "allowProctoringOptOut": false, + "apiErrorMsg": "", + "exam": Object { + "attempt": Object { + "accessibility_time_string": "you have 30 minutes remaining", + "attempt_code": "", + "attempt_id": 1, + "attempt_status": "started", + "course_id": "course-v1:test+special+exam", + "critically_low_threshold_sec": 90, + "desktop_application_js_url": "", + "exam_display_name": "timed", + "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", + "exam_type": "a timed exam", + "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", + "in_timed_exam": true, + "low_threshold_sec": 360, + "software_download_url": "", + "taking_as_proctored": false, + "time_remaining_seconds": 1799.9, + "total_time": "30 minutes", + }, + "backend": "test", + "content_id": "block-v1:test+special+exam+type@sequential+block@abc123", + "course_id": "course-v1:test+special+exam", + "due_date": null, + "exam_name": "prock exam", + "external_id": null, + "hide_after_due": false, + "id": 1, + "is_active": true, + "is_practice_exam": false, + "is_proctored": false, + "prerequisite_status": Object { + "are_prerequisites_satisifed": true, + "declined_prerequisites": Array [], + "failed_prerequisites": Array [], + "pending_prerequisites": Array [], + "satisfied_prerequisites": Array [], + }, + "time_limit_mins": 30, + "total_time": "30 minutes", + "type": "timed", + }, + "isLoading": false, + "proctoringSettings": Object { + "contact_us": "", + "exam_proctoring_backend": Object { + "download_url": "", + "instructions": Array [], + "name": "", + "rules": Object {}, + }, + "integration_specific_email": "", + "learner_notification_from_email": "", + "link_urls": null, + "platform_name": "", + "provider_name": "", + "provider_tech_support_email": "", + "provider_tech_support_phone": "", + }, + "timeIsOver": false, + }, +} +`; + +exports[`Data layer integration tests Test exams IDA url Should call the exams service to get latest attempt data 1`] = ` +Object { + "examState": Object { + "activeAttempt": Object { + "accessibility_time_string": "you have 30 minutes remaining", + "attempt_code": "", + "attempt_id": 1, + "attempt_status": "started", + "course_id": "course-v1:test+special+exam", + "critically_low_threshold_sec": 90, + "desktop_application_js_url": "", + "exam_display_name": "timed", + "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", + "exam_type": "a timed exam", + "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", + "in_timed_exam": true, + "low_threshold_sec": 360, + "software_download_url": "", + "taking_as_proctored": false, + "time_remaining_seconds": 1799.9, + "total_time": "30 minutes", + }, + "allowProctoringOptOut": false, + "apiErrorMsg": "", + "exam": Object {}, + "isLoading": false, + "proctoringSettings": Object { + "contact_us": "", + "exam_proctoring_backend": Object { + "download_url": "", + "instructions": Array [], + "name": "", + "rules": Object {}, + }, + "integration_specific_email": "", + "learner_notification_from_email": "", + "link_urls": null, + "platform_name": "", + "provider_name": "", + "provider_tech_support_email": "", + "provider_tech_support_phone": "", + }, + "timeIsOver": false, + }, +} +`; + exports[`Data layer integration tests Test getExamAttemptsData Should get, and save exam and attempt 1`] = ` Object { "examState": Object { @@ -88,6 +223,53 @@ Object { } `; +exports[`Data layer integration tests Test getLatestAttemptData Should get, and save latest attempt 1`] = ` +Object { + "examState": Object { + "activeAttempt": Object { + "accessibility_time_string": "you have 30 minutes remaining", + "attempt_code": "", + "attempt_id": 1, + "attempt_status": "started", + "course_id": "course-v1:test+special+exam", + "critically_low_threshold_sec": 90, + "desktop_application_js_url": "", + "exam_display_name": "timed", + "exam_started_poll_url": "/api/edx_proctoring/v1/proctored_exam/attempt/1", + "exam_type": "a timed exam", + "exam_url_path": "http://localhost:2000/course/course-v1:test+special+exam/block-v1:test+special+exam+type@sequential+block@abc123", + "in_timed_exam": true, + "low_threshold_sec": 360, + "software_download_url": "", + "taking_as_proctored": false, + "time_remaining_seconds": 1799.9, + "total_time": "30 minutes", + }, + "allowProctoringOptOut": false, + "apiErrorMsg": "", + "exam": Object {}, + "isLoading": false, + "proctoringSettings": Object { + "contact_us": "", + "exam_proctoring_backend": Object { + "download_url": "", + "instructions": Array [], + "name": "", + "rules": Object {}, + }, + "integration_specific_email": "", + "learner_notification_from_email": "", + "link_urls": null, + "platform_name": "", + "provider_name": "", + "provider_tech_support_email": "", + "provider_tech_support_phone": "", + }, + "timeIsOver": false, + }, +} +`; + exports[`Data layer integration tests Test getProctoringSettings Should fail to fetch if error occurs 1`] = ` Object { "contact_us": "", diff --git a/src/data/api.js b/src/data/api.js index 5a7d1b61..a38631ec 100644 --- a/src/data/api.js +++ b/src/data/api.js @@ -4,15 +4,50 @@ import { ExamAction } from '../constants'; const BASE_API_URL = '/api/edx_proctoring/v1/proctored_exam/attempt'; +async function fetchActiveAttempt() { + const activeAttemptUrl = new URL(`${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt/latest`); + const activeAttemptResponse = await getAuthenticatedHttpClient().get(activeAttemptUrl.href); + return activeAttemptResponse.data.attempt; +} + export async function fetchExamAttemptsData(courseId, sequenceId) { - const url = new URL( - `${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}`, - ); - if (sequenceId) { + let data; + if (!getConfig().EXAMS_BASE_URL) { + const url = new URL( + `${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}`, + ); url.searchParams.append('content_id', sequenceId); + url.searchParams.append('is_learning_mfe', true); + const urlResponse = await getAuthenticatedHttpClient().get(url.href); + data = urlResponse.data; + } else { + const examUrl = new URL(`${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${courseId}/content_id/${sequenceId}`); + const examResponse = await getAuthenticatedHttpClient().get(examUrl.href); + data = examResponse.data; + + const attemptData = await fetchActiveAttempt(); + data.active_attempt = attemptData; + } + return data; +} + +// +export async function fetchLatestAttempt(courseId) { + let data; + if (!getConfig().EXAMS_BASE_URL) { + const url = new URL( + `${getConfig().LMS_BASE_URL}${BASE_API_URL}/course_id/${courseId}`, + ); + url.searchParams.append('is_learning_mfe', true); + const urlResponse = await getAuthenticatedHttpClient().get(url.href); + data = urlResponse.data; + } else { + // initialize data dictionary to be similar to what edx-proctoring returns + data = { exam: {} }; + + const attemptData = await fetchActiveAttempt(); + data.active_attempt = attemptData; } - url.searchParams.append('is_learning_mfe', true); - const { data } = await getAuthenticatedHttpClient().get(url.href); return data; } @@ -26,7 +61,7 @@ export async function createExamAttempt(examId, startClock = true, attemptProcto if (!getConfig().EXAMS_BASE_URL) { urlString = `${getConfig().LMS_BASE_URL}${BASE_API_URL}`; } else { - urlString = `${getConfig().EXAMS_BASE_URL}/exams/attempt`; + urlString = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt`; } const url = new URL(urlString); const payload = { @@ -43,7 +78,7 @@ export async function updateAttemptStatus(attemptId, action, detail = null) { if (!getConfig().EXAMS_BASE_URL) { urlString = `${getConfig().LMS_BASE_URL}${BASE_API_URL}/${attemptId}`; } else { - urlString = `${getConfig().EXAMS_BASE_URL}/attempt/${attemptId}`; + urlString = `${getConfig().EXAMS_BASE_URL}/api/v1/attempt/${attemptId}`; } const url = new URL(urlString); const payload = { action }; diff --git a/src/data/index.js b/src/data/index.js index 53ef8539..c665dc5f 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -1,5 +1,6 @@ export { getExamAttemptsData, + getLatestAttemptData, getProctoringSettings, startTimedExam, startProctoredExam, diff --git a/src/data/redux.test.jsx b/src/data/redux.test.jsx index b19b78ac..e99a0095 100644 --- a/src/data/redux.test.jsx +++ b/src/data/redux.test.jsx @@ -644,6 +644,18 @@ 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 }); + + await executeThunk(thunks.getLatestAttemptData(courseId), store.dispatch); + + const state = store.getState(); + expect(state).toMatchSnapshot(); + }); + }); + describe('Test exams IDA url', () => { beforeAll(async () => { mergeConfig({ @@ -652,9 +664,12 @@ describe('Data layer integration tests', () => { }); it('Should call the exams service for create attempt', async () => { - const createExamAttemptURL = `${getConfig().EXAMS_BASE_URL}/exams/attempt`; + 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(fetchExamAttemptsDataUrl).replyOnce(200, { exam, active_attempt: {} }); + 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); @@ -664,14 +679,45 @@ describe('Data layer integration tests', () => { }); it('Should call the exams service for update attempt', async () => { - const updateExamAttemptURL = `${getConfig().EXAMS_BASE_URL}/attempt/${attempt.attempt_id}`; + const updateExamAttemptURL = `${getConfig().EXAMS_BASE_URL}/api/v1/attempt/${attempt.attempt_id}`; + 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(fetchExamAttemptsDataUrl).replyOnce(200, { exam: {}, active_attempt: attempt }); + axiosMock.onGet(examURL).reply(200, { exam }); + axiosMock.onGet(activeAttemptURL).reply(200, { attempt }); axiosMock.onPut(updateExamAttemptURL).reply(200, { exam_attempt_id: attempt.attempt_id }); await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); await executeThunk(thunks.stopExam(), store.dispatch, store.getState); expect(axiosMock.history.put[0].url).toEqual(updateExamAttemptURL); }); + + it('Should call the exams service to fetch attempt data', async () => { + 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, { attempt }); + + await executeThunk(thunks.getExamAttemptsData(courseId, contentId), store.dispatch); + + expect(axiosMock.history.get[0].url).toEqual(examURL); + expect(axiosMock.history.get[1].url).toEqual(activeAttemptURL); + + const state = store.getState(); + expect(state).toMatchSnapshot(); + }); + + it('Should call the exams service to get latest attempt data', async () => { + const attemptDataUrl = `${getConfig().EXAMS_BASE_URL}/api/v1/exams/attempt/latest`; + axiosMock.onGet(attemptDataUrl).reply(200, { attempt }); + + await executeThunk(thunks.getLatestAttemptData(courseId), store.dispatch); + + expect(axiosMock.history.get[0].url).toEqual(attemptDataUrl); + + const state = store.getState(); + expect(state).toMatchSnapshot(); + }); }); }); diff --git a/src/data/thunks.js b/src/data/thunks.js index 48cfecb5..d88ca3fe 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -1,6 +1,7 @@ import { logError } from '@edx/frontend-platform/logging'; import { fetchExamAttemptsData, + fetchLatestAttempt, createExamAttempt, stopAttempt, continueAttempt, @@ -78,6 +79,23 @@ export function getExamAttemptsData(courseId, sequenceId) { return updateAttemptAfter(courseId, sequenceId); } +export function getLatestAttemptData(courseId) { + return async (dispatch) => { + dispatch(setIsLoading({ isLoading: true })); + try { + const attemptData = await fetchLatestAttempt(courseId); + dispatch(setExamState({ + exam: attemptData.exam, + activeAttempt: !isEmpty(attemptData.active_attempt) ? attemptData.active_attempt : null, + })); + } catch (error) { + handleAPIError(error, dispatch); + } finally { + dispatch(setIsLoading({ isLoading: false })); + } + }; +} + export function getProctoringSettings() { return async (dispatch, getState) => { const { exam } = getState().examState;