tests.py 18.6 KB
Newer Older
Dave St.Germain committed
1 2 3
"""
Tests for users API
"""
4
# pylint: disable=no-member
5
import datetime
6

7 8
import ddt
from mock import patch
9
from nose.plugins.attrib import attr
10 11
import pytz
from django.conf import settings
12
from django.utils import timezone
13
from django.template import defaultfilters
14
from django.test import RequestFactory, override_settings
15 16 17
from milestones.tests.utils import MilestonesTestCaseMixin
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
18

19
from certificates.api import generate_user_certificates
20 21
from certificates.models import CertificateStatuses
from certificates.tests.factories import GeneratedCertificateFactory
22 23 24 25 26
from courseware.access_response import (
    MilestoneError,
    StartDateError,
    VisibilityError,
)
27
from course_modes.models import CourseMode
28
from lms.djangoapps.grades.tests.utils import mock_passing_grade
29
from openedx.core.lib.courses import course_image_url
30
from student.models import CourseEnrollment
31
from util.milestones_helpers import set_prerequisite_courses
32
from util.testing import UrlResetMixin
33
from .. import errors
34 35 36 37 38 39
from mobile_api.testutils import (
    MobileAPITestCase,
    MobileAuthTestMixin,
    MobileAuthUserTestMixin,
    MobileCourseAccessTestMixin,
)
40
from .serializers import CourseEnrollmentSerializer
Dave St.Germain committed
41 42


43
@attr(shard=2)
44
class TestUserDetailApi(MobileAPITestCase, MobileAuthUserTestMixin):
Dave St.Germain committed
45
    """
46
    Tests for /api/mobile/v0.5/users/<user_name>...
Dave St.Germain committed
47
    """
48
    REVERSE_INFO = {'name': 'user-detail', 'params': ['username']}
49

50 51
    def test_success(self):
        self.login()
Dave St.Germain committed
52

53 54 55
        response = self.api_response()
        self.assertEqual(response.data['username'], self.user.username)
        self.assertEqual(response.data['email'], self.user.email)
56 57


58
@attr(shard=2)
59 60 61 62 63 64
class TestUserInfoApi(MobileAPITestCase, MobileAuthTestMixin):
    """
    Tests for /api/mobile/v0.5/my_user_info
    """
    def reverse_url(self, reverse_args=None, **kwargs):
        return '/api/mobile/v0.5/my_user_info'
Dave St.Germain committed
65

66 67 68 69 70
    def test_success(self):
        """Verify the endpoint redirects to the user detail endpoint"""
        self.login()

        response = self.api_response(expected_response_code=302)
71
        self.assertIn(self.username, response['location'])
Dave St.Germain committed
72

73

74
@attr(shard=2)
75
@ddt.ddt
76
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
77 78
class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTestMixin,
                            MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
79 80 81 82
    """
    Tests for /api/mobile/v0.5/users/<user_name>/course_enrollments/
    """
    REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username']}
83
    ALLOW_ACCESS_TO_UNRELEASED_COURSE = True
84
    ALLOW_ACCESS_TO_MILESTONE_COURSE = True
85
    ALLOW_ACCESS_TO_NON_VISIBLE_COURSE = True
86 87 88
    NEXT_WEEK = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=7)
    LAST_WEEK = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=7)
    ADVERTISED_START = "Spring 2016"
89

90
    @patch.dict(settings.FEATURES, {"ENABLE_DISCUSSION_SERVICE": True})
91 92 93
    def setUp(self, *args, **kwargs):
        super(TestUserEnrollmentApi, self).setUp()

94
    def verify_success(self, response):
95 96 97
        """
        Verifies user course enrollment response for success
        """
98 99 100
        super(TestUserEnrollmentApi, self).verify_success(response)
        courses = response.data
        self.assertEqual(len(courses), 1)
101

102
        found_course = courses[0]['course']
103 104 105 106
        self.assertIn('courses/{}/about'.format(self.course.id), found_course['course_about'])
        self.assertIn('course_info/{}/updates'.format(self.course.id), found_course['course_updates'])
        self.assertIn('course_info/{}/handouts'.format(self.course.id), found_course['course_handouts'])
        self.assertIn('video_outlines/courses/{}'.format(self.course.id), found_course['video_outline'])
107
        self.assertEqual(found_course['id'], unicode(self.course.id))
108
        self.assertEqual(courses[0]['mode'], CourseMode.DEFAULT_MODE_SLUG)
109
        self.assertEqual(courses[0]['course']['subscription_id'], self.course.clean_id(padding_char='_'))
110

111 112 113 114 115
        expected_course_image_url = course_image_url(self.course)
        self.assertIsNotNone(expected_course_image_url)
        self.assertIn(expected_course_image_url, found_course['course_image'])
        self.assertIn(expected_course_image_url, found_course['media']['course_image']['uri'])

116
    def verify_failure(self, response, error_type=None):
117 118 119 120
        self.assertEqual(response.status_code, 200)
        courses = response.data
        self.assertEqual(len(courses), 0)

121
    @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
122 123 124 125 126
    def test_sort_order(self):
        self.login()

        num_courses = 3
        courses = []
127
        for course_index in range(num_courses):
128
            courses.append(CourseFactory.create(mobile_available=True))
129
            self.enroll(courses[course_index].id)
130 131 132

        # verify courses are returned in the order of enrollment, with most recently enrolled first.
        response = self.api_response()
133
        for course_index in range(num_courses):
134
            self.assertEqual(
135
                response.data[course_index]['course']['id'],
136
                unicode(courses[num_courses - course_index - 1].id)
137 138
            )

139 140 141 142 143
    @patch.dict(settings.FEATURES, {
        'ENABLE_PREREQUISITE_COURSES': True,
        'DISABLE_START_DATES': False,
        'ENABLE_MKTG_SITE': True,
    })
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
    def test_courseware_access(self):
        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, [unicode(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 = [
            MilestoneError().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()
        for course_index in range(len(courses)):
173
            result = response.data[course_index]['course']['courseware_access']
174 175 176 177 178 179 180
            self.assertEqual(result['error_code'], expected_error_codes[::-1][course_index])

            if result['error_code'] is not None:
                self.assertFalse(result['has_access'])

    @ddt.data(
        (NEXT_WEEK, ADVERTISED_START, ADVERTISED_START, "string"),
181
        (NEXT_WEEK, None, defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp"),
182
        (NEXT_WEEK, '', defaultfilters.date(NEXT_WEEK, "DATE_FORMAT"), "timestamp"),
183
        (DEFAULT_START_DATE, ADVERTISED_START, ADVERTISED_START, "string"),
184 185
        (DEFAULT_START_DATE, '', None, "empty"),
        (DEFAULT_START_DATE, None, None, "empty"),
186 187
    )
    @ddt.unpack
188
    @patch.dict(settings.FEATURES, {'DISABLE_START_DATES': False, 'ENABLE_MKTG_SITE': True})
189 190 191 192 193 194 195 196 197 198
    def test_start_type_and_display(self, start, advertised_start, expected_display, expected_type):
        """
        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=start, advertised_start=advertised_start, mobile_available=True)
        self.enroll(course.id)

        response = self.api_response()
199 200
        self.assertEqual(response.data[0]['course']['start_type'], expected_type)
        self.assertEqual(response.data[0]['course']['start_display'], expected_display)
201

202
    @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
203 204 205 206
    def test_no_certificate(self):
        self.login_and_enroll()

        response = self.api_response()
207
        certificate_data = response.data[0]['certificate']
208 209
        self.assertDictEqual(certificate_data, {})

210 211 212 213 214
    def verify_pdf_certificate(self):
        """
        Verifies the correct URL is returned in the response
        for PDF certificates.
        """
215 216 217 218 219 220 221 222 223 224 225 226
        self.login_and_enroll()

        certificate_url = "http://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()
227
        certificate_data = response.data[0]['certificate']
228 229
        self.assertEquals(certificate_data['url'], certificate_url)

230
    @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': False, 'ENABLE_MKTG_SITE': True})
231 232 233 234 235 236
    def test_pdf_certificate_with_html_cert_disabled(self):
        """
        Tests PDF certificates with CERTIFICATES_HTML_VIEW set to False.
        """
        self.verify_pdf_certificate()

237
    @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True, 'ENABLE_MKTG_SITE': True})
238 239 240 241 242 243
    def test_pdf_certificate_with_html_cert_enabled(self):
        """
        Tests PDF certificates with CERTIFICATES_HTML_VIEW set to True.
        """
        self.verify_pdf_certificate()

244
    @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True, 'ENABLE_MKTG_SITE': True})
245
    def test_web_certificate(self):
Bill DeRusha committed
246 247 248 249 250
        CourseMode.objects.create(
            course_id=self.course.id,
            mode_display_name="Honor",
            mode_slug=CourseMode.HONOR,
        )
251 252 253 254 255
        self.login_and_enroll()

        self.course.cert_html_view_enabled = True
        self.store.update_item(self.course, self.user.id)

256
        with mock_passing_grade():
257 258 259 260 261 262 263 264 265 266 267 268
            generate_user_certificates(self.user, self.course.id)

        response = self.api_response()
        certificate_data = response.data[0]['certificate']
        self.assertRegexpMatches(
            certificate_data['url'],
            r'http.*/certificates/user/{user_id}/course/{course_id}'.format(
                user_id=self.user.id,
                course_id=self.course.id,
            )
        )

269
    @patch.dict(settings.FEATURES, {"ENABLE_DISCUSSION_SERVICE": True, 'ENABLE_MKTG_SITE': True})
270 271 272 273 274 275 276
    def test_discussion_url(self):
        self.login_and_enroll()

        response = self.api_response()
        response_discussion_url = response.data[0]['course']['discussion_url']  # pylint: disable=E1101
        self.assertIn('/api/discussion/v1/courses/{}'.format(self.course.id), response_discussion_url)

277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293
    def test_org_query(self):
        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)

294 295 296 297
        response = self.api_response(data={'org': 'edX'})

        # Test for 3 expected courses
        self.assertEqual(len(response.data), 3)
298 299 300 301 302 303

        # Verify only edX courses are returned
        for entry in response.data:
            self.assertEqual(entry['course']['org'], 'edX')


304
@attr(shard=2)
305 306 307 308 309
class CourseStatusAPITestCase(MobileAPITestCase):
    """
    Base test class for /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
    """
    REVERSE_INFO = {'name': 'user-course-status', 'params': ['username', 'course_id']}
310

311
    def setUp(self):
312 313 314
        """
        Creates a basic course structure for our course
        """
315 316 317 318 319 320 321 322 323
        super(CourseStatusAPITestCase, self).setUp()

        self.section = ItemFactory.create(
            parent=self.course,
            category='chapter',
        )
        self.sub_section = ItemFactory.create(
            parent=self.section,
            category='sequential',
324
        )
325 326 327
        self.unit = ItemFactory.create(
            parent=self.sub_section,
            category='vertical',
328
        )
329 330 331
        self.other_sub_section = ItemFactory.create(
            parent=self.section,
            category='sequential',
332
        )
333 334 335
        self.other_unit = ItemFactory.create(
            parent=self.other_sub_section,
            category='vertical',
336 337 338
        )


339
@attr(shard=2)
340 341
class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin,
                          MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
342 343 344 345 346
    """
    Tests for GET of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
    """
    def test_success(self):
        self.login_and_enroll()
347

348
        response = self.api_response()
349
        self.assertEqual(
350
            response.data["last_visited_module_id"],
351 352 353
            unicode(self.sub_section.location)
        )
        self.assertEqual(
354
            response.data["last_visited_module_path"],
355
            [unicode(module.location) for module in [self.sub_section, self.section, self.course]]
356
        )
357 358


359
@attr(shard=2)
360 361
class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin,
                            MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
362 363 364 365 366
    """
    Tests for PATCH of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
    """
    def url_method(self, url, **kwargs):
        # override implementation to use PATCH method.
367
        return self.client.patch(url, data=kwargs.get('data', None))
368

369 370
    def test_success(self):
        self.login_and_enroll()
371 372
        response = self.api_response(data={"last_visited_module_id": unicode(self.other_unit.location)})
        self.assertEqual(
373
            response.data["last_visited_module_id"],
374 375
            unicode(self.other_sub_section.location)
        )
376 377 378 379

    def test_invalid_module(self):
        self.login_and_enroll()
        response = self.api_response(data={"last_visited_module_id": "abc"}, expected_response_code=400)
380
        self.assertEqual(
381
            response.data,
382 383
            errors.ERROR_INVALID_MODULE_ID
        )
384 385 386 387 388

    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)
389
        self.assertEqual(
390
            response.data,
391 392
            errors.ERROR_INVALID_MODULE_ID
        )
393

394 395
    def test_no_timezone(self):
        self.login_and_enroll()
396
        past_date = datetime.datetime.now()
397 398
        response = self.api_response(
            data={
399
                "last_visited_module_id": unicode(self.other_unit.location),
400
                "modification_date": past_date.isoformat()
401
            },
402
            expected_response_code=400
403
        )
404
        self.assertEqual(
405
            response.data,
406 407
            errors.ERROR_INVALID_MODIFICATION_DATE
        )
408

409
    def _date_sync(self, date, initial_unit, update_unit, expected_subsection):
410 411 412 413
        """
        Helper for test cases that use a modification to decide whether
        to update the course status
        """
414 415
        self.login_and_enroll()

416
        # save something so we have an initial date
417
        self.api_response(data={"last_visited_module_id": unicode(initial_unit.location)})
418 419

        # now actually update it
420 421
        response = self.api_response(
            data={
422 423
                "last_visited_module_id": unicode(update_unit.location),
                "modification_date": date.isoformat()
424
            }
425
        )
426
        self.assertEqual(
427
            response.data["last_visited_module_id"],
428 429
            unicode(expected_subsection.location)
        )
430

431 432
    def test_old_date(self):
        self.login_and_enroll()
433
        date = timezone.now() + datetime.timedelta(days=-100)
434
        self._date_sync(date, self.unit, self.other_unit, self.sub_section)
435

436 437
    def test_new_date(self):
        self.login_and_enroll()
438
        date = timezone.now() + datetime.timedelta(days=100)
439
        self._date_sync(date, self.unit, self.other_unit, self.other_sub_section)
440

441 442 443 444
    def test_no_initial_date(self):
        self.login_and_enroll()
        response = self.api_response(
            data={
445
                "last_visited_module_id": unicode(self.other_unit.location),
446 447 448
                "modification_date": timezone.now().isoformat()
            }
        )
449
        self.assertEqual(
450
            response.data["last_visited_module_id"],
451 452
            unicode(self.other_sub_section.location)
        )
453

454 455 456
    def test_invalid_date(self):
        self.login_and_enroll()
        response = self.api_response(data={"modification_date": "abc"}, expected_response_code=400)
457
        self.assertEqual(
458
            response.data,
459 460
            errors.ERROR_INVALID_MODIFICATION_DATE
        )
461

462

463
@attr(shard=2)
464 465
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
466
class TestCourseEnrollmentSerializer(MobileAPITestCase, MilestonesTestCaseMixin):
467 468 469
    """
    Test the course enrollment serializer
    """
470 471
    def setUp(self):
        super(TestCourseEnrollmentSerializer, self).setUp()
472
        self.login_and_enroll()
473 474
        self.request = RequestFactory().get('/')
        self.request.user = self.user
475

476 477 478 479 480
    def test_success(self):
        serialized = CourseEnrollmentSerializer(
            CourseEnrollment.enrollments_for_user(self.user)[0],
            context={'request': self.request},
        ).data
481 482 483 484 485 486 487
        self.assertEqual(serialized['course']['name'], self.course.display_name)
        self.assertEqual(serialized['course']['number'], self.course.id.course)
        self.assertEqual(serialized['course']['org'], self.course.id.org)

    def test_with_display_overrides(self):
        self.course.display_coursenumber = "overridden_number"
        self.course.display_organization = "overridden_org"
488
        self.store.update_item(self.course, self.user.id)
489

490 491 492 493
        serialized = CourseEnrollmentSerializer(
            CourseEnrollment.enrollments_for_user(self.user)[0],
            context={'request': self.request},
        ).data
494 495
        self.assertEqual(serialized['course']['number'], self.course.display_coursenumber)
        self.assertEqual(serialized['course']['org'], self.course.display_organization)