From f54dc17788cd7a721e3c6c117c91e399db6c5d25 Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Tue, 11 Oct 2022 11:54:33 -0400 Subject: [PATCH] feat: configure lti exam providers (#356) Allows setting a course exam provider to integrations managed by edx-exams. This option is gated by the CourseWaffleFlag course_apps.exams_ida in edx-platform. --- .env | 1 + .env.development | 1 + .env.test | 1 + src/data/services/ExamsApiService.js | 34 ++ src/index.jsx | 1 + .../ProctoredExamSettings.jsx | 79 ++- .../ProctoredExamSettings.test.jsx | 457 ++++++++++++------ src/setupTest.js | 14 + 8 files changed, 421 insertions(+), 167 deletions(-) create mode 100644 src/data/services/ExamsApiService.js diff --git a/.env b/.env index e54ac766d..8bbb8c16d 100644 --- a/.env +++ b/.env @@ -5,6 +5,7 @@ CREDENTIALS_BASE_URL='' CSRF_TOKEN_API_PATH='' DISCOVERY_API_BASE_URL='' ECOMMERCE_BASE_URL='' +EXAMS_BASE_URL='' FAVICON_URL='' LANGUAGE_PREFERENCE_COOKIE_NAME='' LMS_BASE_URL='' diff --git a/.env.development b/.env.development index 06d577997..3bfeb2543 100644 --- a/.env.development +++ b/.env.development @@ -4,6 +4,7 @@ BASE_URL='localhost:2001' CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL= +EXAMS_BASE_URL= ECOMMERCE_BASE_URL='http://localhost:18130' FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' diff --git a/.env.test b/.env.test index 68bcb3840..91cf00cfe 100644 --- a/.env.test +++ b/.env.test @@ -4,6 +4,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' ECOMMERCE_BASE_URL='http://localhost:18130' +EXAMS_BASE_URL= FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' diff --git a/src/data/services/ExamsApiService.js b/src/data/services/ExamsApiService.js new file mode 100644 index 000000000..10a6a7526 --- /dev/null +++ b/src/data/services/ExamsApiService.js @@ -0,0 +1,34 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +class ExamsApiService { + static isAvailable() { + return !!this.getExamsBaseUrl(); + } + + static getExamsBaseUrl() { + return getConfig().EXAMS_BASE_URL; + } + + static getExamConfigurationUrl(courseId) { + return `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${courseId}`; + } + + static getAvailableProviders() { + const apiClient = getAuthenticatedHttpClient(); + const providersUrl = `${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`; + return apiClient.get(providersUrl); + } + + static getCourseExamConfiguration(courseId) { + const apiClient = getAuthenticatedHttpClient(); + return apiClient.get(this.getExamConfigurationUrl(courseId)); + } + + static saveCourseExamConfiguration(courseId, dataToSave) { + const apiClient = getAuthenticatedHttpClient(); + return apiClient.patch(this.getExamConfigurationUrl(courseId), dataToSave); + } +} + +export default ExamsApiService; diff --git a/src/index.jsx b/src/index.jsx index 643689a6f..41153d52e 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -47,6 +47,7 @@ initialize({ SUPPORT_URL: process.env.SUPPORT_URL || null, SUPPORT_EMAIL: process.env.SUPPORT_EMAIL || null, LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, + EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null, ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false', ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true', diff --git a/src/proctored-exam-settings/ProctoredExamSettings.jsx b/src/proctored-exam-settings/ProctoredExamSettings.jsx index 9a2790c47..3f8bab35d 100644 --- a/src/proctored-exam-settings/ProctoredExamSettings.jsx +++ b/src/proctored-exam-settings/ProctoredExamSettings.jsx @@ -15,6 +15,7 @@ import { import { getConfig } from '@edx/frontend-platform'; import messages from './ProctoredExamSettings.messages'; +import ExamsApiService from '../data/services/ExamsApiService'; import StudioApiService from '../data/services/StudioApiService'; import Loading from '../generic/Loading'; import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; @@ -33,8 +34,10 @@ function ProctoredExamSettings({ courseId, intl }) { const [loadingPermissionError, setLoadingPermissionError] = useState(false); const [enableProctoredExams, setEnableProctoredExams] = useState(true); const [allowOptingOut, setAllowOptingOut] = useState(false); + const [allowLtiProviders, setAllowLtiProviders] = useState(false); const [proctoringProvider, setProctoringProvider] = useState(''); const [availableProctoringProviders, setAvailableProctoringProviders] = useState([]); + const [ltiProctoringProviders, setLtiProctoringProviders] = useState([]); const [proctortrackEscalationEmail, setProctortrackEscalationEmail] = useState(''); const [createZendeskTickets, setCreateZendeskTickets] = useState(false); const [courseStartDate, setCourseStartDate] = useState(''); @@ -89,24 +92,41 @@ function ProctoredExamSettings({ courseId, intl }) { } } + function isLtiProvider(provider) { + return ltiProctoringProviders.some(p => p.name === provider); + } + function postSettingsBackToServer() { - const dataToPostBack = { + const providerIsLti = isLtiProvider(proctoringProvider); + const studioDataToPostBack = { proctored_exam_settings: { enable_proctored_exams: enableProctoredExams, - proctoring_provider: proctoringProvider, + // lti providers are managed outside edx-platform, lti_external indicates this + proctoring_provider: providerIsLti ? 'lti_external' : proctoringProvider, create_zendesk_tickets: createZendeskTickets, }, }; if (isEdxStaff) { - dataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = allowOptingOut; + studioDataToPostBack.proctored_exam_settings.allow_proctoring_opt_out = allowOptingOut; } if (proctoringProvider === 'proctortrack') { - dataToPostBack.proctored_exam_settings.proctoring_escalation_email = proctortrackEscalationEmail === '' ? null : proctortrackEscalationEmail; + studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = proctortrackEscalationEmail === '' ? null : proctortrackEscalationEmail; } setSubmissionInProgress(true); - StudioApiService.saveProctoredExamSettingsData(courseId, dataToPostBack).then(() => { + + // only save back to exam service if necessary + const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)]; + if (allowLtiProviders && ExamsApiService.isAvailable()) { + saveOperations.push( + ExamsApiService.saveCourseExamConfiguration( + courseId, { provider: providerIsLti ? proctoringProvider : null }, + ), + ); + } + Promise.all(saveOperations) + .then(() => { setSaveSuccess(true); setSaveError(false); setSubmissionInProgress(false); @@ -172,6 +192,11 @@ function ProctoredExamSettings({ courseId, intl }) { return markDisabled; } + function getProviderDisplayLabel(provider) { + // if a display label exists for this provider return it + return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider; + } + function getProctoringProviderOptions(providers) { return providers.map(provider => ( )); } @@ -338,7 +363,7 @@ function ProctoredExamSettings({ courseId, intl }) { )} {/* CREATE ZENDESK TICKETS */} - { isEdxStaff && enableProctoredExams && ( + { isEdxStaff && enableProctoredExams && !isLtiProvider(proctoringProvider) && (
@@ -470,20 +495,43 @@ function ProctoredExamSettings({ courseId, intl }) { useEffect( () => { dispatch(fetchExamSettingsPending(courseId)); - StudioApiService.getProctoredExamSettingsData(courseId) + + Promise.all([ + StudioApiService.getProctoredExamSettingsData(courseId), + ExamsApiService.isAvailable() ? ExamsApiService.getCourseExamConfiguration(courseId) : Promise.resolve(), + ExamsApiService.isAvailable() ? ExamsApiService.getAvailableProviders() : Promise.resolve(), + ]) .then( - response => { - const proctoredExamSettings = response.data.proctored_exam_settings; + ([settingsResponse, examConfigResponse, ltiProvidersResponse]) => { + const proctoredExamSettings = settingsResponse.data.proctored_exam_settings; setLoaded(true); setLoading(false); setSubmissionInProgress(false); - setCourseStartDate(response.data.course_start_date); + setCourseStartDate(settingsResponse.data.course_start_date); setEnableProctoredExams(proctoredExamSettings.enable_proctored_exams); setAllowOptingOut(proctoredExamSettings.allow_proctoring_opt_out); - setProctoringProvider(proctoredExamSettings.proctoring_provider); const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack'; setShowProctortrackEscalationEmail(isProctortrack); - setAvailableProctoringProviders(response.data.available_proctoring_providers); + + // The list of providers returned by studio settings are the default behavior. If lti_external + // is available as an option display the list of LTI providers returned by the exam service. + // Setting 'lti_external' in studio indicates an LTI provider configured outside of edx-platform. + // This option is not directly selectable. + const proctoringProvidersStudio = settingsResponse.data.available_proctoring_providers; + const proctoringProvidersLti = ltiProvidersResponse?.data || []; + setAllowLtiProviders(proctoringProvidersStudio.includes('lti_external')); + setLtiProctoringProviders(proctoringProvidersLti); + // flatten provider objects and coalesce values to just the provider key + setAvailableProctoringProviders( + proctoringProvidersLti.reduce((result, provider) => [...result, provider.name], []).concat( + proctoringProvidersStudio.filter(value => value !== 'lti_external'), + ), + ); + if (proctoredExamSettings.proctoring_provider === 'lti_external') { + setProctoringProvider(examConfigResponse.data.provider); + } else { + setProctoringProvider(proctoredExamSettings.proctoring_provider); + } // The backend API may return null for the proctoringEscalationEmail value, which is the default. // In order to keep our email input component controlled, we use the empty string as the default @@ -494,9 +542,10 @@ function ProctoredExamSettings({ courseId, intl }) { setCreateZendeskTickets(proctoredExamSettings.create_zendesk_tickets); dispatch(fetchExamSettingsSuccess(courseId)); }, - ).catch( + ) + .catch( error => { - if (error.response.status === 403) { + if (error.response?.status === 403) { setLoadingPermissionError(true); } else { setLoadingConnectionError(true); diff --git a/src/proctored-exam-settings/ProctoredExamSettings.test.jsx b/src/proctored-exam-settings/ProctoredExamSettings.test.jsx index 134bc7996..25784f421 100644 --- a/src/proctored-exam-settings/ProctoredExamSettings.test.jsx +++ b/src/proctored-exam-settings/ProctoredExamSettings.test.jsx @@ -5,11 +5,12 @@ import { import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; // import * as auth from '@edx/frontend-platform/auth'; import MockAdapter from 'axios-mock-adapter'; -import { initializeMockApp } from '@edx/frontend-platform'; +import { initializeMockApp, mergeConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import ProctoredExamSettings from './ProctoredExamSettings'; import StudioApiService from '../data/services/StudioApiService'; +import ExamsApiService from '../data/services/ExamsApiService'; import initializeStore from '../store'; const defaultProps = { @@ -30,38 +31,61 @@ const intlWrapper = children => ( ); describe('ProctoredExamSettings', () => { + function setupApp(isAdmin = true) { + mergeConfig({ + EXAMS_BASE_URL: 'http://exams.testing.co', + }, 'CourseAuthoringConfig'); + + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: isAdmin, + roles: [], + }, + }); + store = initializeStore(); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`, + ).reply(200, [ + { + name: 'test_lti', + verbose_name: 'LTI Provider', + }, + ]); + axiosMock.onGet( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(200, { + provider: null, + }); + + axiosMock.onGet( + StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), + ).reply(200, { + proctored_exam_settings: { + enable_proctored_exams: true, + allow_proctoring_opt_out: false, + proctoring_provider: 'mockproc', + proctoring_escalation_email: 'test@example.com', + create_zendesk_tickets: true, + }, + available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'], + course_start_date: '2070-01-01T00:00:00Z', + }); + } + afterEach(() => { cleanup(); + axiosMock.reset(); + }); + beforeEach(async () => { + setupApp(); }); describe('Field dependencies', () => { beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - - axiosMock.onGet( - StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).reply(200, { - proctored_exam_settings: { - enable_proctored_exams: true, - allow_proctoring_opt_out: false, - proctoring_provider: 'mockproc', - proctoring_escalation_email: 'test@example.com', - create_zendesk_tickets: true, - }, - available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'], - course_start_date: '2070-01-01T00:00:00Z', - }); - await act(async () => render(intlWrapper())); }); @@ -154,21 +178,23 @@ describe('ProctoredExamSettings', () => { expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull(); expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull(); }); + + it('Hides unsupported fields when lti provider is selected', async () => { + await waitFor(() => { + screen.getByDisplayValue('mockproc'); + }); + const selectElement = screen.getByDisplayValue('mockproc'); + await act(async () => { + fireEvent.change(selectElement, { target: { value: 'test_lti' } }); + }); + expect(screen.queryByTestId('escalationEmail')).toBeNull(); + expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull(); + expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull(); + }); }); describe('Validation with invalid escalation email', () => { beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: false, - roles: [], - }, - }); - - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock.onGet( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), ).reply(200, { @@ -183,10 +209,6 @@ describe('ProctoredExamSettings', () => { course_start_date: '2070-01-01T00:00:00Z', }); - axiosMock.onPost( - StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).reply(200, {}); - await act(async () => render(intlWrapper())); }); @@ -268,9 +290,16 @@ describe('ProctoredExamSettings', () => { }); it('Has no error when invalid proctoring escalation email is provided with proctoring disabled', async () => { + axiosMock.onPost( + StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), + ).reply(200, 'success'); + axiosMock.onPatch( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(200, 'success'); await waitFor(() => { screen.getByDisplayValue('proctortrack'); }); + const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com'); await act(async () => { fireEvent.change(selectEscalationEmailElement, { target: { value: '' } }); @@ -293,6 +322,13 @@ describe('ProctoredExamSettings', () => { }); it('Has no error when valid proctoring escalation email is provided with proctortrack selected', async () => { + axiosMock.onPost( + StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), + ).reply(200, 'success'); + axiosMock.onPatch( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(200, 'success'); + await waitFor(() => { screen.getByDisplayValue('proctortrack'); }); @@ -387,83 +423,114 @@ describe('ProctoredExamSettings', () => { course_start_date: '2013-01-01T00:00:00Z', }; - function setup(data, isAdmin) { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: isAdmin, - roles: [], - }, - }); - - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + function mockCourseData(data) { axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, data); } it('Disables irrelevant proctoring provider fields when user is not an administrator and it is after start date', async () => { - setup(mockGetPastCourseData, false); + const isAdmin = false; + setupApp(isAdmin); + mockCourseData(mockGetPastCourseData); await act(async () => render(intlWrapper())); const providerOption = screen.getByTestId('proctortrack'); expect(providerOption.hasAttribute('disabled')).toEqual(true); }); it('Enables all proctoring provider options if user is not an administrator and it is before start date', async () => { - setup(mockGetFutureCourseData, false); + const isAdmin = false; + setupApp(isAdmin); + mockCourseData(mockGetFutureCourseData); await act(async () => render(intlWrapper())); const providerOption = screen.getByTestId('proctortrack'); expect(providerOption.hasAttribute('disabled')).toEqual(false); }); it('Enables all proctoring provider options if user administrator and it is after start date', async () => { - setup(mockGetPastCourseData, true); + const isAdmin = true; + setupApp(isAdmin); + mockCourseData(mockGetPastCourseData); await act(async () => render(intlWrapper())); const providerOption = screen.getByTestId('proctortrack'); expect(providerOption.hasAttribute('disabled')).toEqual(false); }); it('Enables all proctoring provider options if user administrator and it is before start date', async () => { - setup(mockGetFutureCourseData, true); + const isAdmin = true; + setupApp(isAdmin); + mockCourseData(mockGetFutureCourseData); await act(async () => render(intlWrapper())); const providerOption = screen.getByTestId('proctortrack'); expect(providerOption.hasAttribute('disabled')).toEqual(false); }); + + it('Does not include lti_external as a selectable option', async () => { + const courseData = mockGetFutureCourseData; + courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc']; + mockCourseData(courseData); + await act(async () => render(intlWrapper())); + await waitFor(() => { + screen.getByDisplayValue('mockproc'); + }); + expect(screen.queryByTestId('lti_external')).toBeNull(); + }); + + it('Includes lti proctoring provider options when lti_external is allowed by studio', async () => { + const courseData = mockGetFutureCourseData; + courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc']; + mockCourseData(courseData); + await act(async () => render(intlWrapper())); + await waitFor(() => { + screen.getByDisplayValue('mockproc'); + }); + const providerOption = screen.getByTestId('test_lti'); + // as as admin the provider should not be disabled + expect(providerOption.hasAttribute('disabled')).toEqual(false); + }); + + it('Does not request lti provider options if there is no exam service url configuration', async () => { + mergeConfig({ + EXAMS_BASE_URL: null, + }, 'CourseAuthoringConfig'); + + await act(async () => render(intlWrapper())); + await waitFor(() => { + screen.getByDisplayValue('mockproc'); + }); + // only outgoing request should be for studio settings + expect(axiosMock.history.get.length).toBe(1); + expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true); + }); + + it('Selected LTI proctoring provider is shown on page load', async () => { + const courseData = { ...mockGetFutureCourseData }; + courseData.available_proctoring_providers = ['lti_external', 'proctortrack', 'mockproc']; + courseData.proctored_exam_settings.proctoring_provider = 'lti_external'; + mockCourseData(courseData); + axiosMock.onGet( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(200, { + provider: 'test_lti', + }); + await act(async () => render(intlWrapper())); + await waitFor(() => { + screen.getByText('Proctoring Provider'); + }); + + // make sure test_lti is the selected provider + expect(screen.getByDisplayValue('LTI Provider')).toBeInTheDocument(); + }); }); describe('Toggles field visibility based on user permissions', () => { - function setup(isAdmin) { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: isAdmin, - roles: [], - }, - }); - - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, { - proctored_exam_settings: { - enable_proctored_exams: true, - allow_proctoring_opt_out: false, - proctoring_provider: 'mockproc', - proctoring_escalation_email: 'test@example.com', - create_zendesk_tickets: true, - }, - available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'], - course_start_date: '2070-01-01T00:00:00Z', - }); - } - it('Hides opting out and zendesk tickets for non edX staff', async () => { - setup(false); + setupApp(false); await act(async () => render(intlWrapper())); expect(screen.queryByTestId('allowOptingOutYes')).toBeNull(); expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull(); }); it('Shows opting out and zendesk tickets for edX staff', async () => { - setup(true); + setupApp(true); await act(async () => render(intlWrapper())); expect(screen.queryByTestId('allowOptingOutYes')).not.toBeNull(); expect(screen.queryByTestId('createZendeskTicketsYes')).not.toBeNull(); @@ -471,18 +538,6 @@ describe('ProctoredExamSettings', () => { }); describe('Connection states', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - }); - it('Shows the spinner before the connection is complete', async () => { await act(async () => { render(intlWrapper()); @@ -492,7 +547,7 @@ describe('ProctoredExamSettings', () => { }); }); - it('Show connection error message when we suffer server side error', async () => { + it('Show connection error message when we suffer studio server side error', async () => { axiosMock.onGet( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), ).reply(500); @@ -504,6 +559,18 @@ describe('ProctoredExamSettings', () => { ); }); + it('Show connection error message when we suffer edx-exams server side error', async () => { + axiosMock.onGet( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/providers`, + ).reply(500); + + await act(async () => render(intlWrapper())); + const connectionError = screen.getByTestId('connectionErrorAlert'); + expect(connectionError.textContent).toEqual( + expect.stringContaining('We encountered a technical error when loading this page.'), + ); + }); + it('Show permission error message when user do not have enough permission', async () => { axiosMock.onGet( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), @@ -518,27 +585,13 @@ describe('ProctoredExamSettings', () => { }); describe('Save settings', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' }); - axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, { - proctored_exam_settings: { - enable_proctored_exams: true, - allow_proctoring_opt_out: false, - proctoring_provider: 'mockproc', - proctoring_escalation_email: 'test@example.com', - create_zendesk_tickets: true, - }, - available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'], - }); + beforeEach(async () => { + axiosMock.onPost( + StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), + ).reply(200, 'success'); + axiosMock.onPatch( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(200, 'success'); }); it('Show spinner while saving', async () => { @@ -557,16 +610,14 @@ describe('ProctoredExamSettings', () => { expect(submitSpinner).toBeDefined(); await waitForElementToBeRemoved(submitSpinner); - expect(axiosMock.history.get.length).toBe(1); - expect(axiosMock.history.post.length).toBe(1); + // request studio settings, exam config, and exam service providers + expect(axiosMock.history.get.length).toBe(3); + expect(axiosMock.history.post.length).toBe(1); // studio + expect(axiosMock.history.patch.length).toBe(1); // edx-exams expect(screen.queryByTestId('saveInProgress')).toBeFalsy(); }); it('Makes API call successfully with proctoring_escalation_email if proctortrack', async () => { - axiosMock.onPost( - StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).reply(200, 'success'); - await act(async () => render(intlWrapper())); // Make a change to the provider to proctortrack and set the email const selectElement = screen.getByDisplayValue('mockproc'); @@ -602,10 +653,6 @@ describe('ProctoredExamSettings', () => { }); it('Makes API call successfully without proctoring_escalation_email if not proctortrack', async () => { - axiosMock.onPost( - StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).reply(200, 'success'); - await act(async () => render(intlWrapper())); // make sure we have not selected proctortrack as the proctoring provider @@ -632,7 +679,112 @@ describe('ProctoredExamSettings', () => { expect(document.activeElement).toEqual(errorAlert); }); - it('Makes API call generated error', async () => { + it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => { + await act(async () => render(intlWrapper())); + // Make a change to the provider to proctortrack and set the email + const selectElement = screen.getByDisplayValue('mockproc'); + await act(async () => { + fireEvent.change(selectElement, { target: { value: 'test_lti' } }); + }); + const submitButton = screen.getByTestId('submissionButton'); + await act(async () => { + fireEvent.click(submitButton); + }); + + // update exam service config + expect(axiosMock.history.patch.length).toBe(1); + expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ + provider: 'test_lti', + }); + + // update studio settings + expect(axiosMock.history.post.length).toBe(1); + expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({ + proctored_exam_settings: { + enable_proctored_exams: true, + allow_proctoring_opt_out: false, + proctoring_provider: 'lti_external', + create_zendesk_tickets: true, + }, + }); + + const errorAlert = screen.getByTestId('saveSuccess'); + expect(errorAlert.textContent).toEqual( + expect.stringContaining('Proctored exam settings saved successfully.'), + ); + expect(document.activeElement).toEqual(errorAlert); + }); + + it('Sets exam service provider to null if a non-lti provider is selected', async () => { + await act(async () => render(intlWrapper())); + const submitButton = screen.getByTestId('submissionButton'); + await act(async () => { + fireEvent.click(submitButton); + }); + // update exam service config + expect(axiosMock.history.patch.length).toBe(1); + expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({ + provider: null, + }); + expect(axiosMock.history.patch.length).toBe(1); + expect(axiosMock.history.post.length).toBe(1); + expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({ + proctored_exam_settings: { + enable_proctored_exams: true, + allow_proctoring_opt_out: false, + proctoring_provider: 'mockproc', + create_zendesk_tickets: true, + }, + }); + + const errorAlert = screen.getByTestId('saveSuccess'); + expect(errorAlert.textContent).toEqual( + expect.stringContaining('Proctored exam settings saved successfully.'), + ); + expect(document.activeElement).toEqual(errorAlert); + }); + + it('Does not update exam service if lti is not enabled in studio', async () => { + axiosMock.onGet( + StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), + ).reply(200, { + proctored_exam_settings: { + enable_proctored_exams: true, + allow_proctoring_opt_out: false, + proctoring_provider: 'mockproc', + proctoring_escalation_email: 'test@example.com', + create_zendesk_tickets: true, + }, + available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'], + course_start_date: '2070-01-01T00:00:00Z', + }); + + await act(async () => render(intlWrapper())); + const submitButton = screen.getByTestId('submissionButton'); + await act(async () => { + fireEvent.click(submitButton); + }); + // does not update exam service config + expect(axiosMock.history.patch.length).toBe(0); + // does update studio + expect(axiosMock.history.post.length).toBe(1); + expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({ + proctored_exam_settings: { + enable_proctored_exams: true, + allow_proctoring_opt_out: false, + proctoring_provider: 'mockproc', + create_zendesk_tickets: true, + }, + }); + + const errorAlert = screen.getByTestId('saveSuccess'); + expect(errorAlert.textContent).toEqual( + expect.stringContaining('Proctored exam settings saved successfully.'), + ); + expect(document.activeElement).toEqual(errorAlert); + }); + + it('Makes studio API call generated error', async () => { axiosMock.onPost( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), ).reply(500); @@ -650,11 +802,29 @@ describe('ProctoredExamSettings', () => { expect(document.activeElement).toEqual(errorAlert); }); + it('Makes exams API call generated error', async () => { + axiosMock.onPatch( + `${ExamsApiService.getExamsBaseUrl()}/api/v1/configs/course_id/${defaultProps.courseId}`, + ).reply(500, 'error'); + + await act(async () => render(intlWrapper())); + const submitButton = screen.getByTestId('submissionButton'); + await act(async () => { + fireEvent.click(submitButton); + }); + expect(axiosMock.history.post.length).toBe(1); + const errorAlert = screen.getByTestId('saveError'); + expect(errorAlert.textContent).toEqual( + expect.stringContaining('We encountered a technical error while trying to save proctored exam settings'), + ); + expect(document.activeElement).toEqual(errorAlert); + }); + it('Manages focus correctly after different save statuses', async () => { // first make a call that will cause a save error axiosMock.onPost( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).replyOnce(500); + ).reply(500); await act(async () => render(intlWrapper())); const submitButton = screen.getByTestId('submissionButton'); @@ -671,7 +841,7 @@ describe('ProctoredExamSettings', () => { // now make a call that will allow for a successful save axiosMock.onPost( StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).replyOnce(200, 'success'); + ).reply(200, 'success'); await act(async () => { fireEvent.click(submitButton); }); @@ -686,31 +856,14 @@ describe('ProctoredExamSettings', () => { it('Include Zendesk ticket in post request if user is not an admin', async () => { // use non-admin user for test - initializeMockApp({ - authenticatedUser: { - userId: 4, - username: 'abc1234', - administrator: false, - roles: [], - }, - }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' }); - axiosMock.onGet(StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId)).reply(200, { - proctored_exam_settings: { - enable_proctored_exams: true, - allow_proctoring_opt_out: false, - proctoring_provider: 'mockproc', - proctoring_escalation_email: 'test@example.com', - create_zendesk_tickets: true, - }, - available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'], - }); - axiosMock.onPost( - StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId), - ).reply(200, 'success'); + const isAdmin = false; + setupApp(isAdmin); await act(async () => render(intlWrapper())); // Make a change to the proctoring provider + await waitFor(() => { + screen.getByDisplayValue('mockproc'); + }); const selectElement = screen.getByDisplayValue('mockproc'); await act(async () => { fireEvent.change(selectElement, { target: { value: 'proctortrack' } }); diff --git a/src/setupTest.js b/src/setupTest.js index ff4679c93..fb84d8e7d 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -9,6 +9,8 @@ import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import 'babel-polyfill'; +import { mergeConfig } from '@edx/frontend-platform'; + Enzyme.configure({ adapter: new Adapter() }); /* need to mock window for tinymce on import, as it is JSDOM incompatible */ @@ -41,3 +43,15 @@ global.IntersectionObserver = jest.fn(function mockIntersectionObserver() { window.getComputedStyle = jest.fn(() => ({ getPropertyValue: jest.fn(), })); + +// Ensure app-specific configs are loaded during tests since +// initialize() is not called. +mergeConfig({ + SUPPORT_URL: process.env.SUPPORT_URL || null, + SUPPORT_EMAIL: process.env.SUPPORT_EMAIL || null, + LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, + EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null, + CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null, + ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false', + ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true', +}, 'CourseAuthoringConfig');