Files
edx-platform/lms/djangoapps/certificates/tests/test_queue.py

441 lines
17 KiB
Python

"""Tests for the XQueue certificates interface. """
import json
from contextlib import contextmanager
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
import ddt
import freezegun
import pytz
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
from opaque_keys.edx.locator import CourseLocator
from testfixtures import LogCapture
# It is really unfortunate that we are using the XQueue client
# code from the capa library. In the future, we should move this
# into a shared library. We import it here so we can mock it
# and verify that items are being correctly added to the queue
# in our `XQueueCertInterface` implementation.
from capa.xqueue_interface import XQueueInterface
from common.djangoapps.course_modes import api as modes_api
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.certificates.models import (
CertificateStatuses,
ExampleCertificate,
ExampleCertificateSet,
GeneratedCertificate
)
from lms.djangoapps.certificates.queue import LOGGER, XQueueCertInterface
from lms.djangoapps.certificates.tests.factories import CertificateWhitelistFactory, GeneratedCertificateFactory
from lms.djangoapps.grades.tests.utils import mock_passing_grade
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@ddt.ddt
@override_settings(CERT_QUEUE='certificates')
class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
"""Test the "add to queue" operation of the XQueue interface. """
def setUp(self):
super().setUp()
self.user = UserFactory.create()
self.course = CourseFactory.create()
self.enrollment = CourseEnrollmentFactory(
user=self.user,
course_id=self.course.id,
is_active=True,
mode="honor",
)
self.xqueue = XQueueCertInterface()
self.user_2 = UserFactory.create()
SoftwareSecurePhotoVerificationFactory.create(user=self.user_2, status='approved')
def test_add_cert_callback_url(self):
with mock_passing_grade():
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user, self.course.id)
# Verify that the task was sent to the queue with the correct callback URL
assert mock_send.called
__, kwargs = mock_send.call_args_list[0]
actual_header = json.loads(kwargs['header'])
assert 'https://edx.org/update_certificate?key=' in actual_header['lms_callback_url']
def test_no_create_action_in_queue_for_html_view_certs(self):
"""
Tests there is no certificate create message in the queue if generate_pdf is False
"""
with mock_passing_grade():
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
self.xqueue.add_cert(self.user, self.course.id, generate_pdf=False)
# Verify that add_cert method does not add message to queue
assert not mock_send.called
certificate = GeneratedCertificate.eligible_certificates.get(user=self.user, course_id=self.course.id)
assert certificate.status == CertificateStatuses.downloadable
assert certificate.verify_uuid is not None
@ddt.data('honor', 'audit')
@override_settings(AUDIT_CERT_CUTOFF_DATE=datetime.now(pytz.UTC) - timedelta(days=1))
def test_add_cert_with_honor_certificates(self, mode):
"""Test certificates generations for honor and audit modes."""
template_name = 'certificate-template-{id.org}-{id.course}.pdf'.format(
id=self.course.id
)
mock_send = self.add_cert_to_queue(mode)
if modes_api.is_eligible_for_certificate(mode):
self.assert_certificate_generated(mock_send, mode, template_name)
else:
self.assert_ineligible_certificate_generated(mock_send, mode)
@ddt.data('credit', 'verified')
def test_add_cert_with_verified_certificates(self, mode):
"""Test if enrollment mode is verified or credit along with valid
software-secure verification than verified certificate should be generated.
"""
template_name = 'certificate-template-{id.org}-{id.course}-verified.pdf'.format(
id=self.course.id
)
mock_send = self.add_cert_to_queue(mode)
self.assert_certificate_generated(mock_send, 'verified', template_name)
@ddt.data((True, CertificateStatuses.audit_passing), (False, CertificateStatuses.generating))
@ddt.unpack
@override_settings(AUDIT_CERT_CUTOFF_DATE=datetime.now(pytz.UTC) - timedelta(days=1))
def test_ineligible_cert_whitelisted(self, disable_audit_cert, status):
"""
Test that audit mode students receive a certificate if DISABLE_AUDIT_CERTIFICATES
feature is set to false
"""
# Enroll as audit
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode='audit'
)
# Whitelist student
CertificateWhitelistFactory(course_id=self.course.id, user=self.user_2)
features = settings.FEATURES
features['DISABLE_AUDIT_CERTIFICATES'] = disable_audit_cert
with override_settings(FEATURES=features) and mock_passing_grade():
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user_2, self.course.id)
certificate = GeneratedCertificate.certificate_for_student(self.user_2, self.course.id)
assert certificate is not None
assert certificate.mode == 'audit'
assert certificate.status == status
def add_cert_to_queue(self, mode):
"""
Dry method for course enrollment and adding request to
queue. Returns a mock object containing information about the
`XQueueInterface.send_to_queue` method, which can be used in other
assertions.
"""
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode=mode,
)
with mock_passing_grade():
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user_2, self.course.id)
return mock_send
def assert_certificate_generated(self, mock_send, expected_mode, expected_template_name):
"""
Assert that a certificate was generated with the correct mode and
template type.
"""
# Verify that the task was sent to the queue with the correct callback URL
assert mock_send.called
__, kwargs = mock_send.call_args_list[0]
actual_header = json.loads(kwargs['header'])
assert 'https://edx.org/update_certificate?key=' in actual_header['lms_callback_url']
body = json.loads(kwargs['body'])
assert expected_template_name in body['template_pdf']
certificate = GeneratedCertificate.eligible_certificates.get(user=self.user_2, course_id=self.course.id)
assert certificate.mode == expected_mode
def assert_ineligible_certificate_generated(self, mock_send, expected_mode):
"""
Assert that an ineligible certificate was generated with the
correct mode.
"""
# Ensure the certificate was not generated
assert not mock_send.called
certificate = GeneratedCertificate.objects.get(
user=self.user_2,
course_id=self.course.id
)
assert certificate.status in (CertificateStatuses.audit_passing, CertificateStatuses.audit_notpassing)
assert certificate.mode == expected_mode
@ddt.data(
(CertificateStatuses.restricted, False),
(CertificateStatuses.deleting, False),
(CertificateStatuses.generating, True),
(CertificateStatuses.unavailable, True),
(CertificateStatuses.deleted, True),
(CertificateStatuses.error, True),
(CertificateStatuses.notpassing, True),
(CertificateStatuses.downloadable, True),
(CertificateStatuses.auditing, True),
)
@ddt.unpack
def test_add_cert_statuses(self, status, should_generate):
"""
Test that certificates can or cannot be generated with the given
certificate status.
"""
with patch(
'lms.djangoapps.certificates.queue.certificate_status_for_student',
Mock(return_value={'status': status})
):
mock_send = self.add_cert_to_queue('verified')
if should_generate:
assert mock_send.called
else:
assert not mock_send.called
@ddt.data(
# Eligible and should stay that way
(
CertificateStatuses.downloadable,
timedelta(days=-2),
'Pass',
CertificateStatuses.generating
),
# Ensure that certs in the wrong state can be fixed by regeneration
(
CertificateStatuses.downloadable,
timedelta(hours=-1),
'Pass',
CertificateStatuses.audit_passing
),
# Ineligible and should stay that way
(
CertificateStatuses.audit_passing,
timedelta(hours=-1),
'Pass',
CertificateStatuses.audit_passing
),
# As above
(
CertificateStatuses.audit_notpassing,
timedelta(hours=-1),
'Pass',
CertificateStatuses.audit_passing
),
# As above
(
CertificateStatuses.audit_notpassing,
timedelta(hours=-1),
None,
CertificateStatuses.audit_notpassing
),
)
@ddt.unpack
@override_settings(AUDIT_CERT_CUTOFF_DATE=datetime.now(pytz.UTC) - timedelta(days=1))
def test_regen_audit_certs_eligibility(self, status, created_delta, grade, expected_status):
"""
Test that existing audit certificates remain eligible even if cert
generation is re-run.
"""
# Create an existing audit enrollment and certificate
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode=CourseMode.AUDIT,
)
created_date = datetime.now(pytz.UTC) + created_delta
with freezegun.freeze_time(created_date):
GeneratedCertificateFactory(
user=self.user_2,
course_id=self.course.id,
grade='1.0',
status=status,
mode=GeneratedCertificate.MODES.audit,
)
# Run grading/cert generation again
with mock_passing_grade(letter_grade=grade):
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user_2, self.course.id)
assert GeneratedCertificate.objects.get(user=self.user_2, course_id=self.course.id).status == expected_status
def test_regen_cert_with_pdf_certificate(self):
"""
Test that regenerating a PDF certificate logs a warning message and the certificate
status remains unchanged.
"""
download_url = 'http://www.example.com/certificate.pdf'
# Create an existing verified enrollment and certificate
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode=CourseMode.VERIFIED,
)
GeneratedCertificateFactory(
user=self.user_2,
course_id=self.course.id,
grade='1.0',
status=CertificateStatuses.downloadable,
mode=GeneratedCertificate.MODES.verified,
download_url=download_url
)
self._assert_pdf_cert_generation_discontinued_logs(download_url)
def test_add_cert_with_existing_pdf_certificate(self):
"""
Test that adding a certificate for existing PDF certificates logs a warning
message and the certificate status remains unchanged.
"""
download_url = 'http://www.example.com/certificate.pdf'
# Create an existing verified enrollment and certificate
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode=CourseMode.VERIFIED,
)
GeneratedCertificateFactory(
user=self.user_2,
course_id=self.course.id,
grade='1.0',
status=CertificateStatuses.downloadable,
mode=GeneratedCertificate.MODES.verified,
download_url=download_url
)
self._assert_pdf_cert_generation_discontinued_logs(download_url, add_cert=True)
def _assert_pdf_cert_generation_discontinued_logs(self, download_url, add_cert=False):
"""Assert PDF certificate generation discontinued logs."""
with LogCapture(LOGGER.name) as log:
if add_cert:
self.xqueue.add_cert(self.user_2, self.course.id)
else:
self.xqueue.regen_cert(self.user_2, self.course.id)
log.check_present(
(
LOGGER.name,
'WARNING',
(
"PDF certificate generation discontinued, canceling "
"PDF certificate generation for student {student_id} "
"in course '{course_id}' "
"with status '{status}' "
"and download_url '{download_url}'."
).format(
student_id=self.user_2.id,
course_id=str(self.course.id),
status=CertificateStatuses.downloadable,
download_url=download_url
)
)
)
@override_settings(CERT_QUEUE='certificates')
class XQueueCertInterfaceExampleCertificateTest(TestCase):
"""Tests for the XQueue interface for certificate generation. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
TEMPLATE = 'test.pdf'
DESCRIPTION = 'test'
ERROR_MSG = 'Kaboom!'
def setUp(self):
super().setUp()
self.xqueue = XQueueCertInterface()
def test_add_example_cert(self):
cert = self._create_example_cert()
with self._mock_xqueue() as mock_send:
self.xqueue.add_example_cert(cert)
# Verify that the correct payload was sent to the XQueue
self._assert_queue_task(mock_send, cert)
# Verify the certificate status
assert cert.status == ExampleCertificate.STATUS_STARTED
def test_add_example_cert_error(self):
cert = self._create_example_cert()
with self._mock_xqueue(success=False):
self.xqueue.add_example_cert(cert)
# Verify the error status of the certificate
assert cert.status == ExampleCertificate.STATUS_ERROR
assert self.ERROR_MSG in cert.error_reason
def _create_example_cert(self):
"""Create an example certificate. """
cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY)
return ExampleCertificate.objects.create(
example_cert_set=cert_set,
description=self.DESCRIPTION,
template=self.TEMPLATE
)
@contextmanager
def _mock_xqueue(self, success=True):
"""Mock the XQueue method for sending a task to the queue. """
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None) if success else (1, self.ERROR_MSG)
yield mock_send
def _assert_queue_task(self, mock_send, cert):
"""Check that the task was added to the queue. """
expected_header = {
'lms_key': cert.access_key,
'lms_callback_url': f'https://edx.org/update_example_certificate?key={cert.uuid}',
'queue_name': 'certificates'
}
expected_body = {
'action': 'create',
'username': cert.uuid,
'name': 'John Doë',
'course_id': str(self.COURSE_KEY),
'template_pdf': 'test.pdf',
'example_certificate': True
}
assert mock_send.called
__, kwargs = mock_send.call_args_list[0]
actual_header = json.loads(kwargs['header'])
actual_body = json.loads(kwargs['body'])
assert expected_header == actual_header
assert expected_body == actual_body