tests.py 15.5 KB
Newer Older
1 2
"""
Run these tests @ Devstack:
3
    paver test_system -s lms --fasttest --verbose --test-id=lms/djangoapps/course_structure_api
4 5 6 7 8
"""
# pylint: disable=missing-docstring,invalid-name,maybe-no-member,attribute-defined-outside-init
from datetime import datetime

from django.core.urlresolvers import reverse
9
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
10
from mock import Mock, patch
11
from opaque_keys.edx.locator import CourseLocator
12 13 14 15 16

from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from courseware.tests.factories import GlobalStaffFactory, StaffFactory
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
17
from xmodule.error_module import ErrorDescriptor
Clinton Blackburn committed
18
from xmodule.modulestore import ModuleStoreEnum
19
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
Ned Batchelder committed
20
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
21 22
from xmodule.modulestore.xml import CourseLocationManager
from xmodule.tests import get_test_system
23 24 25 26 27 28 29 30 31 32

TEST_SERVER_HOST = 'http://testserver'


class CourseViewTestsMixin(object):
    """
    Mixin for course view tests.
    """
    view = None

33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
    raw_grader = [
        {
            "min_count": 24,
            "weight": 0.2,
            "type": "Homework",
            "drop_count": 0,
            "short_label": "HW"
        },
        {
            "min_count": 4,
            "weight": 0.8,
            "type": "Exam",
            "drop_count": 0,
            "short_label": "Exam"
        }
    ]

50 51 52 53
    def setUp(self):
        super(CourseViewTestsMixin, self).setUp()
        self.create_user_and_access_token()

54
    def create_user(self):
55
        self.user = GlobalStaffFactory.create()
56 57 58

    def create_user_and_access_token(self):
        self.create_user()
59 60 61
        self.oauth_client = ClientFactory.create()
        self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token

62 63 64
    @classmethod
    def create_course_data(cls):
        cls.invalid_course_id = 'foo/bar/baz'
65
        cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader)
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
        cls.course_id = unicode(cls.course.id)
        with cls.store.bulk_operations(cls.course.id, emit_signals=False):
            cls.sequential = ItemFactory.create(
                category="sequential",
                parent_location=cls.course.location,
                display_name="Lesson 1",
                format="Homework",
                graded=True
            )

            factory = MultipleChoiceResponseXMLFactory()
            args = {'choices': [False, True, False]}
            problem_xml = factory.build_xml(**args)
            cls.problem = ItemFactory.create(
                category="problem",
                parent_location=cls.sequential.location,
                display_name="Problem 1",
                format="Homework",
                data=problem_xml,
            )

            cls.video = ItemFactory.create(
                category="video",
                parent_location=cls.sequential.location,
                display_name="Video 1",
            )

            cls.html = ItemFactory.create(
                category="html",
                parent_location=cls.sequential.location,
                display_name="HTML 1",
            )

        cls.empty_course = CourseFactory.create(
100 101
            start=datetime(2014, 6, 16, 14, 30),
            end=datetime(2015, 1, 16),
Clinton Blackburn committed
102 103 104
            org="MTD",
            # Use mongo so that we can get a test with a SlashSeparatedCourseKey
            default_store=ModuleStoreEnum.Type.mongo
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
        )

    def build_absolute_url(self, path=None):
        """ Build absolute URL pointing to test server.
        :param path: Path to append to the URL
        """
        url = TEST_SERVER_HOST

        if path:
            url += path

        return url

    def assertValidResponseCourse(self, data, course):
        """ Determines if the given response data (dict) matches the specified course. """

        course_key = course.id
        self.assertEqual(data['id'], unicode(course_key))
        self.assertEqual(data['name'], course.display_name)
        self.assertEqual(data['course'], course_key.course)
        self.assertEqual(data['org'], course_key.org)
        self.assertEqual(data['run'], course_key.run)

        uri = self.build_absolute_url(
            reverse('course_structure_api:v0:detail', kwargs={'course_id': unicode(course_key)}))
        self.assertEqual(data['uri'], uri)

    def http_get(self, uri, **headers):
        """Submit an HTTP GET request"""

        default_headers = {
            'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token
        }
        default_headers.update(headers)

        response = self.client.get(uri, follow=True, **default_headers)
        return response

143 144 145 146 147 148 149 150
    def http_get_for_course(self, course_id=None, **headers):
        """Submit an HTTP GET request to the view for the given course"""

        return self.http_get(
            reverse(self.view, kwargs={'course_id': course_id or self.course_id}),
            **headers
        )

151 152 153 154 155 156 157 158 159 160 161 162 163
    def test_not_authenticated(self):
        """
        Verify that access is denied to non-authenticated users.
        """
        raise NotImplementedError

    def test_not_authorized(self):
        """
        Verify that access is denied to non-authorized users.
        """
        raise NotImplementedError


164
class CourseDetailTestMixin(object):
165 166 167
    """
    Mixin for views utilizing only the course_id kwarg.
    """
168
    view_supports_debug_mode = True
169 170 171 172 173

    def test_get_invalid_course(self):
        """
        The view should return a 404 if the course ID is invalid.
        """
174
        response = self.http_get_for_course(self.invalid_course_id)
175 176 177 178 179 180
        self.assertEqual(response.status_code, 404)

    def test_get(self):
        """
        The view should return a 200 if the course ID is valid.
        """
181
        response = self.http_get_for_course()
182 183 184 185 186 187
        self.assertEqual(response.status_code, 200)

        # Return the response so child classes do not have to repeat the request.
        return response

    def test_not_authenticated(self):
188
        """ The view should return HTTP status 401 if no user is authenticated. """
189
        # HTTP 401 should be returned if the user is not authenticated.
190
        response = self.http_get_for_course(HTTP_AUTHORIZATION=None)
191 192 193 194 195 196 197 198
        self.assertEqual(response.status_code, 401)

    def test_not_authorized(self):
        user = StaffFactory(course_key=self.course.id)
        access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
        auth_header = 'Bearer ' + access_token

        # Access should be granted if the proper access token is supplied.
199
        response = self.http_get_for_course(HTTP_AUTHORIZATION=auth_header)
200 201 202
        self.assertEqual(response.status_code, 200)

        # Access should be denied if the user is not course staff.
203 204
        response = self.http_get_for_course(course_id=unicode(self.empty_course.id), HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 404)
205 206


207
class CourseListTests(CourseViewTestsMixin, SharedModuleStoreTestCase):
208 209
    view = 'course_structure_api:v0:list'

210 211 212 213 214
    @classmethod
    def setUpClass(cls):
        super(CourseListTests, cls).setUpClass()
        cls.create_course_data()

215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
    def test_get(self):
        """
        The view should return a list of all courses.
        """
        response = self.http_get(reverse(self.view))
        self.assertEqual(response.status_code, 200)
        data = response.data
        courses = data['results']
        self.assertEqual(len(courses), 2)
        self.assertEqual(data['count'], 2)
        self.assertEqual(data['num_pages'], 1)

        self.assertValidResponseCourse(courses[0], self.empty_course)
        self.assertValidResponseCourse(courses[1], self.course)

    def test_get_with_pagination(self):
        """
        The view should return a paginated list of courses.
        """
        url = "{}?page_size=1".format(reverse(self.view))
        response = self.http_get(url)
        self.assertEqual(response.status_code, 200)

        courses = response.data['results']
        self.assertEqual(len(courses), 1)
        self.assertValidResponseCourse(courses[0], self.empty_course)

    def test_get_filtering(self):
        """
        The view should return a list of details for the specified courses.
        """
        url = "{}?course_id={}".format(reverse(self.view), self.course_id)
        response = self.http_get(url)
        self.assertEqual(response.status_code, 200)

        courses = response.data['results']
        self.assertEqual(len(courses), 1)
        self.assertValidResponseCourse(courses[0], self.course)

    def test_not_authenticated(self):
        response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=None)
        self.assertEqual(response.status_code, 401)

    def test_not_authorized(self):
        """
        Unauthorized users should get an empty list.
        """
        user = StaffFactory(course_key=self.course.id)
        access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
        auth_header = 'Bearer ' + access_token

        # Data should be returned if the user is authorized.
        response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)

        url = "{}?course_id={}".format(reverse(self.view), self.course_id)
        response = self.http_get(url, HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)
        data = response.data['results']
        self.assertEqual(len(data), 1)
        self.assertEqual(data[0]['name'], self.course.display_name)

        # The view should return an empty list if the user cannot access any courses.
        url = "{}?course_id={}".format(reverse(self.view), unicode(self.empty_course.id))
        response = self.http_get(url, HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)
        self.assertDictContainsSubset({'count': 0, u'results': []}, response.data)

283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300
    def test_course_error(self):
        """
        Ensure the view still returns results even if get_courses() returns an ErrorDescriptor. The ErrorDescriptor
        should be filtered out.
        """

        error_descriptor = ErrorDescriptor.from_xml(
            '<course></course>',
            get_test_system(),
            CourseLocationManager(CourseLocator(org='org', course='course', run='run')),
            None
        )

        descriptors = [error_descriptor, self.empty_course, self.course]

        with patch('xmodule.modulestore.mixed.MixedModuleStore.get_courses', Mock(return_value=descriptors)):
            self.test_get()

301

302
class CourseDetailTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
303 304
    view = 'course_structure_api:v0:detail'

305 306 307 308 309
    @classmethod
    def setUpClass(cls):
        super(CourseDetailTests, cls).setUpClass()
        cls.create_course_data()

310 311 312 313 314
    def test_get(self):
        response = super(CourseDetailTests, self).test_get()
        self.assertValidResponseCourse(response.data, self.course)


315
class CourseStructureTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
316 317
    view = 'course_structure_api:v0:structure'

318 319 320 321 322
    @classmethod
    def setUpClass(cls):
        super(CourseStructureTests, cls).setUpClass()
        cls.create_course_data()

323 324 325 326
    def setUp(self):
        super(CourseStructureTests, self).setUp()

        # Ensure course structure exists for the course
327
        update_course_structure(unicode(self.course.id))
328 329 330 331 332 333 334 335 336 337

    def test_get(self):
        """
        If the course structure exists in the database, the view should return the data. Otherwise, the view should
        initiate an asynchronous course structure generation and return a 503.
        """

        # Attempt to retrieve data for a course without stored structure
        CourseStructure.objects.all().delete()
        self.assertFalse(CourseStructure.objects.filter(course_id=self.course.id).exists())
338
        response = self.http_get_for_course()
339 340 341 342 343
        self.assertEqual(response.status_code, 503)
        self.assertEqual(response['Retry-After'], '120')

        # Course structure generation shouldn't take long. Generate the data and try again.
        self.assertTrue(CourseStructure.objects.filter(course_id=self.course.id).exists())
344
        response = self.http_get_for_course()
345 346 347 348 349 350 351 352 353
        self.assertEqual(response.status_code, 200)

        blocks = {}

        def add_block(xblock):
            children = xblock.get_children()
            blocks[unicode(xblock.location)] = {
                u'id': unicode(xblock.location),
                u'type': xblock.category,
354
                u'parent': None,
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
                u'display_name': xblock.display_name,
                u'format': xblock.format,
                u'graded': xblock.graded,
                u'children': [unicode(child.location) for child in children]
            }

            for child in children:
                add_block(child)

        course = self.store.get_course(self.course.id, depth=None)
        add_block(course)

        expected = {
            u'root': unicode(self.course.location),
            u'blocks': blocks
        }

        self.maxDiff = None
        self.assertDictEqual(response.data, expected)


376
class CourseGradingPolicyTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
377 378
    view = 'course_structure_api:v0:grading_policy'

379 380 381 382 383
    @classmethod
    def setUpClass(cls):
        super(CourseGradingPolicyTests, cls).setUpClass()
        cls.create_course_data()

384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404
    def test_get(self):
        """
        The view should return grading policy for a course.
        """
        response = super(CourseGradingPolicyTests, self).test_get()

        expected = [
            {
                "count": 24,
                "weight": 0.2,
                "assignment_type": "Homework",
                "dropped": 0
            },
            {
                "count": 4,
                "weight": 0.8,
                "assignment_type": "Exam",
                "dropped": 0
            }
        ]
        self.assertListEqual(response.data, expected)
405 406


407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
class CourseGradingPolicyMissingFieldsTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
    view = 'course_structure_api:v0:grading_policy'

    # Update the raw grader to have missing keys
    raw_grader = [
        {
            "min_count": 24,
            "weight": 0.2,
            "type": "Homework",
            "drop_count": 0,
            "short_label": "HW"
        },
        {
            # Deleted "min_count" key
            "weight": 0.8,
            "type": "Exam",
            "drop_count": 0,
            "short_label": "Exam"
        }
    ]

    @classmethod
    def setUpClass(cls):
        super(CourseGradingPolicyMissingFieldsTests, cls).setUpClass()
        cls.create_course_data()

    def test_get(self):
        """
        The view should return grading policy for a course.
        """
        response = super(CourseGradingPolicyMissingFieldsTests, self).test_get()

        expected = [
            {
                "count": 24,
                "weight": 0.2,
                "assignment_type": "Homework",
                "dropped": 0
            },
            {
                "count": None,
                "weight": 0.8,
                "assignment_type": "Exam",
                "dropped": 0
            }
        ]
        self.assertListEqual(response.data, expected)