405 lines
16 KiB
Python
405 lines
16 KiB
Python
"""
|
|
Test signal handlers for program_enrollments
|
|
"""
|
|
|
|
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from django.core.cache import cache
|
|
from edx_django_utils.cache import RequestCache
|
|
from opaque_keys.edx.keys import CourseKey
|
|
from organizations.tests.factories import OrganizationFactory
|
|
from social_django.models import UserSocialAuth
|
|
from testfixtures import LogCapture
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.student.models import CourseEnrollmentException
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
|
from common.djangoapps.third_party_auth.models import SAMLProviderConfig
|
|
from common.djangoapps.third_party_auth.tests.factories import SAMLProviderConfigFactory
|
|
from lms.djangoapps.program_enrollments.signals import _listen_for_lms_retire, logger
|
|
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
|
|
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
|
|
from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
|
|
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
|
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement
|
|
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
|
|
|
|
|
class ProgramEnrollmentRetireSignalTests(ModuleStoreTestCase):
|
|
"""
|
|
Test the _listen_for_lms_retire signal
|
|
"""
|
|
|
|
def create_enrollment_and_history(self, user=None, external_user_key='defaultExternalKey'):
|
|
"""
|
|
Create ProgramEnrollment and several History entries
|
|
"""
|
|
if user:
|
|
enrollment = ProgramEnrollmentFactory(user=user, external_user_key=external_user_key)
|
|
else:
|
|
enrollment = ProgramEnrollmentFactory(external_user_key=external_user_key)
|
|
for status in ['pending', 'suspended', 'canceled', 'enrolled']:
|
|
enrollment.status = status
|
|
enrollment.save()
|
|
return enrollment
|
|
|
|
def assert_enrollment_and_history_retired(self, enrollment):
|
|
"""
|
|
Assert that for the enrollment and all histories, external key is None
|
|
"""
|
|
enrollment.refresh_from_db()
|
|
assert enrollment.external_user_key is not None
|
|
assert enrollment.external_user_key.startswith('retired_external_key')
|
|
for history_record in enrollment.historical_records.all():
|
|
assert history_record.external_user_key is not None
|
|
assert history_record.external_user_key.startswith('retired_external_key')
|
|
|
|
def test_retire_enrollment(self):
|
|
"""
|
|
Test basic retirement of program enrollment
|
|
"""
|
|
enrollment = self.create_enrollment_and_history()
|
|
_listen_for_lms_retire(sender=self.__class__, user=enrollment.user)
|
|
self.assert_enrollment_and_history_retired(enrollment)
|
|
|
|
def test_retire_enrollment_multiple(self):
|
|
"""
|
|
Test basic retirement of user with multiple program enrollments
|
|
"""
|
|
enrollment = self.create_enrollment_and_history()
|
|
enrollment2 = self.create_enrollment_and_history(user=enrollment.user)
|
|
enrollment3 = self.create_enrollment_and_history(user=enrollment.user)
|
|
_listen_for_lms_retire(sender=self.__class__, user=enrollment.user)
|
|
self.assert_enrollment_and_history_retired(enrollment)
|
|
self.assert_enrollment_and_history_retired(enrollment2)
|
|
self.assert_enrollment_and_history_retired(enrollment3)
|
|
|
|
def test_success_no_enrollment(self):
|
|
"""
|
|
Basic success path for users who have no enrollments, should simply not error
|
|
"""
|
|
user = UserFactory()
|
|
_listen_for_lms_retire(sender=self.__class__, user=user)
|
|
|
|
def test_idempotent(self):
|
|
"""
|
|
Tests that running a retirement multiple times does not throw an error
|
|
"""
|
|
enrollment = self.create_enrollment_and_history()
|
|
|
|
# Run twice to make sure no errors are raised
|
|
_listen_for_lms_retire(sender=self.__class__, user=enrollment.user)
|
|
fake_completed_retirement(enrollment.user)
|
|
_listen_for_lms_retire(sender=self.__class__, user=enrollment.user)
|
|
|
|
self.assert_enrollment_and_history_retired(enrollment)
|
|
|
|
|
|
class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
|
|
"""
|
|
Test post-save handler on UserSocialAuth
|
|
"""
|
|
ENABLED_CACHES = ['default']
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
|
|
cls.external_id = '0000'
|
|
cls.provider_slug = 'uox'
|
|
cls.course_keys = [
|
|
CourseKey.from_string('course-v1:edX+DemoX+Test_Course'),
|
|
CourseKey.from_string('course-v1:edX+DemoX+Another_Test_Course'),
|
|
]
|
|
cls.organization = OrganizationFactory.create(
|
|
short_name='UoX'
|
|
)
|
|
cls.user = UserFactory.create()
|
|
|
|
for course_key in cls.course_keys:
|
|
CourseOverviewFactory(id=course_key)
|
|
cls.provider_config = SAMLProviderConfigFactory.create(
|
|
organization=cls.organization, slug=cls.provider_slug
|
|
)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
RequestCache.clear_all_namespaces()
|
|
catalog_org = CatalogOrganizationFactory.create(key=self.organization.short_name)
|
|
self.program_uuid = self._create_catalog_program(catalog_org)['uuid']
|
|
|
|
def _create_catalog_program(self, catalog_org):
|
|
""" helper method to create a cached catalog program """
|
|
program = ProgramFactory.create(
|
|
authoring_organizations=[catalog_org]
|
|
)
|
|
cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']), program, None)
|
|
return program
|
|
|
|
def _create_waiting_program_enrollment(self):
|
|
""" helper method to create a waiting program enrollment """
|
|
return ProgramEnrollmentFactory.create(
|
|
user=None,
|
|
external_user_key=self.external_id,
|
|
program_uuid=self.program_uuid,
|
|
)
|
|
|
|
def _create_waiting_course_enrollments(self, program_enrollment):
|
|
""" helper method to create waiting course enrollments """
|
|
return [
|
|
ProgramCourseEnrollmentFactory(
|
|
program_enrollment=program_enrollment,
|
|
course_enrollment=None,
|
|
course_key=course_key,
|
|
)
|
|
for course_key in self.course_keys
|
|
]
|
|
|
|
def _assert_program_enrollment_user(self, program_enrollment, user):
|
|
""" validate program enrollment has a user """
|
|
program_enrollment.refresh_from_db()
|
|
assert program_enrollment.user == user
|
|
|
|
def _assert_program_course_enrollment(self, program_course_enrollment, mode=CourseMode.MASTERS):
|
|
""" validate program course enrollment has a valid course enrollment """
|
|
program_course_enrollment.refresh_from_db()
|
|
student_course_enrollment = program_course_enrollment.course_enrollment
|
|
assert student_course_enrollment.user == self.user
|
|
assert student_course_enrollment.course.id == program_course_enrollment.course_key
|
|
assert student_course_enrollment.mode == mode
|
|
|
|
def test_update_social_auth(self):
|
|
"""
|
|
Makes sure we can update a social_auth row to trigger the same program enrollments
|
|
"""
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
|
|
|
|
user_social_auth = UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid='{}:{}'.format(self.provider_slug, 'gobbledegook')
|
|
)
|
|
|
|
# Not yet a thing, didn't match
|
|
program_enrollment.refresh_from_db()
|
|
assert program_enrollment.user is None
|
|
|
|
user_social_auth.uid = f'{self.provider_slug}:{self.external_id}'
|
|
user_social_auth.save()
|
|
|
|
# now we see the enrollments realized
|
|
self._assert_program_enrollment_user(program_enrollment, self.user)
|
|
for program_course_enrollment in program_course_enrollments:
|
|
self._assert_program_course_enrollment(program_course_enrollment)
|
|
|
|
def test_waiting_course_enrollments_completed(self):
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
|
|
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
|
|
self._assert_program_enrollment_user(program_enrollment, self.user)
|
|
for program_course_enrollment in program_course_enrollments:
|
|
self._assert_program_course_enrollment(program_course_enrollment)
|
|
|
|
def test_same_user_key_in_multiple_organizations(self):
|
|
uox_program_enrollment = self._create_waiting_program_enrollment()
|
|
|
|
second_organization = OrganizationFactory.create()
|
|
SAMLProviderConfigFactory.create(organization=second_organization, slug='aiu')
|
|
catalog_org = CatalogOrganizationFactory.create(key=second_organization.short_name)
|
|
program_uuid = self._create_catalog_program(catalog_org)['uuid']
|
|
|
|
# aiu enrollment with the same student key as our uox user
|
|
aiu_program_enrollment = ProgramEnrollmentFactory.create(
|
|
user=None,
|
|
external_user_key=self.external_id,
|
|
program_uuid=program_uuid
|
|
)
|
|
|
|
UserSocialAuth.objects.create(
|
|
user=UserFactory.create(),
|
|
uid='{}:{}'.format('not_used', self.external_id),
|
|
)
|
|
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}',
|
|
)
|
|
self._assert_program_enrollment_user(uox_program_enrollment, self.user)
|
|
|
|
aiu_user = UserFactory.create()
|
|
UserSocialAuth.objects.create(
|
|
user=aiu_user,
|
|
uid='{}:{}'.format('aiu', self.external_id),
|
|
)
|
|
self._assert_program_enrollment_user(aiu_program_enrollment, aiu_user)
|
|
|
|
def test_only_active_saml_config_used(self):
|
|
""" makes sure only the active row in SAMLProvider config is used """
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
|
|
# update will create a second record
|
|
self.provider_config.organization = None
|
|
self.provider_config.save()
|
|
assert len(SAMLProviderConfig.objects.all()) == 2
|
|
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
program_enrollment.refresh_from_db()
|
|
assert program_enrollment.user is None
|
|
|
|
def test_learner_already_enrolled_in_course(self):
|
|
course_key = self.course_keys[0]
|
|
course = CourseOverview.objects.get(id=course_key)
|
|
CourseEnrollmentFactory(user=self.user, course=course, mode=CourseMode.VERIFIED)
|
|
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
|
|
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
|
|
self._assert_program_enrollment_user(program_enrollment, self.user)
|
|
|
|
duplicate_program_course_enrollment = program_course_enrollments[0]
|
|
self._assert_program_course_enrollment(
|
|
duplicate_program_course_enrollment, CourseMode.VERIFIED
|
|
)
|
|
|
|
program_course_enrollment = program_course_enrollments[1]
|
|
self._assert_program_course_enrollment(program_course_enrollment)
|
|
|
|
def test_enrolled_with_no_course_enrollments(self):
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
|
|
self._assert_program_enrollment_user(program_enrollment, self.user)
|
|
|
|
def test_create_social_auth_with_no_waiting_enrollments(self):
|
|
"""
|
|
No exceptions should be raised if there are no enrollments to update
|
|
"""
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
|
|
def test_create_social_auth_provider_has_no_organization(self):
|
|
"""
|
|
No exceptions should be raised if provider is not linked to an organization
|
|
"""
|
|
provider = SAMLProviderConfigFactory.create()
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{provider.slug}:{self.external_id}'
|
|
)
|
|
|
|
def test_create_social_auth_non_saml_provider(self):
|
|
"""
|
|
No exceptions should be raised for a non-SAML uid
|
|
"""
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid='google-oauth-user@gmail.com'
|
|
)
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid='123:123:123'
|
|
)
|
|
|
|
def test_saml_provider_not_found(self):
|
|
"""
|
|
An error should be logged for incoming social auth entries with a saml id but
|
|
no matching saml configuration exists
|
|
"""
|
|
with LogCapture(logger.name) as log:
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid='abc:123456'
|
|
)
|
|
log.check_present(
|
|
(
|
|
logger.name,
|
|
'WARNING',
|
|
'Got incoming social auth for provider={} but no such provider exists'.format('abc')
|
|
)
|
|
)
|
|
|
|
def test_cannot_find_catalog_program(self):
|
|
"""
|
|
An error should be logged if a program enrollment exists but a matching catalog
|
|
program cannot be found
|
|
"""
|
|
self.program_uuid = self._create_catalog_program(None)['uuid']
|
|
self._create_waiting_program_enrollment()
|
|
|
|
with LogCapture(logger.name) as log:
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
error_template = (
|
|
'Failed to complete waiting enrollments for organization={}.'
|
|
' No catalog programs with matching authoring_organization exist.'
|
|
)
|
|
log.check_present(
|
|
(
|
|
logger.name,
|
|
'WARNING',
|
|
error_template.format('UoX')
|
|
)
|
|
)
|
|
|
|
def test_exception_on_enrollment_failure(self):
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
self._create_waiting_course_enrollments(program_enrollment)
|
|
|
|
with mock.patch('common.djangoapps.student.models.CourseEnrollment.enroll') as enrollMock:
|
|
enrollMock.side_effect = CourseEnrollmentException('something has gone wrong')
|
|
with pytest.raises(CourseEnrollmentException):
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}'
|
|
)
|
|
|
|
def test_log_on_unexpected_exception(self):
|
|
"""
|
|
unexpected errors as part of the account linking process should be logged and re-raised
|
|
"""
|
|
program_enrollment = self._create_waiting_program_enrollment()
|
|
self._create_waiting_course_enrollments(program_enrollment)
|
|
|
|
with mock.patch('lms.djangoapps.program_enrollments.api.linking.enroll_in_masters_track') as enrollMock:
|
|
enrollMock.side_effect = Exception('unexpected error')
|
|
with LogCapture(logger.name) as log:
|
|
with self.assertRaisesRegex(Exception, 'unexpected error'):
|
|
UserSocialAuth.objects.create(
|
|
user=self.user,
|
|
uid=f'{self.provider_slug}:{self.external_id}',
|
|
)
|
|
error_template = 'Unable to link waiting enrollments for user {}, social auth creation failed: {}'
|
|
log.check_present(
|
|
(
|
|
logger.name,
|
|
'WARNING',
|
|
error_template.format(self.user.id, 'unexpected error')
|
|
)
|
|
)
|