From 6630c192346f8b9ceba52e569b9fd9a85cf979db Mon Sep 17 00:00:00 2001 From: Syed Muhammad Dawoud Sheraz Ali <40599381+DawoudSheraz@users.noreply.github.com> Date: Mon, 28 Jun 2021 14:02:09 +0500 Subject: [PATCH] feat: add view certificate button (#121) * feat: add view certificate button --- src/users/data/api.js | 71 ++++++ src/users/data/api.test.js | 76 +++++- src/users/data/test/certificates.js | 19 ++ src/users/data/urls.js | 12 + src/users/enrollments/Certificates.jsx | 198 ++++++++++++++++ src/users/enrollments/Certificates.test.jsx | 242 ++++++++++++++++++++ src/users/enrollments/Enrollments.jsx | 25 +- src/users/enrollments/Enrollments.test.jsx | 22 ++ 8 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 src/users/data/test/certificates.js create mode 100644 src/users/enrollments/Certificates.jsx create mode 100644 src/users/enrollments/Certificates.test.jsx diff --git a/src/users/data/api.js b/src/users/data/api.js index 6aa0b9146..407105f23 100644 --- a/src/users/data/api.js +++ b/src/users/data/api.js @@ -443,3 +443,74 @@ export async function postResetPassword(email) { ); return data; } + +export async function getCertificate(username, courseKey) { + try { + const { data } = await getAuthenticatedHttpClient().get( + AppUrls.getCertificateUrl(username, courseKey), + ); + return Array.isArray(data) && data.length > 0 ? data[0] : data; + } catch (error) { + return { + errors: [ + { + code: null, + dismissible: true, + text: error.message, + type: 'danger', + topic: 'certificates', + }, + ], + }; + } +} + +export async function generateCertificate(username, courseKey) { + const formData = new FormData(); + formData.append('username', username); + formData.append('course_key', courseKey); + try { + const { data } = await getAuthenticatedHttpClient().post( + AppUrls.generateCertificateUrl(), + formData, + ); + return data; + } catch (error) { + return { + errors: [ + { + code: null, + dismissible: true, + text: error.message, + type: 'danger', + topic: 'certificates', + }, + ], + }; + } +} + +export async function regenerateCertificate(username, courseKey) { + const formData = new FormData(); + formData.append('username', username); + formData.append('course_key', courseKey); + try { + const { data } = await getAuthenticatedHttpClient().post( + AppUrls.regenerateCertificateUrl(), + formData, + ); + return data; + } catch (error) { + return { + errors: [ + { + code: null, + dismissible: true, + text: error.message, + type: 'danger', + topic: 'certificates', + }, + ], + }; + } +} diff --git a/src/users/data/api.test.js b/src/users/data/api.test.js index cb49cd5ec..9524ae337 100644 --- a/src/users/data/api.test.js +++ b/src/users/data/api.test.js @@ -3,11 +3,14 @@ import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { enrollmentsData } from './test/enrollments'; +import { downloadableCertificate } from './test/certificates'; import * as api from './api'; +import * as urls from './urls'; describe('API', () => { const testUsername = 'username'; const testEmail = 'email@example.com'; + const testCourseId = 'course-v1:testX+test123+2030'; const userAccountApiBaseUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`; const ssoRecordsApiUrl = `${getConfig().LMS_BASE_URL}/support/sso_records/${testUsername}`; const enrollmentsApiUrl = `${getConfig().LMS_BASE_URL}/support/enrollment/${testUsername}`; @@ -17,6 +20,9 @@ describe('API', () => { const verificationStatusApiUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${testUsername}/verification_status/`; const licensesApiUrl = `${getConfig().LICENSE_MANAGER_URL}/api/v1/staff_lookup_licenses/`; const onboardingStatusApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/user_onboarding/status`; + const certificatesUrl = urls.getCertificateUrl(testUsername, testCourseId); + const generateCertificateUrl = urls.generateCertificateUrl(); + const regenerateCertificateUrl = urls.regenerateCertificateUrl(); let mockAdapter; @@ -30,7 +36,7 @@ describe('API', () => { }; beforeEach(() => { - mockAdapter = new MockAdapter(getAuthenticatedHttpClient()); + mockAdapter = new MockAdapter(getAuthenticatedHttpClient(), { onNoMatch: 'throwException' }); }); afterEach(() => { @@ -682,4 +688,72 @@ describe('API', () => { }); }); }); + + describe('Certificate Operations', () => { + const successDictResponse = downloadableCertificate; + const successListResponse = [successDictResponse]; + const expectedError = { + code: null, + dismissible: true, + text: '', + type: 'danger', + topic: 'certificates', + }; + + test.each([successDictResponse, successListResponse])('Successful Certificate Fetch', async (successResponse) => { + mockAdapter.onGet(certificatesUrl).reply(200, successResponse); + const response = await api.getCertificate(testUsername, testCourseId); + expect(response).toEqual(Array.isArray(successResponse) ? successResponse[0] : successResponse); + }); + + it('Unsuccessful Certificates fetch', async () => { + mockAdapter.onGet(certificatesUrl).reply(() => throwError(400, '')); + const response = await api.getCertificate(testUsername, testCourseId); + expect(...response.errors).toEqual(expectedError); + }); + + it('Successful generate Certificate', async () => { + /** + * No data is added in the post request check because axios-mock-adapter fails + * with formData in post request. + * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253 + */ + mockAdapter.onPost(generateCertificateUrl).reply(200, successDictResponse); + const response = await api.generateCertificate(testUsername, testCourseId); + expect(response).toEqual(successDictResponse); + }); + + it('Unsuccessful generate Certificate', async () => { + /** + * No data is added in the post request check because axios-mock-adapter fails + * with formData in post request. + * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253 + */ + mockAdapter.onPost(generateCertificateUrl).reply(() => throwError(400, '')); + const response = await api.generateCertificate(testUsername, testCourseId); + expect(...response.errors).toEqual(expectedError); + }); + + it('Successful regenerate Certificate', async () => { + /** + * No data is added in the post request check because axios-mock-adapter fails + * with formData in post request. + * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253 + */ + mockAdapter.onPost(regenerateCertificateUrl).reply(200, successDictResponse); + const response = await api.regenerateCertificate(testUsername, testCourseId); + expect(response).toEqual(successDictResponse); + }); + + it('Unsuccessful regenerate Certificate', async () => { + /** + * No data is added in the post request check because axios-mock-adapter fails + * with formData in post request. + * See: https://github.com/ctimmerm/axios-mock-adapter/issues/253 + */ + mockAdapter.onPost(regenerateCertificateUrl).reply(() => throwError(400, '')); + const response = await api.regenerateCertificate(testUsername, testCourseId); + expect(...response.errors).toEqual(expectedError); + }); + }); }); diff --git a/src/users/data/test/certificates.js b/src/users/data/test/certificates.js new file mode 100644 index 000000000..a5e040f68 --- /dev/null +++ b/src/users/data/test/certificates.js @@ -0,0 +1,19 @@ +export const downloadableCertificate = { + courseKey: 'course-v1:testX+test123+2030', + type: 'verified', + status: 'passing', + grade: '60', + modified: new Date('2020/01/01').toLocaleString(), + downloadUrl: '/certificates/1234-abcd', + regenerate: false, +}; + +export const regeneratableCertificate = { + courseKey: 'course-v1:testX+test123+2030', + type: 'verified', + status: 'passing', + grade: '60', + modified: new Date('2020/01/01').toLocaleString(), + downloadUrl: null, + regenerate: true, +}; diff --git a/src/users/data/urls.js b/src/users/data/urls.js index eb68d241a..d1f490f93 100644 --- a/src/users/data/urls.js +++ b/src/users/data/urls.js @@ -69,3 +69,15 @@ export const getAccountActivationUrl = (activationKey) => `${ export const getOnboardingStatusUrl = (courseId, username) => `${ LMS_BASE_URL }/api/edx_proctoring/v1/user_onboarding/status?course_id=${encodeURIComponent(courseId)}&username=${encodeURIComponent(username)}`; + +export const getCertificateUrl = (username, courseKey) => `${ + LMS_BASE_URL +}/certificates/search?user=${username}&course_id=${courseKey}`; + +export const generateCertificateUrl = () => `${ + LMS_BASE_URL +}/certificates/generate`; + +export const regenerateCertificateUrl = () => `${ + LMS_BASE_URL +}/certificates/regenerate`; diff --git a/src/users/enrollments/Certificates.jsx b/src/users/enrollments/Certificates.jsx new file mode 100644 index 000000000..40f3aef1b --- /dev/null +++ b/src/users/enrollments/Certificates.jsx @@ -0,0 +1,198 @@ +import React, { + useContext, + useEffect, + useState, + useRef, + useLayoutEffect, +} from 'react'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import PropTypes from 'prop-types'; +import { Button } from '@edx/paragon'; +import PageLoading from '../../components/common/PageLoading'; +import { formatDate } from '../../utils'; +import { getCertificate, generateCertificate, regenerateCertificate } from '../data/api'; +import UserMessagesContext from '../../userMessages/UserMessagesContext'; +import AlertList from '../../userMessages/AlertList'; + +export default function Certificates({ + username, courseId, closeHandler, +}) { + const { add, clear } = useContext(UserMessagesContext); + const [displayCertErrors, setDisplayCertificateErrors] = useState(false); + const [certificate, setCertificateData] = useState(undefined); + const [status, setStatus] = useState(undefined); + const [buttonDisabled, setButtonDisabled] = useState(false); + // eslint-disable-next-line no-use-before-define + const oldCourseId = usePrevious(courseId); + const certificateRef = useRef(null); + + useEffect(() => { + if ((certificate === undefined && !displayCertErrors) || (courseId !== oldCourseId)) { + getCertificate(username, courseId).then((result) => { + const camelCaseResult = camelCaseObject(result); + if (camelCaseResult.errors) { + clear('certificates'); + camelCaseResult.errors.forEach(error => add(error)); + setDisplayCertificateErrors(true); + } else { + setDisplayCertificateErrors(false); + setCertificateData(camelCaseResult); + } + }); + } + }, [certificate, courseId]); + + useLayoutEffect(() => { + if (certificateRef != null) { + certificateRef.current.focus(); + } + }); + + function usePrevious(value) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; + } + + function renderHideButton() { + return ( +
+ +
+ ); + } + + function postGenerateCertificate() { + setButtonDisabled(true); + setStatus('Generating New Certificate'); + generateCertificate(username, certificate.courseKey).then((result) => { + if (result.errors) { + clear('certificates'); + result.errors.forEach(error => add(error)); + setDisplayCertificateErrors(true); + } else { + setDisplayCertificateErrors(false); + setCertificateData(undefined); + } + setButtonDisabled(false); + setStatus(undefined); + }); + } + + function postRegenerateCertificate() { + setButtonDisabled(true); + setStatus('Regenerating Certificate'); + regenerateCertificate(username, certificate.courseKey).then((result) => { + if (result.errors) { + clear('certificates'); + result.errors.forEach(error => add(error)); + setDisplayCertificateErrors(true); + } else { + setDisplayCertificateErrors(false); + setCertificateData(undefined); + } + setButtonDisabled(false); + setStatus(undefined); + }); + } + + return ( +
+ <> + {renderHideButton()} + + + {!certificate && !displayCertErrors && } + {displayCertErrors && } + + {certificate && !displayCertErrors && ( + +
+

Course ID: {certificate.courseKey}

+ {status && (

Status: {status}

)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Certificate Type{certificate.type ? certificate.type : 'Not Available'}
Status{certificate.status ? certificate.status : 'Not Available'}
Grade{certificate.grade ? certificate.grade : 'Not Available'}
Last Updated{formatDate(certificate.modified)}
Download URL{certificate.downloadUrl ? Download : 'Not Available'}
Actions + { + certificate.regenerate + ? ( + + ) + : ( + + ) + } + +
+
+ )} +
+ ); +} + +Certificates.propTypes = { + username: PropTypes.string, + courseId: PropTypes.string, + closeHandler: PropTypes.func, +}; + +Certificates.defaultProps = { + username: null, + courseId: null, + closeHandler: null, +}; diff --git a/src/users/enrollments/Certificates.test.jsx b/src/users/enrollments/Certificates.test.jsx new file mode 100644 index 000000000..deb531323 --- /dev/null +++ b/src/users/enrollments/Certificates.test.jsx @@ -0,0 +1,242 @@ +import { mount } from 'enzyme'; +import React from 'react'; + +import { waitForComponentToPaint } from '../../setupTest'; +import Certificates from './Certificates'; +import { downloadableCertificate, regeneratableCertificate } from '../data/test/certificates'; +import UserMessagesProvider from '../../userMessages/UserMessagesProvider'; +import * as api from '../data/api'; + +const CertificateWrapper = (props) => ( + + ; + +); + +describe('Certificate component', () => { + let apiMock; let wrapper; + const testUser = 'testUser'; + const testCourseId = 'course-v1:testX+test123+2030'; + + const props = { + username: testUser, + courseId: testCourseId, + closeHandler: jest.fn(() => {}), + }; + + afterEach(() => { + apiMock.mockRestore(); + }); + + it('Downloadable Certificate', async () => { + apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(downloadableCertificate)); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + expect(wrapper.find('h2').html()).toEqual(expect.stringContaining(downloadableCertificate.courseKey)); + const dataRows = wrapper.find('table.table tbody tr'); + expect(dataRows.length).toEqual(6); + + // Explictly check each row and verify individual piece of data + const certType = dataRows.at(0).find('td'); + expect(certType.at(0).text()).toEqual('Certificate Type'); + expect(certType.at(1).text()).toEqual('verified'); + + const status = dataRows.at(1).find('td'); + expect(status.at(0).text()).toEqual('Status'); + expect(status.at(1).text()).toEqual('passing'); + + const grade = dataRows.at(2).find('td'); + expect(grade.at(0).text()).toEqual('Grade'); + expect(grade.at(1).text()).toEqual('60'); + + const lastUpdated = dataRows.at(3).find('td'); + expect(lastUpdated.at(0).text()).toEqual('Last Updated'); + expect(lastUpdated.at(1).text()).toEqual('Jan 1, 2020 12:00 AM'); + + const downloadUrl = dataRows.at(4).find('td'); + expect(downloadUrl.at(0).text()).toEqual('Download URL'); + expect(downloadUrl.at(1).text()).toEqual('Download'); + expect(downloadUrl.at(1).find('a').prop('href')).toEqual('http://localhost:18000/certificates/1234-abcd'); + + const action = dataRows.at(5).find('td'); + expect(action.at(0).text()).toEqual('Actions'); + expect(action.find('button#generate-certificate').length).toEqual(1); + }); + + it('Regeneratable Certificate', async () => { + apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(regeneratableCertificate)); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + expect(wrapper.find('h2').html()).toEqual(expect.stringContaining(regeneratableCertificate.courseKey)); + const dataRows = wrapper.find('table.table tbody tr'); + expect(dataRows.length).toEqual(6); + + const action = dataRows.at(5).find('td'); + const actionButton = action.find('button#regenerate-certificate'); + expect(action.at(0).text()).toEqual('Actions'); + expect(actionButton.text()).toEqual('Regenerate'); + }); + + it('Missing Certificate Data', async () => { + apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({ courseKey: testCourseId })); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + expect(wrapper.find('h2').html()).toEqual(expect.stringContaining(testCourseId)); + const dataRows = wrapper.find('table.table tbody tr'); + expect(dataRows.length).toEqual(6); + + expect(dataRows.at(0).find('td').at(1).text()).toEqual('Not Available'); + expect(dataRows.at(1).find('td').at(1).text()).toEqual('Not Available'); + expect(dataRows.at(2).find('td').at(1).text()).toEqual('Not Available'); + expect(dataRows.at(3).find('td').at(1).text()).toEqual('N/A'); + expect(dataRows.at(4).find('td').at(1).text()).toEqual('Not Available'); + }); + + it('Certificate Fetch Errors', async () => { + apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({ + errors: [ + { + code: null, + dismissible: true, + text: 'No certificate found', + type: 'danger', + topic: 'certificates', + }, + ], + })); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + const alert = wrapper.find('.alert'); + expect(alert.text()).toEqual('No certificate found'); + }); + + describe('Generate Certificates flow', () => { + let generateApiMock; + + afterEach(() => { + generateApiMock.mockRestore(); + }); + + it('Successful certificate generation flow', async () => { + generateApiMock = jest.spyOn(api, 'generateCertificate').mockImplementationOnce(() => Promise.resolve({})); + apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(downloadableCertificate)); + // 2nd call to get certificate after generaion would yield regenerate certificate data. + jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve(regeneratableCertificate)); + + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + let generateButton = wrapper.find('button#generate-certificate'); + expect(generateButton.text()).toEqual('Generate'); + expect(generateButton.prop('disabled')).toBeFalsy(); + + generateButton.simulate('click'); + generateButton = wrapper.find('button#generate-certificate'); + + expect(generateButton.prop('disabled')).toBeTruthy(); + expect(generateApiMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('h3').text()).toEqual('Status: Generating New Certificate '); + + // Once the generation api call is successful, the status text will revert + // and button will change into regenerate button. + await waitForComponentToPaint(wrapper); + const regenerateButton = wrapper.find('button#regenerate-certificate'); + expect(wrapper.find('h3').length).toEqual(0); + expect(regenerateButton.text()).toEqual('Regenerate'); + expect(regenerateButton.prop('disabled')).toBeFalsy(); + expect(apiMock).toHaveBeenCalledTimes(2); + }); + + it('Unsuccessful certificate generation flow', async () => { + generateApiMock = jest.spyOn(api, 'generateCertificate').mockImplementationOnce(() => Promise.resolve({ + errors: [ + { + code: null, + dismissible: true, + text: 'Error generating certificate', + type: 'danger', + topic: 'certificates', + }, + ], + })); + apiMock = jest.spyOn(api, 'getCertificate').mockImplementation(() => Promise.resolve(downloadableCertificate)); + + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + const generateButton = wrapper.find('button#generate-certificate'); + expect(generateButton.text()).toEqual('Generate'); + expect(generateButton.prop('disabled')).toBeFalsy(); + generateButton.simulate('click'); + expect(generateApiMock).toHaveBeenCalledTimes(1); + + await waitForComponentToPaint(wrapper); + const alert = wrapper.find('.alert'); + expect(alert.text()).toEqual('Error generating certificate'); + }); + }); + + describe('Regenerate Certificates flow', () => { + let regenerateApiMock; + + afterEach(() => { + regenerateApiMock.mockRestore(); + }); + + it('Successful certificate regeneration flow', async () => { + regenerateApiMock = jest.spyOn(api, 'regenerateCertificate').mockImplementationOnce(() => Promise.resolve({})); + apiMock = jest.spyOn(api, 'getCertificate').mockImplementation(() => Promise.resolve(regeneratableCertificate)); + + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + let regenerateButton = wrapper.find('button#regenerate-certificate'); + expect(regenerateButton.text()).toEqual('Regenerate'); + expect(regenerateButton.prop('disabled')).toBeFalsy(); + + regenerateButton.simulate('click'); + regenerateButton = wrapper.find('button#regenerate-certificate'); + + expect(regenerateButton.prop('disabled')).toBeTruthy(); + expect(regenerateApiMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('h3').text()).toEqual('Status: Regenerating Certificate '); + + await waitForComponentToPaint(wrapper); + regenerateButton = wrapper.find('button#regenerate-certificate'); + expect(wrapper.find('h3').length).toEqual(0); + expect(regenerateButton.prop('disabled')).toBeFalsy(); + }); + + it('Unsuccessful certificate regeneration flow', async () => { + regenerateApiMock = jest.spyOn(api, 'regenerateCertificate').mockImplementationOnce(() => Promise.resolve({ + errors: [ + { + code: null, + dismissible: true, + text: 'Error regenerating certificate', + type: 'danger', + topic: 'certificates', + }, + ], + })); + apiMock = jest.spyOn(api, 'getCertificate').mockImplementation(() => Promise.resolve(regeneratableCertificate)); + + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + const regenerateButton = wrapper.find('button#regenerate-certificate'); + expect(regenerateButton.text()).toEqual('Regenerate'); + expect(regenerateButton.prop('disabled')).toBeFalsy(); + + regenerateButton.simulate('click'); + expect(regenerateApiMock).toHaveBeenCalledTimes(1); + + await waitForComponentToPaint(wrapper); + const alert = wrapper.find('.alert'); + expect(alert.text()).toEqual('Error regenerating certificate'); + }); + }); +}); diff --git a/src/users/enrollments/Enrollments.jsx b/src/users/enrollments/Enrollments.jsx index 1e9d41455..e5a9e6e25 100644 --- a/src/users/enrollments/Enrollments.jsx +++ b/src/users/enrollments/Enrollments.jsx @@ -9,6 +9,7 @@ import React, { import { Button, TransitionReplace, Collapsible } from '@edx/paragon'; import { getConfig } from '@edx/frontend-platform'; import PropTypes from 'prop-types'; +import Certificates from './Certificates'; import EnrollmentForm from './EnrollmentForm'; import EnrollmentExtra from './EnrollmentExtra'; import { CREATE, CHANGE } from './constants'; @@ -23,6 +24,7 @@ export default function Enrollments({ const [formType, setFormType] = useState(null); const [enrollmentToChange, setEnrollmentToChange] = useState(undefined); const [enrollmentExtraData, setEnrollmentExtraData] = useState(undefined); + const [selectedCourseId, setSelectedCourseId] = useState(undefined); const formRef = useRef(null); const extraRef = useRef(null); @@ -33,11 +35,12 @@ export default function Enrollments({ lastModifiedBy: enrollment.manualEnrollment && enrollment.manualEnrollment.enrolledBy ? enrollment.manualEnrollment.enrolledBy : 'N/A', reason: enrollment.manualEnrollment && enrollment.manualEnrollment.reason ? enrollment.manualEnrollment.reason : 'N/A', }; + setSelectedCourseId(undefined); setEnrollmentExtraData(extraData); } useLayoutEffect(() => { - if (enrollmentExtraData !== undefined) { + if (enrollmentExtraData !== undefined && selectedCourseId === undefined) { extraRef.current.focus(); } }); @@ -103,6 +106,16 @@ export default function Enrollments({ > Show Extra + ), value: 'Change', @@ -199,6 +212,16 @@ export default function Enrollments({ /> ) : () } + + {selectedCourseId !== undefined ? ( + setSelectedCourseId(undefined)} + courseId={selectedCourseId} + username={user} + /> + ) : () } + ( @@ -62,6 +64,26 @@ describe('Course Enrollments Listing', () => { }); }); + it('View Certificate action', async () => { + /** + * Testing the certificate fetch on first row only. Async painting in the loop was causing + * the test to pass data across the loop, causing inconsistent behavior.. + */ + const dataRow = wrapper.find('table.table tbody tr').at(0); + const courseName = dataRow.find('td').at(1).text(); + const apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({ courseKey: courseName })); + dataRow.find('button#certificate').simulate('click'); + + await waitForComponentToPaint(wrapper); + const certificates = wrapper.find('Certificates'); + expect(certificates.html()).toEqual(expect.stringContaining(courseName)); + + expect(apiMock).toHaveBeenCalledTimes(1); + certificates.find('button.btn-outline-secondary').simulate('click'); + expect(wrapper.find('Certificates')).toEqual({}); + apiMock.mockReset(); + }); + it('Sorting Columns Button Enabled by default', () => { const dataTable = wrapper.find('table.table'); const tableHeaders = dataTable.find('thead tr th');