Files
edx-platform/lms/djangoapps/mobile_api/users/tests.py
Michael Terry cb1bb7fa64 test: switch default test store to the split store
It's long past time that the default test modulestore was Split,
instead of Old Mongo. This commit switches the default store and
fixes some tests that now fail:
- Tests that didn't expect MFE to be enabled (because we don't
  enable MFE for Old Mongo) - opt out of MFE for those
- Tests that hardcoded old key string formats
- Lots of other random little differences

In many places, I didn't spend much time trying to figure out how to
properly fix the test, and instead just set the modulestore to Old
Mongo.

For those tests that I didn't spend time investigating, I've set
the modulestore to TEST_DATA_MONGO_AMNESTY_MODULESTORE - search for
that string to find further work.
2022-02-04 14:32:50 -05:00

654 lines
26 KiB
Python

"""
Tests for users API
"""
import datetime
from unittest.mock import patch
from urllib.parse import parse_qs
import ddt
import pytz
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
from django.conf import settings
from django.db import transaction
from django.template import defaultfilters
from django.test import RequestFactory, override_settings
from django.utils import timezone
from django.utils.timezone import now
from milestones.tests.utils import MilestonesTestCaseMixin
from opaque_keys.edx.keys import CourseKey
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.util.milestones_helpers import set_prerequisite_courses
from common.djangoapps.util.testing import UrlResetMixin
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError
from lms.djangoapps.mobile_api.testutils import (
MobileAPITestCase,
MobileAuthTestMixin,
MobileAuthUserTestMixin,
MobileCourseAccessTestMixin
)
from lms.djangoapps.mobile_api.utils import API_V1, API_V05
from openedx.core.lib.courses import course_image_url
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
from xmodule.course_module import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory # lint-amnesty, pylint: disable=wrong-import-order
from .. import errors
from .serializers import CourseEnrollmentSerializer, CourseEnrollmentSerializerv05
@ddt.ddt
class TestUserDetailApi(MobileAPITestCase, MobileAuthUserTestMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>...
"""
REVERSE_INFO = {'name': 'user-detail', 'params': ['username', 'api_version']}
@ddt.data(API_V05, API_V1)
def test_success(self, api_version):
self.login()
response = self.api_response(api_version=api_version)
assert response.data['username'] == self.user.username
assert response.data['email'] == self.user.email
@ddt.ddt
class TestUserInfoApi(MobileAPITestCase, MobileAuthTestMixin):
"""
Tests for /api/mobile/{api_version}/my_user_info
"""
REVERSE_INFO = {'name': 'user-info', 'params': ['api_version']}
@ddt.data(API_V05, API_V1)
def test_success(self, api_version):
"""Verify the endpoint redirects to the user detail endpoint"""
self.login()
response = self.api_response(expected_response_code=302, api_version=api_version)
assert self.username in response['location']
@ddt.data(API_V05, API_V1)
def test_last_loggedin_updated(self, api_version):
"""Verify that a user's last logged in value updates after hitting the my_user_info endpoint"""
self.login()
self.user.refresh_from_db()
last_login_before = self.user.last_login
# just hit the api endpoint; we don't care about the response here (tested previously)
self.api_response(expected_response_code=302, api_version=api_version)
self.user.refresh_from_db()
last_login_after = self.user.last_login
assert last_login_after > last_login_before
@ddt.ddt
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>/course_enrollments/
"""
REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username', 'api_version']}
ALLOW_ACCESS_TO_UNRELEASED_COURSE = True
ALLOW_ACCESS_TO_MILESTONE_COURSE = True
ALLOW_ACCESS_TO_NON_VISIBLE_COURSE = True
NEXT_WEEK = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=7)
LAST_WEEK = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=7)
THREE_YEARS_AGO = now() - datetime.timedelta(days=(365 * 3))
ADVERTISED_START = "Spring 2016"
ENABLED_SIGNALS = ['course_published']
DATES = {
'next_week': NEXT_WEEK,
'last_week': LAST_WEEK,
'default_start_date': DEFAULT_START_DATE,
}
@patch.dict(settings.FEATURES, {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super().setUp()
def verify_success(self, response):
"""
Verifies user course enrollment response for success
"""
super().verify_success(response)
courses = response.data
assert len(courses) == 1
found_course = courses[0]['course']
assert f'courses/{self.course.id}/about' in found_course['course_about']
assert f'course_info/{self.course.id}/updates' in found_course['course_updates']
assert f'course_info/{self.course.id}/handouts' in found_course['course_handouts']
assert found_course['id'] == str(self.course.id)
assert courses[0]['mode'] == CourseMode.DEFAULT_MODE_SLUG
assert courses[0]['course']['subscription_id'] == self.course.clean_id(padding_char='_')
expected_course_image_url = course_image_url(self.course)
assert expected_course_image_url is not None
assert expected_course_image_url in found_course['course_image']
assert expected_course_image_url in found_course['media']['course_image']['uri']
def verify_failure(self, response, error_type=None):
assert response.status_code == 200
courses = response.data
assert len(courses) == 0
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@ddt.data(API_V05, API_V1)
def test_sort_order(self, api_version):
self.login()
num_courses = 3
courses = []
for course_index in range(num_courses):
courses.append(CourseFactory.create(mobile_available=True))
self.enroll(courses[course_index].id)
# verify courses are returned in the order of enrollment, with most recently enrolled first.
response = self.api_response(api_version=api_version)
for course_index in range(num_courses):
assert response.data[course_index]['course']['id'] ==\
str(courses[((num_courses - course_index) - 1)].id)
@ddt.data(API_V05, API_V1)
@patch.dict(settings.FEATURES, {
'ENABLE_PREREQUISITE_COURSES': True,
'DISABLE_START_DATES': False,
'ENABLE_MKTG_SITE': True,
})
def test_courseware_access(self, api_version):
self.login()
course_with_prereq = CourseFactory.create(start=self.LAST_WEEK, mobile_available=True)
prerequisite_course = CourseFactory.create()
set_prerequisite_courses(course_with_prereq.id, [str(prerequisite_course.id)])
# Create list of courses with various expected courseware_access responses and corresponding expected codes
courses = [
course_with_prereq,
CourseFactory.create(start=self.NEXT_WEEK, mobile_available=True),
CourseFactory.create(visible_to_staff_only=True, mobile_available=True),
CourseFactory.create(start=self.LAST_WEEK, mobile_available=True, visible_to_staff_only=False),
]
expected_error_codes = [
MilestoneAccessError().error_code, # 'unfulfilled_milestones'
StartDateError(self.NEXT_WEEK).error_code, # 'course_not_started'
VisibilityError().error_code, # 'not_visible_to_user'
None,
]
# Enroll in all the courses
for course in courses:
self.enroll(course.id)
# Verify courses have the correct response through error code. Last enrolled course is first course in response
response = self.api_response(api_version=api_version)
for course_index in range(len(courses)):
result = response.data[course_index]['course']['courseware_access']
assert result['error_code'] == expected_error_codes[::(- 1)][course_index]
if result['error_code'] is not None:
assert not result['has_access']
@ddt.data(
('next_week', ADVERTISED_START, ADVERTISED_START, "string", API_V05),
('next_week', ADVERTISED_START, ADVERTISED_START, "string", API_V1),
('next_week', None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V05),
('next_week', None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V1),
('next_week', '', defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V05),
('next_week', '', defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp", API_V1),
('default_start_date', ADVERTISED_START, ADVERTISED_START, "string", API_V05),
('default_start_date', ADVERTISED_START, ADVERTISED_START, "string", API_V1),
('default_start_date', '', None, "empty", API_V05),
('default_start_date', '', None, "empty", API_V1),
('default_start_date', None, None, "empty", API_V05),
('default_start_date', None, None, "empty", API_V1),
)
@ddt.unpack
@patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False, 'ENABLE_MKTG_SITE': True})
def test_start_type_and_display(self, start, advertised_start, expected_display, expected_type, api_version):
"""
Tests that the correct start_type and start_display are returned in the
case the course has not started
"""
self.login()
course = CourseFactory.create(start=self.DATES[start], advertised_start=advertised_start, mobile_available=True)
self.enroll(course.id)
response = self.api_response(api_version=api_version)
assert response.data[0]['course']['start_type'] == expected_type
assert response.data[0]['course']['start_display'] == expected_display
@ddt.data(API_V05, API_V1)
@patch.dict(settings.FEATURES, {"ENABLE_DISCUSSION_SERVICE": True, 'ENABLE_MKTG_SITE': True})
def test_discussion_url(self, api_version):
self.login_and_enroll()
response = self.api_response(api_version=api_version)
response_discussion_url = response.data[0]['course']['discussion_url']
assert f'/api/discussion/v1/courses/{self.course.id}' in response_discussion_url
@ddt.data(API_V05, API_V1)
def test_org_query(self, api_version):
self.login()
# Create list of courses with various organizations
courses = [
CourseFactory.create(org='edX', mobile_available=True),
CourseFactory.create(org='edX', mobile_available=True),
CourseFactory.create(org='edX', mobile_available=True, visible_to_staff_only=True),
CourseFactory.create(org='Proversity.org', mobile_available=True),
CourseFactory.create(org='MITx', mobile_available=True),
CourseFactory.create(org='HarvardX', mobile_available=True),
]
# Enroll in all the courses
for course in courses:
self.enroll(course.id)
response = self.api_response(data={'org': 'edX'}, api_version=api_version)
# Test for 3 expected courses
assert len(response.data) == 3
# Verify only edX courses are returned
for entry in response.data:
assert entry['course']['org'] == 'edX'
def create_enrollment(self, expired):
"""
Create an enrollment
"""
if expired:
course = CourseFactory.create(start=self.THREE_YEARS_AGO, mobile_available=True)
enrollment = CourseEnrollmentFactory.create(
user=self.user,
course_id=course.id
)
enrollment.created = self.THREE_YEARS_AGO + datetime.timedelta(days=1)
enrollment.save()
else:
course = CourseFactory.create(start=self.LAST_WEEK, mobile_available=True)
self.enroll(course.id)
add_course_mode(course, mode_slug=CourseMode.AUDIT)
add_course_mode(course)
def _get_enrollment_data(self, api_version, expired):
self.login()
self.create_enrollment(expired)
return self.api_response(api_version=api_version).data
def _assert_enrollment_results(self, api_version, courses, num_courses_returned, gating_enabled=True): # lint-amnesty, pylint: disable=missing-function-docstring
assert len(courses) == num_courses_returned
if api_version == API_V05:
if num_courses_returned:
assert 'audit_access_expires' not in courses[0]
else:
assert 'audit_access_expires' in courses[0]
if gating_enabled:
assert courses[0].get('audit_access_expires') is not None
@ddt.data(
(API_V05, True, 0),
(API_V05, False, 1),
(API_V1, True, 1),
(API_V1, False, 1),
)
@ddt.unpack
def test_enrollment_with_gating(self, api_version, expired, num_courses_returned):
"""
Test that expired courses are only returned in v1 of API
when waffle flag enabled, and un-expired courses always returned
"""
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2015, 1, 1))
courses = self._get_enrollment_data(api_version, expired)
self._assert_enrollment_results(api_version, courses, num_courses_returned, True)
@ddt.data(
(API_V05, True, 1),
(API_V05, False, 1),
(API_V1, True, 1),
(API_V1, False, 1),
)
@ddt.unpack
def test_enrollment_no_gating(self, api_version, expired, num_courses_returned):
"""
Test that expired and non-expired courses are returned if the waffle flag is disabled,
regardless of the API version
"""
CourseDurationLimitConfig.objects.create(enabled=False)
courses = self._get_enrollment_data(api_version, expired)
self._assert_enrollment_results(api_version, courses, num_courses_returned, False)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin):
"""
Tests for /api/mobile/{api_version}/users/<user_name>/course_enrollments/
"""
REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username', 'api_version']}
ENABLED_SIGNALS = ['course_published']
def verify_pdf_certificate(self):
"""
Verifies the correct URL is returned in the response
for PDF certificates.
"""
self.login_and_enroll()
certificate_url = "https://test_certificate_url"
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='verified',
download_url=certificate_url,
)
response = self.api_response()
certificate_data = response.data[0]['certificate']
assert certificate_data['url'] == certificate_url
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
def test_no_certificate(self):
self.login_and_enroll()
response = self.api_response()
certificate_data = response.data[0]['certificate']
self.assertDictEqual(certificate_data, {})
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': False, 'ENABLE_MKTG_SITE': True})
def test_pdf_certificate_with_html_cert_disabled(self):
"""
Tests PDF certificates with CERTIFICATES_HTML_VIEW set to True.
"""
self.verify_pdf_certificate()
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True, 'ENABLE_MKTG_SITE': True})
def test_pdf_certificate_with_html_cert_enabled(self):
"""
Tests PDF certificates with CERTIFICATES_HTML_VIEW set to True.
"""
self.verify_pdf_certificate()
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True, 'ENABLE_MKTG_SITE': True})
def test_web_certificate(self):
self.login_and_enroll()
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable
)
response = self.api_response()
certificate_data = response.data[0]['certificate']
self.assertRegex(
certificate_data['url'],
r'http.*/certificates/[0-9a-f]{32}'
)
class CourseStatusAPITestCase(MobileAPITestCase):
"""
Base test class for /api/mobile/{api_version}/users/<user_name>/course_status_info/{course_id}
"""
REVERSE_INFO = {'name': 'user-course-status', 'params': ['username', 'course_id', 'api_version']}
def setUp(self):
"""
Creates a basic course structure for our course
"""
super().setUp()
self.section = ItemFactory.create(
parent=self.course,
category='chapter',
)
self.sub_section = ItemFactory.create(
parent=self.section,
category='sequential',
)
self.unit = ItemFactory.create(
parent=self.sub_section,
category='vertical',
)
self.other_sub_section = ItemFactory.create(
parent=self.section,
category='sequential',
)
self.other_unit = ItemFactory.create(
parent=self.other_sub_section,
category='vertical',
)
class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin, CompletionWaffleTestMixin):
"""
Tests for GET of /api/mobile/v<version_number>/users/<user_name>/course_status_info/{course_id}
"""
def test_success_v0(self):
self.login_and_enroll()
response = self.api_response(api_version=API_V05)
assert response.data['last_visited_module_id'] == str(self.sub_section.location)
assert response.data['last_visited_module_path'] == [str(module.location) for module in
[self.sub_section, self.section, self.course]]
def test_success_v1(self):
self.override_waffle_switch(True)
self.login_and_enroll()
submit_completions_for_testing(self.user, [self.unit.location])
response = self.api_response(api_version=API_V1)
assert response.data['last_visited_block_id'] == str(self.unit.location)
# Since we are testing an non atomic view in atomic test case, therefore we are expecting error on failures
def api_error_response(self, reverse_args=None, data=None, **kwargs):
"""
Same as api response from MobileAPITestCase but handle views which throw errors
"""
url = self.reverse_url(reverse_args, **kwargs)
try:
with transaction.atomic():
self.url_method(url, data=data, **kwargs)
assert False
except transaction.TransactionManagementError:
assert True
def test_invalid_user(self):
self.login_and_enroll()
self.api_error_response(username='no_user')
def test_other_user(self):
# login and enroll as the test user
self.login_and_enroll()
self.logout()
# login and enroll as another user
other = UserFactory.create()
self.client.login(username=other.username, password='test')
self.enroll()
self.logout()
# now login and call the API as the test user
self.login()
self.api_error_response(username=other.username)
def test_course_not_found(self):
non_existent_course_id = CourseKey.from_string('a/b/c')
self.init_course_access(course_id=non_existent_course_id)
self.api_error_response(course_id=non_existent_course_id)
def test_unenrolled_user(self):
self.login()
self.unenroll()
self.api_error_response(expected_response_code=None)
def test_no_auth(self):
self.logout()
self.api_error_response()
class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin,
MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
"""
Tests for PATCH of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
"""
def url_method(self, url, **kwargs): # pylint: disable=arguments-differ
# override implementation to use PATCH method.
return self.client.patch(url, data=kwargs.get('data', None))
def test_success(self):
self.login_and_enroll()
response = self.api_response(data={"last_visited_module_id": str(self.other_unit.location)})
assert response.data['last_visited_module_id'] == str(self.other_sub_section.location)
def test_invalid_module(self):
self.login_and_enroll()
response = self.api_response(data={"last_visited_module_id": "abc"}, expected_response_code=400)
assert response.data == errors.ERROR_INVALID_MODULE_ID
def test_nonexistent_module(self):
self.login_and_enroll()
non_existent_key = self.course.id.make_usage_key('video', 'non-existent')
response = self.api_response(data={"last_visited_module_id": non_existent_key}, expected_response_code=400)
assert response.data == errors.ERROR_INVALID_MODULE_ID
def test_no_timezone(self):
self.login_and_enroll()
past_date = datetime.datetime.now()
response = self.api_response(
data={
"last_visited_module_id": str(self.other_unit.location),
"modification_date": past_date.isoformat()
},
expected_response_code=400
)
assert response.data == errors.ERROR_INVALID_MODIFICATION_DATE
def _date_sync(self, date, initial_unit, update_unit, expected_subsection):
"""
Helper for test cases that use a modification to decide whether
to update the course status
"""
self.login_and_enroll()
# save something so we have an initial date
self.api_response(data={"last_visited_module_id": str(initial_unit.location)})
# now actually update it
response = self.api_response(
data={
"last_visited_module_id": str(update_unit.location),
"modification_date": date.isoformat()
}
)
assert response.data['last_visited_module_id'] == str(expected_subsection.location)
def test_old_date(self):
self.login_and_enroll()
date = timezone.now() + datetime.timedelta(days=-100)
self._date_sync(date, self.unit, self.other_unit, self.sub_section)
def test_new_date(self):
self.login_and_enroll()
date = timezone.now() + datetime.timedelta(days=100)
self._date_sync(date, self.unit, self.other_unit, self.other_sub_section)
def test_no_initial_date(self):
self.login_and_enroll()
response = self.api_response(
data={
"last_visited_module_id": str(self.other_unit.location),
"modification_date": timezone.now().isoformat()
}
)
assert response.data['last_visited_module_id'] == str(self.other_sub_section.location)
def test_invalid_date(self):
self.login_and_enroll()
response = self.api_response(data={"modification_date": "abc"}, expected_response_code=400)
assert response.data == errors.ERROR_INVALID_MODIFICATION_DATE
@ddt.ddt
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
class TestCourseEnrollmentSerializer(MobileAPITestCase, MilestonesTestCaseMixin):
"""
Test the course enrollment serializer
"""
ENABLED_SIGNALS = ['course_published']
def setUp(self):
super().setUp()
self.login_and_enroll()
self.request = RequestFactory().get('/')
self.request.user = self.user
def get_serialized_data(self, api_version):
"""
Return data from CourseEnrollmentSerializer
"""
if api_version == API_V05:
serializer = CourseEnrollmentSerializerv05
else:
serializer = CourseEnrollmentSerializer
return serializer(
CourseEnrollment.enrollments_for_user(self.user)[0],
context={'request': self.request, 'api_version': api_version},
).data
def _expiration_in_response(self, response, api_version):
"""
Assert that audit_access_expires field in present in response
based on version of api being used
"""
if api_version != API_V05:
assert 'audit_access_expires' in response
else:
assert 'audit_access_expires' not in response
@ddt.data(API_V05, API_V1)
def test_success(self, api_version):
serialized = self.get_serialized_data(api_version)
assert serialized['course']['name'] == self.course.display_name
assert serialized['course']['number'] == self.course.id.course
assert serialized['course']['org'] == self.course.id.org
self._expiration_in_response(serialized, api_version)
# Assert utm parameters
qstwitter = parse_qs('utm_campaign=social-sharing-db&utm_medium=social&utm_source=twitter')
qsfacebook = parse_qs('utm_campaign=social-sharing-db&utm_medium=social&utm_source=facebook')
self.assertDictEqual(qsfacebook, parse_qs(serialized['course']['course_sharing_utm_parameters']['facebook']))
self.assertDictEqual(qstwitter, parse_qs(serialized['course']['course_sharing_utm_parameters']['twitter']))
@ddt.data(API_V05, API_V1)
def test_with_display_overrides(self, api_version):
self.course.display_coursenumber = "overridden_number"
self.course.display_organization = "overridden_org"
self.course = self.update_course(self.course, self.user.id)
serialized = self.get_serialized_data(api_version)
assert serialized['course']['number'] == self.course.display_coursenumber
assert serialized['course']['org'] == self.course.display_organization
self._expiration_in_response(serialized, api_version)