Files
edx-platform/lms/lib/xblock/test/test_mixin.py
2021-03-22 17:51:23 +05:00

485 lines
21 KiB
Python

# lint-amnesty, pylint: disable=django-not-configured
"""
Tests of the LMS XBlock Mixin
"""
import ddt
from xblock.validation import ValidationMessage
from lms.djangoapps.lms_xblock.mixin import (
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT,
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
INVALID_USER_PARTITION_VALIDATION_UNIT,
NONSENSICAL_ACCESS_RESTRICTION
)
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyCourseFactory
from xmodule.partitions.partitions import Group, UserPartition
class LmsXBlockMixinTestCase(ModuleStoreTestCase):
"""
Base class for XBlock mixin tests cases. A simple course with a single user partition is created
in setUp for all subclasses to use.
"""
def build_course(self):
"""
Build up a course tree with a UserPartition.
"""
# pylint: disable=attribute-defined-outside-init
self.user_partition = UserPartition(
0,
'first_partition',
'First Partition',
[
Group(0, 'alpha'),
Group(1, 'beta')
]
)
self.group1 = self.user_partition.groups[0]
self.group2 = self.user_partition.groups[1]
self.course = CourseFactory.create(user_partitions=[self.user_partition])
section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
subsection = ItemFactory.create(parent=section, category='sequential', display_name='Test Subsection')
vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='Test Unit')
video = ItemFactory.create(parent=vertical, category='video', display_name='Test Video 1')
split_test = ItemFactory.create(parent=vertical, category='split_test', display_name='Test Content Experiment')
child_vertical = ItemFactory.create(parent=split_test, category='vertical')
child_html_module = ItemFactory.create(parent=child_vertical, category='html')
self.section_location = section.location
self.subsection_location = subsection.location
self.vertical_location = vertical.location
self.video_location = video.location
self.split_test_location = split_test.location
self.child_vertical_location = child_vertical.location
self.child_html_module_location = child_html_module.location
def set_group_access(self, block_location, access_dict):
"""
Sets the group_access dict on the block referenced by block_location.
"""
block = self.store.get_item(block_location)
block.group_access = access_dict
self.store.update_item(block, 1)
class XBlockValidationTest(LmsXBlockMixinTestCase):
"""
Unit tests for XBlock validation
"""
def setUp(self):
super().setUp()
self.build_course()
def verify_validation_message(self, message, expected_message, expected_message_type):
"""
Verify that the validation message has the expected validation message and type.
"""
assert message.text == expected_message
assert message.type == expected_message_type
def test_validate_full_group_access(self):
"""
Test the validation messages produced for an xblock with full group access.
"""
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 0
def test_validate_restricted_group_access(self):
"""
Test the validation messages produced for an xblock with a valid group access restriction
"""
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, self.group2.id]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 0
def test_validate_invalid_user_partitions(self):
"""
Test the validation messages produced for a component referring to non-existent user partitions.
"""
self.set_group_access(self.video_location, {999: [self.group1.id]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
# Now add a second invalid user partition and validate again.
# Note that even though there are two invalid configurations,
# only a single error message will be returned.
self.set_group_access(self.video_location, {998: [self.group2.id]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
def test_validate_invalid_user_partitions_unit(self):
"""
Test the validation messages produced for a unit referring to non-existent user partitions.
"""
self.set_group_access(self.vertical_location, {999: [self.group1.id]})
validation = self.store.get_item(self.vertical_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION_UNIT,
ValidationMessage.ERROR,
)
# Now add a second invalid user partition and validate again.
# Note that even though there are two invalid configurations,
# only a single error message will be returned.
self.set_group_access(self.vertical_location, {998: [self.group2.id]})
validation = self.store.get_item(self.vertical_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_VALIDATION_UNIT,
ValidationMessage.ERROR,
)
def test_validate_invalid_groups(self):
"""
Test the validation messages produced for an xblock referring to non-existent groups.
"""
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, 999]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
# Now try again with two invalid group ids
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, 998, 999]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
def test_validate_nonsensical_access_for_split_test_children(self):
"""
Test the validation messages produced for components within
a content group experiment (also known as a split_test).
Ensures that children of split_test xblocks only validate
their access settings off the parent, rather than any
grandparent.
"""
# Test that no validation message is displayed on split_test child when child agrees with parent
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.split_test_location, {self.user_partition.id: [self.group2.id]})
self.set_group_access(self.child_vertical_location, {self.user_partition.id: [self.group2.id]})
self.set_group_access(self.child_html_module_location, {self.user_partition.id: [self.group2.id]})
validation = self.store.get_item(self.child_html_module_location).validate()
assert len(validation.messages) == 0
# Test that a validation message is displayed on split_test child when the child contradicts the parent,
# even though the child agrees with the grandparent unit.
self.set_group_access(self.child_html_module_location, {self.user_partition.id: [self.group1.id]})
validation = self.store.get_item(self.child_html_module_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
def test_validate_invalid_groups_for_unit(self):
"""
Test the validation messages produced for a unit-level xblock referring to non-existent groups.
"""
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id, 999]})
validation = self.store.get_item(self.vertical_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT,
ValidationMessage.ERROR,
)
def test_validate_nonsensical_access_restriction(self):
"""
Test the validation messages produced for a component whose
access settings contradict the unit level access.
"""
# Test that there is no validation message for non-contradicting access restrictions
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 0
# Now try again with opposing access restrictions
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group2.id]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
# Now try again when the component restricts access to additional groups that the unit does not
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, self.group2.id]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
# Now try again when the component tries to allow access to all learners and staff
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 1
self.verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
def test_nonsensical_access_restriction_does_not_override(self):
"""
Test that the validation message produced for a component
whose access settings contradict the unit level access don't
override other messages but add on to them.
"""
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
self.set_group_access(self.video_location, {self.user_partition.id: [self.group2.id, 999]})
validation = self.store.get_item(self.video_location).validate()
assert len(validation.messages) == 2
self.verify_validation_message(
validation.messages[0],
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
ValidationMessage.ERROR,
)
self.verify_validation_message(
validation.messages[1],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
class OpenAssessmentBlockMixinTestCase(ModuleStoreTestCase):
"""
Tests for OpenAssessmentBlock mixin.
"""
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
self.open_assessment = ItemFactory.create(
parent=self.section,
category="openassessment",
display_name="untitled",
)
def test_has_score(self):
"""
Test has_score is true for ora2 problems.
"""
assert self.open_assessment.has_score
@ddt.ddt
class XBlockGetParentTest(LmsXBlockMixinTestCase):
"""
Test that XBlock.get_parent returns correct results with each modulestore
backend.
"""
MODULESTORE = TEST_DATA_MIXED_MODULESTORE
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_parents(self, modulestore_type):
with self.store.default_store(modulestore_type):
# setting up our own local course tree here, since it needs to be
# created with the correct modulestore type.
course_key = ToyCourseFactory.create().id
course = self.store.get_course(course_key)
assert course.get_parent() is None
def recurse(parent):
"""
Descend the course tree and ensure the result of get_parent()
is the expected one.
"""
visited = []
for child in parent.get_children():
assert parent.location == child.get_parent().location
visited.append(child)
visited += recurse(child)
return visited
visited = recurse(course)
assert len(visited) == 28
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_parents_draft_content(self, modulestore_type):
# move the video to the new vertical
with self.store.default_store(modulestore_type):
self.build_course()
subsection = self.store.get_item(self.subsection_location)
new_vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='New Test Unit')
child_to_move_location = self.video_location.for_branch(None)
new_parent_location = new_vertical.location.for_branch(None)
old_parent_location = self.vertical_location.for_branch(None)
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
assert self.course.get_parent() is None
with self.store.bulk_operations(self.course.id):
user_id = ModuleStoreEnum.UserID.test
old_parent = self.store.get_item(old_parent_location)
old_parent.children.remove(child_to_move_location)
self.store.update_item(old_parent, user_id)
new_parent = self.store.get_item(new_parent_location)
new_parent.children.append(child_to_move_location)
self.store.update_item(new_parent, user_id)
# re-fetch video from draft store
video = self.store.get_item(child_to_move_location)
assert new_parent_location == video.get_parent().location
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
# re-fetch video from published store
video = self.store.get_item(child_to_move_location)
assert old_parent_location == video.get_parent().location.for_branch(None)
class RenamedTuple(tuple):
"""
This class is only used to allow overriding __name__ on the tuples passed
through ddt, in order to have the generated test names make sense.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def ddt_named(parent, child):
"""
Helper to get more readable dynamically-generated test names from ddt.
"""
args = RenamedTuple([parent, child])
args.__name__ = f'parent_{parent}_child_{child}' # pylint: disable=attribute-defined-outside-init
return args
@ddt.ddt
class XBlockMergedGroupAccessTest(LmsXBlockMixinTestCase):
"""
Test that XBlock.merged_group_access is computed correctly according to
our access control rules.
"""
PARTITION_1 = 1
PARTITION_1_GROUP_1 = 11
PARTITION_1_GROUP_2 = 12
PARTITION_2 = 2
PARTITION_2_GROUP_1 = 21
PARTITION_2_GROUP_2 = 22
PARENT_CHILD_PAIRS = (
ddt_named('section_location', 'subsection_location'),
ddt_named('section_location', 'vertical_location'),
ddt_named('section_location', 'video_location'),
ddt_named('subsection_location', 'vertical_location'),
ddt_named('subsection_location', 'video_location'),
)
def setUp(self):
super().setUp()
self.build_course()
def verify_group_access(self, block_location, expected_dict):
"""
Verify the expected value for the block's group_access.
"""
block = self.store.get_item(block_location)
assert block.merged_group_access == expected_dict
@ddt.data(*PARENT_CHILD_PAIRS)
@ddt.unpack
def test_intersecting_groups(self, parent, child):
"""
When merging group_access on a block, the resulting group IDs for each
partition is the intersection of the group IDs defined for that
partition across all ancestor blocks (including this one).
"""
parent_block = getattr(self, parent)
child_block = getattr(self, child)
self.set_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1, self.PARTITION_1_GROUP_2]})
self.set_group_access(child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
self.verify_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1, self.PARTITION_1_GROUP_2]})
self.verify_group_access(child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
@ddt.data(*PARENT_CHILD_PAIRS)
@ddt.unpack
def test_disjoint_groups(self, parent, child):
"""
When merging group_access on a block, if the intersection of group IDs
for a partition is empty, the merged value for that partition is False.
"""
parent_block = getattr(self, parent)
child_block = getattr(self, child)
self.set_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
self.set_group_access(child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
self.verify_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
self.verify_group_access(child_block, {self.PARTITION_1: False})
def test_disjoint_groups_no_override(self):
"""
Special case of the above test - ensures that `False` propagates down
to the block being queried even if blocks further down in the hierarchy
try to override it.
"""
self.set_group_access(self.section_location, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
self.set_group_access(self.subsection_location, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
self.set_group_access(
self.vertical_location, {self.PARTITION_1: [self.PARTITION_1_GROUP_1, self.PARTITION_1_GROUP_2]}
)
self.verify_group_access(self.vertical_location, {self.PARTITION_1: False})
self.verify_group_access(self.video_location, {self.PARTITION_1: False})
@ddt.data(*PARENT_CHILD_PAIRS)
@ddt.unpack
def test_union_partitions(self, parent, child):
"""
When merging group_access on a block, the result's keys (partitions)
are the union of all partitions specified across all ancestor blocks
(including this one).
"""
parent_block = getattr(self, parent)
child_block = getattr(self, child)
self.set_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
self.set_group_access(child_block, {self.PARTITION_2: [self.PARTITION_1_GROUP_2]})
self.verify_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
self.verify_group_access(
child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1], self.PARTITION_2: [self.PARTITION_1_GROUP_2]}
)