diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 2b5a33b2ac..1cd8cf8903 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -138,11 +138,11 @@ class BaseTestXmodule(ModuleStoreTestCase): self.setup_course() self.initialize_module(metadata=self.METADATA, data=self.DATA) - def get_url(self, dispatch): + def get_url(self, dispatch, handler_name='xmodule_handler'): """Return item url with dispatch.""" return reverse( 'xblock_handler', - args=(str(self.course.id), quote_slashes(self.item_url), 'xmodule_handler', dispatch) + args=(str(self.course.id), quote_slashes(self.item_url), handler_name, dispatch) ) diff --git a/lms/djangoapps/courseware/tests/test_word_cloud.py b/lms/djangoapps/courseware/tests/test_word_cloud.py index 06217628cb..8e225e1433 100644 --- a/lms/djangoapps/courseware/tests/test_word_cloud.py +++ b/lms/djangoapps/courseware/tests/test_word_cloud.py @@ -1,23 +1,40 @@ """Word cloud integration tests using mongo modulestore.""" - +import importlib +import json +import re +from operator import itemgetter +from unittest.mock import patch +from uuid import UUID import pytest +from django.conf import settings +from django.test import override_settings +from xblock import plugin -import json -from operator import itemgetter - +from common.djangoapps.student.tests.factories import RequestFactoryNoCsrf +from xmodule import word_cloud_block # noinspection PyUnresolvedReferences -from xmodule.tests.helpers import override_descriptor_system # pylint: disable=unused-import +from xmodule.tests.helpers import override_descriptor_system, mock_render_template # pylint: disable=unused-import from xmodule.x_module import STUDENT_VIEW - from .helpers import BaseTestXmodule @pytest.mark.usefixtures("override_descriptor_system") -class TestWordCloud(BaseTestXmodule): +class _TestWordCloudBase(BaseTestXmodule): """Integration test for Word Cloud Block.""" + __test__ = False CATEGORY = "word_cloud" + @classmethod + def setUpClass(cls): + super().setUpClass() + plugin.PLUGIN_CACHE = {} + importlib.reload(word_cloud_block) + + def setUp(self): + super().setUp() + self.request_factory = RequestFactoryNoCsrf() + def _get_users_state(self): """Return current state for each user: @@ -27,7 +44,18 @@ class TestWordCloud(BaseTestXmodule): users_state = {} for user in self.users: - response = self.clients[user.username].post(self.get_url('get_state')) + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # The extracted Word Cloud XBlock uses @XBlock.json_handler, which expects a different + # request format and url pattern + handler_url = self.get_url('', handler_name='handle_get_state') + response = self.clients[user.username].post( + handler_url, + data=json.dumps({}), + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + else: + response = self.clients[user.username].post(self.get_url('get_state')) users_state[user.username] = json.loads(response.content.decode('utf-8')) return users_state @@ -40,11 +68,22 @@ class TestWordCloud(BaseTestXmodule): users_state = {} for user in self.users: - response = self.clients[user.username].post( - self.get_url('submit'), - {'student_words[]': words}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest' - ) + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # The extracted Word Cloud XBlock uses @XBlock.json_handler, which expects a different + # request format and url pattern + handler_url = self.get_url('', handler_name='handle_submit_state') + response = self.clients[user.username].post( + handler_url, + data=json.dumps({'student_words': words}), + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + ) + else: + response = self.clients[user.username].post( + self.get_url('submit'), + {'student_words[]': words}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest' + ) users_state[user.username] = json.loads(response.content.decode('utf-8')) return users_state @@ -52,7 +91,6 @@ class TestWordCloud(BaseTestXmodule): def _check_response(self, response_contents, correct_jsons): """Utility function that compares correct and real responses.""" for username, content in response_contents.items(): - # Used in debugger for comparing objects. # self.maxDiff = None @@ -120,7 +158,6 @@ class TestWordCloud(BaseTestXmodule): correct_state = {} for index, user in enumerate(self.users): - correct_state[user.username] = { 'status': 'success', 'submitted': True, @@ -202,6 +239,14 @@ class TestWordCloud(BaseTestXmodule): for user in self.users } + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # The extracted Word Cloud XBlock uses @XBlock.json_handler to handle AJAX requests, + # which automatically returns a 404 for unknown requests, so there's no need to test + # the incorrect dispatch case in this scenario. + for username, response in responses.items(): + self.assertEqual(response.status_code, 404) + return + status_codes = {response.status_code for response in responses.values()} assert status_codes.pop() == 200 @@ -214,19 +259,44 @@ class TestWordCloud(BaseTestXmodule): } ) - def test_word_cloud_constructor(self): + @patch('xblock.utils.resources.ResourceLoader.render_django_template', side_effect=mock_render_template) + def test_word_cloud_constructor(self, mock_render_django_template): """ Make sure that all parameters extracted correctly from xml. """ fragment = self.runtime.render(self.block, STUDENT_VIEW) expected_context = { - 'ajax_url': self.block.ajax_url, 'display_name': self.block.display_name, 'instructions': self.block.instructions, - 'element_class': self.block.location.block_type, - 'element_id': self.block.location.html_id(), + 'element_class': self.block.scope_ids.block_type, 'num_inputs': 5, # default value 'submitted': False, # default value, } - assert fragment.content == self.runtime.render_template('word_cloud.html', expected_context) + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # If `USE_EXTRACTED_WORD_CLOUD_BLOCK` is enabled, the `expected_context` will be different + # because in the extracted Word Cloud XBlock, the expected context: + # - contains `range_num_inputs` + # - uses `UUID` for `element_id` instead of `html_id()` + # - does not include `ajax_url` since it uses the `@XBlock.json_handler` decorator for AJAX requests + expected_context['range_num_inputs'] = range(5) + uuid_str = re.search(r"UUID\('([a-f0-9\-]+)'\)", fragment.content).group(1) + expected_context['element_id'] = UUID(uuid_str) + mock_render_django_template.assert_called_once() + # Remove i18n service + fragment_content_clean = re.sub(r"\{.*?}", "{}", fragment.content) + assert fragment_content_clean == self.runtime.render_template('templates/word_cloud.html', expected_context) + else: + expected_context['ajax_url'] = self.block.ajax_url + expected_context['element_id'] = self.block.location.html_id() + assert fragment.content == self.runtime.render_template('word_cloud.html', expected_context) + + +@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=True) +class TestWordCloudExtracted(_TestWordCloudBase): + __test__ = True + + +@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=False) +class TestWordCloudBuiltIn(_TestWordCloudBase): + __test__ = True diff --git a/xmodule/tests/test_word_cloud.py b/xmodule/tests/test_word_cloud.py index 9fbd02a612..bc3f18a83c 100644 --- a/xmodule/tests/test_word_cloud.py +++ b/xmodule/tests/test_word_cloud.py @@ -1,38 +1,48 @@ """Test for Word Cloud Block functional logic.""" - import json +import os from unittest.mock import Mock +from django.conf import settings from django.test import TestCase +from django.test import override_settings from fs.memoryfs import MemoryFS from lxml import etree -from webob import Request from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from webob import Request from webob.multidict import MultiDict from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds -from xmodule.word_cloud_block import WordCloudBlock +from xmodule import word_cloud_block from . import get_test_descriptor_system, get_test_system -class WordCloudBlockTest(TestCase): +class _TestWordCloudBase(TestCase): """ Logic tests for Word Cloud Block. """ + __test__ = False - raw_field_data = { - 'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2}, - 'top_words': {'cat': 10, 'dog': 5, 'dad': 2}, - 'submitted': False, - 'display_name': 'Word Cloud Block', - 'instructions': 'Enter some random words that comes to your mind' - } + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.word_cloud_class = word_cloud_block.reset_class() + + def setUp(self): + super().setUp() + self.raw_field_data = { + 'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2}, + 'top_words': {'cat': 10, 'dog': 5, 'dad': 2}, + 'submitted': False, + 'display_name': 'Word Cloud Block', + 'instructions': 'Enter some random words that comes to your mind' + } def test_xml_import_export_cycle(self): """ Test the import export cycle. """ - runtime = get_test_descriptor_system() runtime.export_fs = MemoryFS() @@ -43,7 +53,11 @@ class WordCloudBlockTest(TestCase): olx_element = etree.fromstring(original_xml) runtime.id_generator = Mock() - block = WordCloudBlock.parse_xml(olx_element, runtime, None) + + def_id = runtime.id_generator.create_definition(olx_element.tag, olx_element.get('url_name')) + keys = ScopeIds(None, olx_element.tag, def_id, runtime.id_generator.create_usage(def_id)) + block = self.word_cloud_class.parse_xml(olx_element, runtime, keys) + block.location = BlockUsageLocator( CourseLocator('org', 'course', 'run', branch='revision'), 'word_cloud', 'block_id' ) @@ -54,38 +68,70 @@ class WordCloudBlockTest(TestCase): assert block.num_inputs == 3 assert block.num_top_words == 100 - node = etree.Element("unknown_root") - # This will export the olx to a separate file. - block.add_xml_to_node(node) + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # For extracted XBlocks, we need to manually export the XML definition to a file to properly test the + # import/export cycle. This is because extracted XBlocks use XBlock core's `add_xml_to_node` method, + # which does not export the XML to a file like `XmlMixin.add_xml_to_node` does. + filepath = 'word_cloud/block_id.xml' + runtime.export_fs.makedirs(os.path.dirname(filepath), recreate=True) + with runtime.export_fs.open(filepath, 'wb') as fileObj: + runtime.export_to_xml(block, fileObj) + else: + node = etree.Element("unknown_root") + # This will export the olx to a separate file. + block.add_xml_to_node(node) + with runtime.export_fs.open('word_cloud/block_id.xml') as f: exported_xml = f.read() + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # For extracted XBlocks, we need to remove the `xblock-family` attribute from the exported XML to ensure + # consistency with the original XML. + # This is because extracted XBlocks use the core XBlock's `add_xml_to_node` method, which includes this + # attribute, whereas `XmlMixin.add_xml_to_node` does not. + exported_xml_tree = etree.fromstring(exported_xml.encode('utf-8')) + etree.cleanup_namespaces(exported_xml_tree) + if 'xblock-family' in exported_xml_tree.attrib: + del exported_xml_tree.attrib['xblock-family'] + exported_xml = etree.tostring(exported_xml_tree, encoding='unicode', pretty_print=True) + assert exported_xml == original_xml def test_bad_ajax_request(self): """ Make sure that answer for incorrect request is error json. """ - module_system = get_test_system() - block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock()) + block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock()) - response = json.loads(block.handle_ajax('bad_dispatch', {})) - self.assertDictEqual(response, { - 'status': 'fail', - 'error': 'Unknown Command!' - }) + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # The extracted Word Cloud XBlock uses @XBlock.json_handler for handling AJAX requests, + # which requires a different way of method invocation. + with self.assertRaises(AttributeError) as context: + json.loads(block.bad_dispatch('bad_dispatch', {})) + self.assertIn("'WordCloudBlock' object has no attribute 'bad_dispatch'", str(context.exception)) + else: + response = json.loads(block.handle_ajax('bad_dispatch', {})) + self.assertDictEqual(response, { + 'status': 'fail', + 'error': 'Unknown Command!' + }) def test_good_ajax_request(self): """ Make sure that ajax request works correctly. """ - module_system = get_test_system() - block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock()) + block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock()) - post_data = MultiDict(('student_words[]', word) for word in ['cat', 'cat', 'dog', 'sun']) - response = json.loads(block.handle_ajax('submit', post_data)) + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # The extracted Word Cloud XBlock uses @XBlock.json_handler for handling AJAX requests. + # It expects a standard Python dictionary as POST data and returns a JSON object in response. + post_data = {'student_words': ['cat', 'cat', 'dog', 'sun']} + response = block.submit_state(post_data) + else: + post_data = MultiDict(('student_words[]', word) for word in ['cat', 'cat', 'dog', 'sun']) + response = json.loads(block.handle_ajax('submit', post_data)) assert response['status'] == 'success' assert response['submitted'] is True assert response['total_count'] == 22 @@ -109,9 +155,8 @@ class WordCloudBlockTest(TestCase): """ Test indexibility of Word Cloud """ - module_system = get_test_system() - block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock()) + block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock()) assert block.index_dictionary() ==\ {'content_type': 'Word Cloud', 'content': {'display_name': 'Word Cloud Block', @@ -128,13 +173,23 @@ class WordCloudBlockTest(TestCase): 'num_top_words': 10, 'display_student_percents': 'False', } + if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK: + # In the extracted Word Cloud XBlock, we use StudioEditableXBlockMixin.submit_studio_edits, + # which expects a different handler name and request JSON format. + handler_name = 'submit_studio_edits' + TEST_REQUEST_JSON = { + 'values': TEST_SUBMIT_DATA, + } + else: + handler_name = 'studio_submit' + TEST_REQUEST_JSON = TEST_SUBMIT_DATA module_system = get_test_system() - block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock()) - body = json.dumps(TEST_SUBMIT_DATA) + block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock()) + body = json.dumps(TEST_REQUEST_JSON) request = Request.blank('/') request.method = 'POST' request.body = body.encode('utf-8') - res = block.handle('studio_submit', request) + res = block.handle(handler_name, request) assert json.loads(res.body.decode('utf8')) == {'result': 'success'} assert block.display_name == TEST_SUBMIT_DATA['display_name'] @@ -142,3 +197,13 @@ class WordCloudBlockTest(TestCase): assert block.num_inputs == TEST_SUBMIT_DATA['num_inputs'] assert block.num_top_words == TEST_SUBMIT_DATA['num_top_words'] assert block.display_student_percents == (TEST_SUBMIT_DATA['display_student_percents'] == "True") + + +@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=True) +class TestWordCloudExtracted(_TestWordCloudBase): + __test__ = True + + +@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=False) +class TestWordCloudBuiltIn(_TestWordCloudBase): + __test__ = True diff --git a/xmodule/word_cloud_block.py b/xmodule/word_cloud_block.py index 37e82400df..b22ecf3b7a 100644 --- a/xmodule/word_cloud_block.py +++ b/xmodule/word_cloud_block.py @@ -316,8 +316,17 @@ class _BuiltInWordCloudBlock( # pylint: disable=abstract-method return xblock_body -WordCloudBlock = ( - _ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK - else _BuiltInWordCloudBlock -) +WordCloudBlock = None + + +def reset_class(): + """Reset class as per django settings flag""" + global WordCloudBlock + WordCloudBlock = ( + _ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK + else _BuiltInWordCloudBlock + ) + return WordCloudBlock + +reset_class() WordCloudBlock.__name__ = "WordCloudBlock"