Skip to content

Commit

Permalink
fix: display exam content for masquerading user (#40)
Browse files Browse the repository at this point in the history
If a staff user is masquerading as a specific learner,
they should be able to view exam content regardless
of the learner's state. If the learner would not normally
be able to see the exam content, an alert with this
information will display for the masquerading user.
  • Loading branch information
bseverino authored Aug 24, 2021
1 parent 4cb25ea commit c043b71
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 9 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@edx/frontend-lib-special-exams",
"version": "1.0.0",
"version": "1.13.0",
"description": "Special exams lib",
"main": "dist/index.js",
"release": {
Expand Down
42 changes: 37 additions & 5 deletions src/exam/Exam.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Spinner } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Spinner } from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { ExamTimerBlock } from '../timer';
import Instructions from '../instructions';
import ExamStateContext from '../context';
import ExamAPIError from './ExamAPIError';
import { ExamType } from '../constants';
import { ExamStatus, ExamType } from '../constants';

/**
* Exam component is intended to render exam instructions before and after exam.
Expand All @@ -16,15 +18,36 @@ import { ExamType } from '../constants';
* @returns {JSX.Element}
* @constructor
*/
const Exam = ({ isTimeLimited, children }) => {
const Exam = ({ isTimeLimited, originalUserIsStaff, children }) => {
const state = useContext(ExamStateContext);
const {
isLoading, activeAttempt, showTimer, stopExam, exam,
expireExam, pollAttempt, apiErrorMsg, pingAttempt,
getVerificationData, getProctoringSettings, submitExam,
} = state;

const { type: examType, id: examId } = exam || {};
const {
attempt,
type: examType,
id: examId,
passed_due_date: passedDueDate,
hide_after_due: hideAfterDue,
} = exam || {};
const { attempt_status: attemptStatus } = attempt || {};

const shouldShowMasqueradeAlert = () => {
// if course staff is masquerading as a specific learner, they should be able
// to view the exam content regardless of the learner's current state
if (originalUserIsStaff) {
if (examType === ExamType.TIMED && passedDueDate && !hideAfterDue) {
// if the learner is able to view exam content after the due date is passed,
// don't show this alert
return false;
}
return attemptStatus !== ExamStatus.STARTED;
}
return false;
};

useEffect(() => {
if (examId) {
Expand All @@ -51,6 +74,14 @@ const Exam = ({ isTimeLimited, children }) => {

return (
<div className="d-flex flex-column justify-content-center">
{shouldShowMasqueradeAlert() && (
<Alert variant="info" icon={Info} data-testid="masquerade-alert">
<FormattedMessage
id="exam.hiddenContent"
defaultMessage="This exam is hidden from the learner."
/>
</Alert>
)}
{showTimer && (
<ExamTimerBlock
attempt={activeAttempt}
Expand All @@ -62,7 +93,7 @@ const Exam = ({ isTimeLimited, children }) => {
/>
)}
{apiErrorMsg && <ExamAPIError />}
{isTimeLimited
{isTimeLimited && !originalUserIsStaff
? <Instructions>{sequenceContent}</Instructions>
: sequenceContent}
</div>
Expand All @@ -71,6 +102,7 @@ const Exam = ({ isTimeLimited, children }) => {

Exam.propTypes = {
isTimeLimited: PropTypes.bool.isRequired,
originalUserIsStaff: PropTypes.bool.isRequired,
children: PropTypes.element.isRequired,
};

Expand Down
11 changes: 9 additions & 2 deletions src/exam/ExamWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import ExamStateContext from '../context';
const ExamWrapper = ({ children, ...props }) => {
const state = useContext(ExamStateContext);
const { authenticatedUser } = useContext(AppContext);
const { sequence, courseId, isStaff } = props;
const {
sequence,
courseId,
isStaff,
originalUserIsStaff,
} = props;
const { getExamAttemptsData, getAllowProctoringOptOut } = state;
const loadInitialData = async () => {
await getExamAttemptsData(courseId, sequence.id);
Expand All @@ -29,7 +34,7 @@ const ExamWrapper = ({ children, ...props }) => {
}, []);

return (
<Exam isTimeLimited={sequence.isTimeLimited}>
<Exam isTimeLimited={sequence.isTimeLimited} originalUserIsStaff={originalUserIsStaff}>
{children}
</Exam>
);
Expand All @@ -44,10 +49,12 @@ ExamWrapper.propTypes = {
courseId: PropTypes.string.isRequired,
children: PropTypes.element.isRequired,
isStaff: PropTypes.bool,
originalUserIsStaff: PropTypes.bool,
};

ExamWrapper.defaultProps = {
isStaff: false,
originalUserIsStaff: false,
};

export default ExamWrapper;
74 changes: 73 additions & 1 deletion src/exam/ExamWrapper.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import SequenceExamWrapper from './ExamWrapper';
import { store, getExamAttemptsData, startTimedExam } from '../data';
import { render } from '../setupTest';
import ExamStateProvider from '../core/ExamStateProvider';
import { ExamType } from '../constants';
import { ExamStatus, ExamType } from '../constants';

jest.mock('../data', () => ({
store: {},
Expand Down Expand Up @@ -140,4 +140,76 @@ describe('SequenceExamWrapper', () => {
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
});

it('renders exam content for staff masquerading as a learner', () => {
store.getState = () => ({
examState: Factory.build('examState', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
passed_due_date: false,
hide_after_due: false,
}),
}),
});
const { queryByTestId } = render(
<ExamStateProvider>
<SequenceExamWrapper sequence={sequence} courseId={courseId} originalUserIsStaff>
<div data-testid="sequence-content">children</div>
</SequenceExamWrapper>
</ExamStateProvider>,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
expect(queryByTestId('masquerade-alert')).toBeInTheDocument();
});

it('does not display masquerade alert if specified learner is in the middle of the exam', () => {
store.getState = () => ({
examState: Factory.build('examState', {
exam: Factory.build('exam', {
type: ExamType.PROCTORED,
attempt: {
attempt_status: ExamStatus.STARTED,
},
passed_due_date: false,
hide_after_due: false,
}),
}),
});
const { queryByTestId } = render(
<ExamStateProvider>
<SequenceExamWrapper sequence={sequence} courseId={courseId} originalUserIsStaff>
<div data-testid="sequence-content">children</div>
</SequenceExamWrapper>
</ExamStateProvider>,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
expect(queryByTestId('masquerade-alert')).not.toBeInTheDocument();
});

it('does not display masquerade alert if learner can view the exam after the due date', () => {
store.getState = () => ({
examState: Factory.build('examState', {
exam: Factory.build('exam', {
type: ExamType.TIMED,
attempt: {
attempt_status: ExamStatus.SUBMITTED,
},
passed_due_date: true,
hide_after_due: false,
}),
}),
});
const { queryByTestId } = render(
<ExamStateProvider>
<SequenceExamWrapper sequence={sequence} courseId={courseId} originalUserIsStaff>
<div data-testid="sequence-content">children</div>
</SequenceExamWrapper>
</ExamStateProvider>,
{ store },
);
expect(queryByTestId('sequence-content')).toHaveTextContent('children');
expect(queryByTestId('masquerade-alert')).not.toBeInTheDocument();
});
});

0 comments on commit c043b71

Please sign in to comment.