tests.py 17.7 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
@attr(shard=2)
279 280 281 282 283
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']}
284

285
    def setUp(self):
286 287 288
        """
        Creates a basic course structure for our course
        """
289 290 291 292 293 294 295 296 297
        super(CourseStatusAPITestCase, self).setUp()

        self.section = ItemFactory.create(
            parent=self.course,
            category='chapter',
        )
        self.sub_section = ItemFactory.create(
            parent=self.section,
            category='sequential',
298
        )
299 300 301
        self.unit = ItemFactory.create(
            parent=self.sub_section,
            category='vertical',
302
        )
303 304 305
        self.other_sub_section = ItemFactory.create(
            parent=self.section,
            category='sequential',
306
        )
307 308 309
        self.other_unit = ItemFactory.create(
            parent=self.other_sub_section,
            category='vertical',
310 311 312
        )


313
@attr(shard=2)
314 315
class TestCourseStatusGET(CourseStatusAPITestCase, MobileAuthUserTestMixin,
                          MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
316 317 318 319 320
    """
    Tests for GET of /api/mobile/v0.5/users/<user_name>/course_status_info/{course_id}
    """
    def test_success(self):
        self.login_and_enroll()
321

322
        response = self.api_response()
323
        self.assertEqual(
324
            response.data["last_visited_module_id"],
325 326 327
            unicode(self.sub_section.location)
        )
        self.assertEqual(
328
            response.data["last_visited_module_path"],
329
            [unicode(module.location) for module in [self.sub_section, self.section, self.course]]
330
        )
331 332


333
@attr(shard=2)
334 335
class TestCourseStatusPATCH(CourseStatusAPITestCase, MobileAuthUserTestMixin,
                            MobileCourseAccessTestMixin, MilestonesTestCaseMixin):
336 337 338 339 340
    """
    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.
341
        return self.client.patch(url, data=kwargs.get('data', None))
342

343 344
    def test_success(self):
        self.login_and_enroll()
345 346
        response = self.api_response(data={"last_visited_module_id": unicode(self.other_unit.location)})
        self.assertEqual(
347
            response.data["last_visited_module_id"],
348 349
            unicode(self.other_sub_section.location)
        )
350 351 352 353

    def test_invalid_module(self):
        self.login_and_enroll()
        response = self.api_response(data={"last_visited_module_id": "abc"}, expected_response_code=400)
354
        self.assertEqual(
355
            response.data,
356 357
            errors.ERROR_INVALID_MODULE_ID
        )
358 359 360 361 362

    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)
363
        self.assertEqual(
364
            response.data,
365 366
            errors.ERROR_INVALID_MODULE_ID
        )
367

368 369
    def test_no_timezone(self):
        self.login_and_enroll()
370
        past_date = datetime.datetime.now()
371 372
        response = self.api_response(
            data={
373
                "last_visited_module_id": unicode(self.other_unit.location),
374
                "modification_date": past_date.isoformat()
375
            },
376
            expected_response_code=400
377
        )
378
        self.assertEqual(
379
            response.data,
380 381
            errors.ERROR_INVALID_MODIFICATION_DATE
        )
382

383
    def _date_sync(self, date, initial_unit, update_unit, expected_subsection):
384 385 386 387
        """
        Helper for test cases that use a modification to decide whether
        to update the course status
        """
388 389
        self.login_and_enroll()

390
        # save something so we have an initial date
391
        self.api_response(data={"last_visited_module_id": unicode(initial_unit.location)})
392 393

        # now actually update it
394 395
        response = self.api_response(
            data={
396 397
                "last_visited_module_id": unicode(update_unit.location),
                "modification_date": date.isoformat()
398
            }
399
        )
400
        self.assertEqual(
401
            response.data["last_visited_module_id"],
402 403
            unicode(expected_subsection.location)
        )
404

405 406
    def test_old_date(self):
        self.login_and_enroll()
407
        date = timezone.now() + datetime.timedelta(days=-100)
408
        self._date_sync(date, self.unit, self.other_unit, self.sub_section)
409

410 411
    def test_new_date(self):
        self.login_and_enroll()
412
        date = timezone.now() + datetime.timedelta(days=100)
413
        self._date_sync(date, self.unit, self.other_unit, self.other_sub_section)
414

415 416 417 418
    def test_no_initial_date(self):
        self.login_and_enroll()
        response = self.api_response(
            data={
419
                "last_visited_module_id": unicode(self.other_unit.location),
420 421 422
                "modification_date": timezone.now().isoformat()
            }
        )
423
        self.assertEqual(
424
            response.data["last_visited_module_id"],
425 426
            unicode(self.other_sub_section.location)
        )
427

428 429 430
    def test_invalid_date(self):
        self.login_and_enroll()
        response = self.api_response(data={"modification_date": "abc"}, expected_response_code=400)
431
        self.assertEqual(
432
            response.data,
433 434
            errors.ERROR_INVALID_MODIFICATION_DATE
        )
435

436

437
@attr(shard=2)
438 439
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True})
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
440
class TestCourseEnrollmentSerializer(MobileAPITestCase, MilestonesTestCaseMixin):
441 442 443
    """
    Test the course enrollment serializer
    """
444 445
    def setUp(self):
        super(TestCourseEnrollmentSerializer, self).setUp()
446
        self.login_and_enroll()
447 448
        self.request = RequestFactory().get('/')
        self.request.user = self.user
449

450 451 452 453 454
    def test_success(self):
        serialized = CourseEnrollmentSerializer(
            CourseEnrollment.enrollments_for_user(self.user)[0],
            context={'request': self.request},
        ).data
455 456 457 458 459 460 461
        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"
462
        self.store.update_item(self.course, self.user.id)
463

464 465 466 467
        serialized = CourseEnrollmentSerializer(
            CourseEnrollment.enrollments_for_user(self.user)[0],
            context={'request': self.request},
        ).data
468 469
        self.assertEqual(serialized['course']['number'], self.course.display_coursenumber)
        self.assertEqual(serialized['course']['org'], self.course.display_organization)