From 34b88dfc3755b2896242511fddfb63db76dc7f2c Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Wed, 19 Apr 2023 13:29:47 -0400 Subject: [PATCH] feat: start lti proctoring software (#101) * feat: start lti proctoring software --- src/instructions/Instructions.test.jsx | 167 +++++++++++++++++- .../LtiProviderInstructions.jsx | 47 +++++ ...Instructions.jsx => RPNowInstructions.jsx} | 6 +- ...tions.jsx => RestProviderInstructions.jsx} | 8 +- .../download-instructions/index.jsx | 54 ++++-- 5 files changed, 255 insertions(+), 27 deletions(-) create mode 100644 src/instructions/proctored_exam/download-instructions/LtiProviderInstructions.jsx rename src/instructions/proctored_exam/download-instructions/{DefaultInstructions.jsx => RPNowInstructions.jsx} (93%) rename src/instructions/proctored_exam/download-instructions/{ProviderInstructions.jsx => RestProviderInstructions.jsx} (88%) diff --git a/src/instructions/Instructions.test.jsx b/src/instructions/Instructions.test.jsx index 6e424879..bcaffaaf 100644 --- a/src/instructions/Instructions.test.jsx +++ b/src/instructions/Instructions.test.jsx @@ -1,13 +1,16 @@ import '@testing-library/jest-dom'; import { Factory } from 'rosie'; import React from 'react'; -import { fireEvent } from '@testing-library/dom'; +import { fireEvent, waitFor } from '@testing-library/dom'; import Instructions from './index'; import { store, getExamAttemptsData, startTimedExam } from '../data'; +import { pollExamAttempt } from '../data/api'; import { continueExam, submitExam } from '../data/thunks'; import Emitter from '../data/emitter'; import { TIMER_REACHED_NULL } from '../timer/events'; -import { render, screen, act } from '../setupTest'; +import { + render, screen, act, initializeMockApp, +} from '../setupTest'; import ExamStateProvider from '../core/ExamStateProvider'; import { ExamStatus, ExamType, INCOMPLETE_STATUSES, @@ -23,14 +26,23 @@ jest.mock('../data/thunks', () => ({ getExamReviewPolicy: jest.fn(), submitExam: jest.fn(), })); +jest.mock('../data/api', () => ({ + pollExamAttempt: jest.fn(), + softwareDownloadAttempt: jest.fn(), +})); continueExam.mockReturnValue(jest.fn()); submitExam.mockReturnValue(jest.fn()); getExamAttemptsData.mockReturnValue(jest.fn()); startTimedExam.mockReturnValue(jest.fn()); +pollExamAttempt.mockReturnValue(Promise.resolve({})); store.subscribe = jest.fn(); store.dispatch = jest.fn(); describe('SequenceExamWrapper', () => { + beforeEach(() => { + initializeMockApp(); + }); + it('Start exam instructions can be successfully rendered', () => { store.getState = () => ({ examState: Factory.build('examState') }); @@ -709,7 +721,116 @@ describe('SequenceExamWrapper', () => { expect(screen.getByText('You have submitted this proctored exam for review')).toBeInTheDocument(); }); - it('Shows download software proctored exam instructions if attempt status is created', () => { + it('Shows correct download instructions for LTI provider if attempt status is created', () => { + store.getState = () => ({ + examState: Factory.build('examState', { + activeAttempt: {}, + proctoringSettings: Factory.build('proctoringSettings', { + provider_name: 'LTI Provider', + provider_tech_support_email: 'ltiprovidersupport@example.com', + provider_tech_support_phone: '+123456789', + }), + exam: Factory.build('exam', { + is_proctored: true, + type: ExamType.PROCTORED, + attempt: Factory.build('attempt', { + attempt_status: ExamStatus.CREATED, + }), + }), + }), + }); + + render( + + +
Sequence
+
+
, + { store }, + ); + + expect(screen.getByText( + 'If you have issues relating to proctoring, you can contact ' + + 'LTI Provider technical support by emailing ltiprovidersupport@example.com or by calling +123456789.', + )).toBeInTheDocument(); + expect(screen.getByText('Set up and start your proctored exam.')).toBeInTheDocument(); + expect(screen.getByText('Start System Check')).toBeInTheDocument(); + expect(screen.getByText('Start Exam')).toBeInTheDocument(); + }); + + it('Hides support contact info on download instructions for LTI provider if not provided', () => { + store.getState = () => ({ + examState: Factory.build('examState', { + activeAttempt: {}, + proctoringSettings: Factory.build('proctoringSettings', { + provider_name: 'LTI Provider', + }), + exam: Factory.build('exam', { + is_proctored: true, + type: ExamType.PROCTORED, + attempt: Factory.build('attempt', { + attempt_status: ExamStatus.CREATED, + }), + }), + }), + }); + + render( + + +
Sequence
+
+
, + { store }, + ); + + expect(screen.queryByText('If you have issues relating to proctoring, you can contact LTI Provider')).toBeNull(); + expect(screen.getByText('Set up and start your proctored exam.')).toBeInTheDocument(); + expect(screen.getByText('Start System Check')).toBeInTheDocument(); + expect(screen.getByText('Start Exam')).toBeInTheDocument(); + }); + + it('Initiates an LTI launch in a new window when the user clicks the System Check button', async () => { + const windowSpy = jest.spyOn(window, 'open'); + windowSpy.mockImplementation(() => ({})); + store.getState = () => ({ + examState: Factory.build('examState', { + activeAttempt: {}, + proctoringSettings: Factory.build('proctoringSettings', { + provider_name: 'LTI Provider', + provider_tech_support_email: 'ltiprovidersupport@example.com', + provider_tech_support_phone: '+123456789', + }), + exam: Factory.build('exam', { + is_proctored: true, + type: ExamType.PROCTORED, + attempt: Factory.build('attempt', { + attempt_id: 4321, + attempt_status: ExamStatus.CREATED, + }), + }), + getExamAttemptsData, + }), + }); + + render( + + +
Sequence
+
+
, + { store }, + ); + fireEvent.click(screen.getByText('Start System Check')); + await waitFor(() => { expect(windowSpy).toHaveBeenCalledWith('http://localhost:18740/lti/start_proctoring/4321', '_blank'); }); + + // also validate start button works + pollExamAttempt.mockReturnValue(Promise.resolve({ status: ExamStatus.READY_TO_START })); + fireEvent.click(screen.getByText('Start Exam')); + await waitFor(() => { expect(getExamAttemptsData).toHaveBeenCalled(); }); + }); + + it('Shows correct download instructions for legacy rest provider if attempt status is created', () => { const instructions = [ 'instruction 1', 'instruction 2', @@ -729,8 +850,10 @@ describe('SequenceExamWrapper', () => { exam: Factory.build('exam', { is_proctored: true, type: ExamType.PROCTORED, + use_legacy_attempt_api: true, attempt: Factory.build('attempt', { attempt_status: ExamStatus.CREATED, + use_legacy_attempt_api: true, }), }), }), @@ -755,6 +878,41 @@ describe('SequenceExamWrapper', () => { }); }); + it('Shows correct download instructions for legacy rpnow provider if attempt status is created', () => { + store.getState = () => ({ + examState: Factory.build('examState', { + activeAttempt: {}, + proctoringSettings: Factory.build('proctoringSettings', { + provider_name: 'Provider Name', + exam_proctoring_backend: {}, + }), + exam: Factory.build('exam', { + is_proctored: true, + type: ExamType.PROCTORED, + use_legacy_attempt_api: true, + attempt: Factory.build('attempt', { + attempt_status: ExamStatus.CREATED, + attempt_code: '1234-5678-9012-3456', + use_legacy_attempt_api: true, + }), + }), + }), + }); + + render( + + +
Sequence
+
+
, + { store }, + ); + expect(screen.getByDisplayValue('1234-5678-9012-3456')).toBeInTheDocument(); + expect(screen.getByText('For security and exam integrity reasons, ' + + 'we ask you to sign in to your edX account. Then we will ' + + 'direct you to the RPNow proctoring experience.')).toBeInTheDocument(); + }); + it('Shows error message if receives unknown attempt status', () => { store.getState = () => ({ examState: Factory.build('examState', { @@ -806,7 +964,7 @@ describe('SequenceExamWrapper', () => { expect(screen.getByText('You must adhere to the following rules while you complete this exam.')).toBeInTheDocument(); }); - it('Shows loading spinner while waiting to start exam', () => { + it('Shows loading spinner while waiting to start exam', async () => { store.getState = () => ({ examState: Factory.build('examState', { activeAttempt: {}, @@ -831,6 +989,7 @@ describe('SequenceExamWrapper', () => { ); fireEvent.click(screen.getByTestId('start-exam-button')); + waitFor(() => expect(getExamAttemptsData).toHaveBeenCalled()); expect(screen.getByTestId('exam-loading-spinner')).toBeInTheDocument(); }); }); diff --git a/src/instructions/proctored_exam/download-instructions/LtiProviderInstructions.jsx b/src/instructions/proctored_exam/download-instructions/LtiProviderInstructions.jsx new file mode 100644 index 00000000..e9cc2d84 --- /dev/null +++ b/src/instructions/proctored_exam/download-instructions/LtiProviderInstructions.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const LtiProviderExamInstructions = ({ + providerName, supportEmail, supportPhone, +}) => ( + <> +

+ +

+ {supportEmail && supportPhone && ( +

+ +

+ )} + +); + +LtiProviderExamInstructions.propTypes = { + providerName: PropTypes.string, + supportEmail: PropTypes.string, + supportPhone: PropTypes.string, +}; + +LtiProviderExamInstructions.defaultProps = { + providerName: '', + supportEmail: '', + supportPhone: '', +}; + +export default LtiProviderExamInstructions; diff --git a/src/instructions/proctored_exam/download-instructions/DefaultInstructions.jsx b/src/instructions/proctored_exam/download-instructions/RPNowInstructions.jsx similarity index 93% rename from src/instructions/proctored_exam/download-instructions/DefaultInstructions.jsx rename to src/instructions/proctored_exam/download-instructions/RPNowInstructions.jsx index b4d201dd..85c49532 100644 --- a/src/instructions/proctored_exam/download-instructions/DefaultInstructions.jsx +++ b/src/instructions/proctored_exam/download-instructions/RPNowInstructions.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import ExamCode from './ExamCode'; -const DefaultInstructions = ({ code }) => ( +const RPNowInstructions = ({ code }) => ( <>
( ); -DefaultInstructions.propTypes = { +RPNowInstructions.propTypes = { code: PropTypes.string.isRequired, }; -export default DefaultInstructions; +export default RPNowInstructions; diff --git a/src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx b/src/instructions/proctored_exam/download-instructions/RestProviderInstructions.jsx similarity index 88% rename from src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx rename to src/instructions/proctored_exam/download-instructions/RestProviderInstructions.jsx index d5eb76b5..5d34fdcf 100644 --- a/src/instructions/proctored_exam/download-instructions/ProviderInstructions.jsx +++ b/src/instructions/proctored_exam/download-instructions/RestProviderInstructions.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -const ProviderProctoredExamInstructions = ({ +const RestProviderInstructions = ({ providerName, supportEmail, supportPhone, instructions, }) => ( <> @@ -39,17 +39,17 @@ const ProviderProctoredExamInstructions = ({ ); -ProviderProctoredExamInstructions.propTypes = { +RestProviderInstructions.propTypes = { providerName: PropTypes.string, supportEmail: PropTypes.string, supportPhone: PropTypes.string, instructions: PropTypes.arrayOf(PropTypes.string).isRequired, }; -ProviderProctoredExamInstructions.defaultProps = { +RestProviderInstructions.defaultProps = { providerName: '', supportEmail: '', supportPhone: '', }; -export default ProviderProctoredExamInstructions; +export default RestProviderInstructions; diff --git a/src/instructions/proctored_exam/download-instructions/index.jsx b/src/instructions/proctored_exam/download-instructions/index.jsx index 6abd7476..7830cd5d 100644 --- a/src/instructions/proctored_exam/download-instructions/index.jsx +++ b/src/instructions/proctored_exam/download-instructions/index.jsx @@ -1,5 +1,6 @@ import React, { useContext, useState } from 'react'; import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Container } from '@edx/paragon'; import ExamStateContext from '../../../context'; @@ -7,8 +8,9 @@ import { ExamStatus } from '../../../constants'; import WarningModal from '../WarningModal'; import { pollExamAttempt, softwareDownloadAttempt } from '../../../data/api'; import messages from '../messages'; -import ProviderInstructions from './ProviderInstructions'; -import DefaultInstructions from './DefaultInstructions'; +import LtiProviderExamInstructions from './LtiProviderInstructions'; +import RestProviderInstructions from './RestProviderInstructions'; +import RPNowInstructions from './RPNowInstructions'; import DownloadButtons from './DownloadButtons'; import Footer from '../Footer'; import SkipProctoredExamButton from '../SkipProctoredExamButton'; @@ -31,6 +33,7 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam }) attempt_code: examCode, attempt_id: attemptId, software_download_url: downloadUrl, + use_legacy_attempt_api: useLegacyAttemptApi, } = attempt; const { provider_name: providerName, @@ -38,10 +41,13 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam }) provider_tech_support_phone: supportPhone, exam_proctoring_backend: proctoringBackend, } = proctoringSettings; + const examHasLtiProvider = !useLegacyAttemptApi; const { instructions } = proctoringBackend || {}; const [systemCheckStatus, setSystemCheckStatus] = useState(''); const [downloadClicked, setDownloadClicked] = useState(false); const withProviderInstructions = instructions && instructions.length > 0; + const launchSoftwareUrl = examHasLtiProvider + ? `${getConfig().EXAMS_BASE_URL}/lti/start_proctoring/${attemptId}` : downloadUrl; const handleDownloadClick = () => { pollExamAttempt(`${pollUrl}?sourceid=instructions`) @@ -50,14 +56,14 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam }) setSystemCheckStatus('success'); } else { softwareDownloadAttempt(attemptId); - window.open(downloadUrl, '_blank'); + window.open(launchSoftwareUrl, '_blank'); } }); setDownloadClicked(true); }; const handleStartExamClick = () => { - pollExamAttempt(`${attempt.exam_started_poll_url}?sourceid=instructions`) + pollExamAttempt(`${pollUrl}?sourceid=instructions`) .then((data) => ( data.status === ExamStatus.READY_TO_START ? getExamAttemptsData(courseId, sequenceId) @@ -65,6 +71,31 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam }) )); }; + function providerInstructions() { + if (examHasLtiProvider) { + return ( + + ); + } + if (withProviderInstructions) { + return ( + + ); + } + return ( + + ); + } + return (
@@ -88,23 +119,14 @@ const DownloadSoftwareProctoredExamInstructions = ({ intl, skipProctoredExam }) defaultMessage="Set up and start your proctored exam." />
- {withProviderInstructions - ? ( - - ) - : } + { providerInstructions() } - {!withProviderInstructions && ( + {!examHasLtiProvider && !withProviderInstructions && (