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
"""
# pylint: disable=missing-docstring,invalid-name,maybe-no-member,attribute-defined-outside-init
from datetime import datetime
7
from mock import patch, Mock
8 9

from django.core.urlresolvers import reverse
10 11

from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
12
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
13 14
from opaque_keys.edx.locator import CourseLocator
from xmodule.error_module import ErrorDescriptor
Clinton Blackburn committed
15
from xmodule.modulestore import ModuleStoreEnum
16
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
Ned Batchelder committed
17
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
18 19
from xmodule.modulestore.xml import CourseLocationManager
from xmodule.tests import get_test_system
20 21

from courseware.tests.factories import GlobalStaffFactory, StaffFactory
22 23
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
24 25 26 27 28 29 30 31 32 33 34


TEST_SERVER_HOST = 'http://testserver'


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

35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
    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"
        }
    ]

52 53 54 55
    def setUp(self):
        super(CourseViewTestsMixin, self).setUp()
        self.create_user_and_access_token()

56
    def create_user(self):
57
        self.user = GlobalStaffFactory.create()
58 59 60

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

64 65 66
    @classmethod
    def create_course_data(cls):
        cls.invalid_course_id = 'foo/bar/baz'
67
        cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader)
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 100 101
        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(
102 103
            start=datetime(2014, 6, 16, 14, 30),
            end=datetime(2015, 1, 16),
Clinton Blackburn committed
104 105 106
            org="MTD",
            # Use mongo so that we can get a test with a SlashSeparatedCourseKey
            default_store=ModuleStoreEnum.Type.mongo
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 143 144
        )

    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

145 146 147 148 149 150 151 152
    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
        )

153 154 155 156 157 158 159 160 161 162 163 164 165
    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


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

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

    def test_get(self):
        """
        The view should return a 200 if the course ID is valid.
        """
183
        response = self.http_get_for_course()
184 185 186 187 188 189
        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):
190
        """ The view should return HTTP status 401 if no user is authenticated. """
191
        # HTTP 401 should be returned if the user is not authenticated.
192
        response = self.http_get_for_course(HTTP_AUTHORIZATION=None)
193 194 195 196 197 198 199 200
        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.
201
        response = self.http_get_for_course(HTTP_AUTHORIZATION=auth_header)
202 203 204
        self.assertEqual(response.status_code, 200)

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


209
class CourseListTests(CourseViewTestsMixin, SharedModuleStoreTestCase):
210 211
    view = 'course_structure_api:v0:list'

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

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 283 284
    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)

285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    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()

303

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

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

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


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

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

325 326 327 328
    def setUp(self):
        super(CourseStructureTests, self).setUp()

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

    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())
340
        response = self.http_get_for_course()
341 342 343 344 345
        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())
346
        response = self.http_get_for_course()
347 348 349 350 351 352 353 354 355
        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,
356
                u'parent': None,
357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
                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)


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

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

386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406
    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)
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 454 455
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)