From cf5694e6c26de29fd27b165cfb382f8c9a248885 Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:04:57 +0300 Subject: [PATCH] test: [AXM-636] Cover Offline Mode API with unit tests (#2577) * test: [AXM-636] Add some tests to cover Offline Mode * test: [AXM-636] Covered the all Ofline Mode app with tests * test: [AXM-636] Add test to Blocks Info API * style: [AXM-636] Improve code style * style: [AXM-636] Ivan's code improving Co-authored-by: Ivan Niedielnitsev <81557788+NiedielnitsevIvan@users.noreply.github.com> * refactor: [AXM-636] Refactor tests, add new tests * test: [AXM-653] Fix tests, refactor, add new functional tests * fix: [AXM-653] Commit forgotten base file * test: [AXM-653] Fix old tests, add new tests --------- Co-authored-by: Ivan Niedielnitsev <81557788+NiedielnitsevIvan@users.noreply.github.com> --- .../tests/test_course_info_views.py | 87 +++- .../offline_mode/assets_management.py | 3 +- openedx/features/offline_mode/tests/base.py | 47 +++ .../tests/test_assets_management.py | 390 ++++++++++++++++++ .../tests/test_html_manipulator.py | 163 ++++++++ .../offline_mode/tests/test_renderer.py | 31 ++ .../tests/test_storage_management.py | 268 ++++++++++++ .../features/offline_mode/tests/test_tasks.py | 141 +++++++ 8 files changed, 1126 insertions(+), 4 deletions(-) create mode 100644 openedx/features/offline_mode/tests/base.py create mode 100644 openedx/features/offline_mode/tests/test_assets_management.py create mode 100644 openedx/features/offline_mode/tests/test_html_manipulator.py create mode 100644 openedx/features/offline_mode/tests/test_renderer.py create mode 100644 openedx/features/offline_mode/tests/test_storage_management.py create mode 100644 openedx/features/offline_mode/tests/test_tasks.py diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_views.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py index ca4750d121bf..091ce38561fb 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_views.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py @@ -1,7 +1,7 @@ """ Tests for course_info """ -from unittest.mock import patch +from unittest.mock import MagicMock, patch import ddt @@ -17,11 +17,13 @@ from common.djangoapps.student.tests.factories import UserFactory # pylint: disable=unused-import from common.djangoapps.util.course import get_link_for_about_page from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin -from lms.djangoapps.mobile_api.utils import API_V1, API_V05 +from lms.djangoapps.mobile_api.utils import API_V05, API_V1, API_V2, API_V3, API_V4 from lms.djangoapps.mobile_api.course_info.views import BlocksInfoInCourseView from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_experience import ENABLE_COURSE_GOALS +from openedx.features.offline_mode.constants import DEFAULT_OFFLINE_SUPPORTED_XBLOCKS +from openedx.features.offline_mode.toggles import ENABLE_OFFLINE_MODE from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -450,3 +452,84 @@ def test_extend_sequential_info_with_assignment_progress_for_other_types(self, b self.assertEqual(response.status_code, status.HTTP_200_OK) for block_info in response.data['blocks'].values(): self.assertNotEqual('assignment_progress', block_info) + + @patch('lms.djangoapps.mobile_api.course_info.views.default_storage') + @patch('lms.djangoapps.mobile_api.course_info.views.get_offline_block_content_path') + @patch('lms.djangoapps.mobile_api.course_info.views.is_offline_mode_enabled') + def test_extend_block_info_with_offline_data( + self, + is_offline_mode_enabled_mock: MagicMock, + get_offline_block_content_path_mock: MagicMock, + default_storage_mock: MagicMock, + ) -> None: + url = reverse('blocks_info_in_course', kwargs={'api_version': API_V4}) + offline_content_path_mock = '/offline_content_path_mock/' + created_time_mock = 'created_time_mock' + size_mock = 'size_mock' + get_offline_block_content_path_mock.return_value = offline_content_path_mock + default_storage_mock.get_modified_time.return_value = created_time_mock + default_storage_mock.size.return_value = size_mock + + expected_offline_download_data = { + 'file_url': offline_content_path_mock, + 'last_modified': created_time_mock, + 'file_size': size_mock + } + + response = self.verify_response(url=url) + + is_offline_mode_enabled_mock.assert_called_once_with(self.course.course_id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertDictEqual(block_info['offline_download'], expected_offline_download_data) + + @patch('lms.djangoapps.mobile_api.course_info.views.is_offline_mode_enabled') + @ddt.data( + (API_V05, True), + (API_V05, False), + (API_V1, True), + (API_V1, False), + (API_V2, True), + (API_V2, False), + (API_V3, True), + (API_V3, False), + ) + @ddt.unpack + def test_not_extend_block_info_with_offline_data_for_version_less_v4_and_any_waffle_flag( + self, + api_version: str, + offline_mode_waffle_flag_mock: MagicMock, + is_offline_mode_enabled_mock: MagicMock, + ) -> None: + url = reverse('blocks_info_in_course', kwargs={'api_version': api_version}) + is_offline_mode_enabled_mock.return_value = offline_mode_waffle_flag_mock + + response = self.verify_response(url=url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertNotIn('offline_download', block_info) + + @override_waffle_flag(ENABLE_OFFLINE_MODE, active=True) + @patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets') + def test_create_offline_content_integration_test(self, save_mathjax_to_xblock_assets_mock: MagicMock) -> None: + UserFactory.create(username='offline_mode_worker', password='password', is_staff=True) + handle_course_published_url = reverse('offline_mode:handle_course_published') + self.client.login(username='offline_mode_worker', password='password') + + handler_response = self.client.post(handle_course_published_url, {'course_id': str(self.course.id)}) + self.assertEqual(handler_response.status_code, status.HTTP_200_OK) + + url = reverse('blocks_info_in_course', kwargs={'api_version': API_V4}) + + response = self.verify_response(url=url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + if block_type := block_info.get('type'): + if block_type in DEFAULT_OFFLINE_SUPPORTED_XBLOCKS: + expected_offline_content_url = f'/uploads/{self.course.id}/{block_info["block_id"]}.zip' + self.assertIn('offline_download', block_info) + self.assertIn('file_url', block_info['offline_download']) + self.assertIn('last_modified', block_info['offline_download']) + self.assertIn('file_size', block_info['offline_download']) + self.assertEqual(expected_offline_content_url, block_info['offline_download']['file_url']) diff --git a/openedx/features/offline_mode/assets_management.py b/openedx/features/offline_mode/assets_management.py index 225990449e5f..6e02a28ae5c8 100644 --- a/openedx/features/offline_mode/assets_management.py +++ b/openedx/features/offline_mode/assets_management.py @@ -39,7 +39,7 @@ def read_static_file(path): def save_asset_file(temp_dir, xblock, path, filename): """ - Saves an asset file to the default storage. + Saves an asset file to the temporary directory. If the filename contains a '/', it reads the static file directly from the file system. Otherwise, it fetches the asset from the AssetManager. @@ -89,7 +89,6 @@ def clean_outdated_xblock_files(xblock): base_path = block_storage_path(xblock) offline_zip_path = os.path.join(base_path, f'{xblock.location.block_id}.zip') - # Delete the 'offline_content.zip' file if it exists if default_storage.exists(offline_zip_path): default_storage.delete(offline_zip_path) log.info(f"Successfully deleted the file: {offline_zip_path}") diff --git a/openedx/features/offline_mode/tests/base.py b/openedx/features/offline_mode/tests/base.py new file mode 100644 index 000000000000..f8692400f267 --- /dev/null +++ b/openedx/features/offline_mode/tests/base.py @@ -0,0 +1,47 @@ +""" +Tests for the testing xBlock renderers for Offline Mode. +""" + +from xmodule.capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + + +class CourseForOfflineTestCase(ModuleStoreTestCase): + """ + Base class for creation course for Offline Mode testing. + """ + + def setUp(self): + super().setUp() + default_store = self.store.default_modulestore.get_modulestore_type() + with self.store.default_store(default_store): + self.course = CourseFactory.create( # lint-amnesty, pylint: disable=attribute-defined-outside-init + display_name='Offline Course', + org='RaccoonGang', + number='1', + run='2024', + ) + chapter = BlockFactory.create(parent=self.course, category='chapter') + problem_xml = MultipleChoiceResponseXMLFactory().build_xml( + question_text='The correct answer is Choice 2', + choices=[False, False, True, False], + choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3'] + ) + self.vertical_block = BlockFactory.create( # lint-amnesty, pylint: disable=attribute-defined-outside-init + parent_location=chapter.location, + category='vertical', + display_name='Vertical' + ) + self.html_block = BlockFactory.create( # lint-amnesty, pylint: disable=attribute-defined-outside-init + parent=self.vertical_block, + category='html', + display_name='HTML xblock for Offline', + data='
Test HTML Content
' + ) + self.problem_block = BlockFactory.create( # lint-amnesty, pylint: disable=attribute-defined-outside-init + parent=self.vertical_block, + category='problem', + display_name='Problem xblock for Offline', + data=problem_xml + ) diff --git a/openedx/features/offline_mode/tests/test_assets_management.py b/openedx/features/offline_mode/tests/test_assets_management.py new file mode 100644 index 000000000000..e386d10e2b57 --- /dev/null +++ b/openedx/features/offline_mode/tests/test_assets_management.py @@ -0,0 +1,390 @@ +""" +Tests for the testing utility functions for managing assets and files for Offline Mode. +""" + +import os + +from datetime import datetime +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from botocore.exceptions import ClientError +from django.conf import settings +from path import Path +from pytz import UTC + +from openedx.features.offline_mode.assets_management import ( + block_storage_path, + clean_outdated_xblock_files, + create_subdirectories_for_asset, + get_offline_block_content_path, + get_static_file_path, + is_modified, + save_asset_file, + save_mathjax_to_xblock_assets, +) +from openedx.features.offline_mode.constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH +from xmodule.modulestore.exceptions import ItemNotFoundError + + +class AssetsManagementTestCase(TestCase): + """ + Test case for the testing utility functions for managing assets and files. + """ + + def test_get_static_file_path(self) -> None: + relative_path_mock = 'relative_path_mock' + expected_result = Path(f'{settings.STATIC_ROOT}/{relative_path_mock}') + + result = get_static_file_path(relative_path_mock) + + self.assertEqual(result, expected_result) + + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.create_subdirectories_for_asset') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + @patch('openedx.features.offline_mode.assets_management.AssetManager.find') + @patch('openedx.features.offline_mode.assets_management.StaticContent.get_asset_key_from_path') + def test_save_asset_file_if_filename_contains_slash( + self, + get_asset_key_from_path_mock: MagicMock, + asset_manager_find_mock: MagicMock, + os_path_join_mock: MagicMock, + create_subdirectories_for_asset_mock: MagicMock, + context_manager_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + xblock_mock = Mock() + path_mock = 'path_mock' + filename_mock = 'assets/filename_mock' + + save_asset_file(temp_dir_mock, xblock_mock, path_mock, filename_mock) + + get_asset_key_from_path_mock.assert_called_once_with( + xblock_mock.location.course_key, filename_mock.split('/')[-1] + ) + asset_manager_find_mock.assert_called_once_with(get_asset_key_from_path_mock.return_value) + os_path_join_mock.assert_called_once_with(temp_dir_mock, filename_mock) + create_subdirectories_for_asset_mock.assert_called_once_with(os_path_join_mock.return_value) + context_manager_mock.assert_called_once_with(os_path_join_mock.return_value, 'wb') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + asset_manager_find_mock.return_value.data + ) + + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.create_subdirectories_for_asset') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + @patch('openedx.features.offline_mode.assets_management.read_static_file') + @patch('openedx.features.offline_mode.assets_management.get_static_file_path') + def test_save_asset_file_no_slash_in_filename( + self, + get_static_file_path_mock: MagicMock, + read_static_file_mock: MagicMock, + os_path_join_mock: MagicMock, + create_subdirectories_for_asset_mock: MagicMock, + context_manager_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + xblock_mock = Mock() + path_mock = 'path_mock' + filename_mock = 'filename_mock' + + save_asset_file(temp_dir_mock, xblock_mock, path_mock, filename_mock) + + get_static_file_path_mock.assert_called_once_with(filename_mock) + read_static_file_mock.assert_called_once_with(get_static_file_path_mock.return_value) + os_path_join_mock.assert_called_once_with( + temp_dir_mock, 'assets', filename_mock, + ) + create_subdirectories_for_asset_mock.assert_called_once_with(os_path_join_mock.return_value) + context_manager_mock.assert_called_once_with(os_path_join_mock.return_value, 'wb') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + read_static_file_mock.return_value + ) + + @patch('openedx.features.offline_mode.assets_management.log.warning') + @patch( + 'openedx.features.offline_mode.assets_management.get_static_file_path', side_effect=ItemNotFoundError + ) + def test_save_asset_file_can_not_find( + self, + get_static_file_path_mock: MagicMock, + log_warning_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + xblock_mock = Mock() + path_mock = 'path_mock' + filename_mock = 'filename_mock' + + save_asset_file(temp_dir_mock, xblock_mock, path_mock, filename_mock) + + get_static_file_path_mock.assert_called_once_with(filename_mock) + log_warning_mock.assert_called_once_with( + f'Asset not found: {filename_mock}, during offline content generation.' + ) + + @patch('openedx.features.offline_mode.assets_management.os') + def test_create_subdirectories_for_asset_subdirectories_does_not_exist(self, os_mock: MagicMock) -> None: + file_path_mock = 'file/path/mock/' + os_mock.path.exists.return_value = False + + expected_os_path_join_call_args_list = [ + call('/', 'file'), + call(os_mock.path.join.return_value, 'path'), + call(os_mock.path.join.return_value, 'mock'), + ] + expected_os_mock_mkdir_call_args_list = [ + call(os_mock.path.join.return_value), + call(os_mock.path.join.return_value), + call(os_mock.path.join.return_value), + ] + + create_subdirectories_for_asset(file_path_mock) + + self.assertListEqual(os_mock.path.join.call_args_list, expected_os_path_join_call_args_list) + self.assertListEqual(os_mock.mkdir.call_args_list, expected_os_mock_mkdir_call_args_list) + + @patch('openedx.features.offline_mode.assets_management.os') + def test_create_subdirectories_for_asset_subdirectories_exist(self, os_mock: MagicMock) -> None: + file_path_mock = 'file/path/mock/' + + expected_os_path_join_call_args_list = [ + call('/', 'file'), + call(os_mock.path.join.return_value, 'path'), + call(os_mock.path.join.return_value, 'mock'), + ] + + create_subdirectories_for_asset(file_path_mock) + + self.assertListEqual(os_mock.path.join.call_args_list, expected_os_path_join_call_args_list) + os_mock.mkdir.assert_not_called() + + @patch('openedx.features.offline_mode.assets_management.log') + @patch('openedx.features.offline_mode.assets_management.default_storage') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_clean_outdated_xblock_files_successful( + self, + block_storage_path_mock: MagicMock, + default_storage_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + default_storage_mock.exists.return_value = True + expected_offline_zip_path = os.path.join( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + + clean_outdated_xblock_files(xblock_mock) + + block_storage_path_mock.assert_called_once_with(xblock_mock) + default_storage_mock.exists.assert_called_once_with(expected_offline_zip_path) + default_storage_mock.delete.assert_called_once_with(expected_offline_zip_path) + logger_mock.info.assert_called_once_with(f'Successfully deleted the file: {expected_offline_zip_path}') + + @patch('openedx.features.offline_mode.assets_management.log') + @patch('openedx.features.offline_mode.assets_management.default_storage') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_clean_outdated_xblock_files_does_not_exist( + self, + block_storage_path_mock: MagicMock, + default_storage_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + default_storage_mock.exists.return_value = False + expected_offline_zip_path = os.path.join( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + + clean_outdated_xblock_files(xblock_mock) + + block_storage_path_mock.assert_called_once_with(xblock_mock) + default_storage_mock.exists.assert_called_once_with(expected_offline_zip_path) + default_storage_mock.delete.assert_not_called() + logger_mock.info.assert_not_called() + + @patch('openedx.features.offline_mode.assets_management.log.error') + @patch('openedx.features.offline_mode.assets_management.default_storage.exists') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_remove_old_files_client_error( + self, + block_storage_path_mock: MagicMock, + default_storage_exists_mock: MagicMock, + log_error_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + default_storage_exists_mock.side_effect = ClientError( + operation_name='InvalidKeyPair.Duplicate', error_response={ + 'Error': {'Code': 'Duplicate', 'Message': 'Invalid File Path'} + } + ) + expected_error_message = ( + 'An error occurred (Duplicate) when calling the InvalidKeyPair.Duplicate operation: Invalid File Path' + ) + + clean_outdated_xblock_files(xblock_mock) + block_storage_path_mock.assert_called_once_with(xblock_mock) + log_error_mock.assert_called_once_with( + f'Error occurred while deleting the files or directory: {expected_error_message}' + ) + + @patch('openedx.features.offline_mode.assets_management.default_storage.exists') + @patch('openedx.features.offline_mode.assets_management.os.path.join', return_value='offline_zip_path_mock') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_get_offline_block_content_path_offline_content_exists( + self, + block_storage_path_mock: MagicMock, + os_path_join_mock: MagicMock, + default_storage_exists_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + + result = get_offline_block_content_path(xblock_mock) + + block_storage_path_mock.assert_called_once_with(usage_key=xblock_mock.location) + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + default_storage_exists_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, 'offline_zip_path_mock') + + @patch('openedx.features.offline_mode.assets_management.default_storage.exists', return_value=False) + @patch('openedx.features.offline_mode.assets_management.os.path.join', return_value='offline_zip_path_mock') + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + def test_get_offline_block_content_path_does_not_exist( + self, + block_storage_path_mock: MagicMock, + os_path_join_mock: MagicMock, + default_storage_exists_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + + result = get_offline_block_content_path(xblock_mock) + + block_storage_path_mock.assert_called_once_with(usage_key=xblock_mock.location) + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip' + ) + default_storage_exists_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, None) + + def test_block_storage_path_exists(self) -> None: + xblock_mock = Mock(location=Mock(course_key='course_key_mock')) + + result = block_storage_path(xblock_mock) + + self.assertEqual(result, 'course_key_mock/') + + def test_block_storage_path_does_not_exists(self) -> None: + result = block_storage_path() + + self.assertEqual(result, '') + + @patch( + 'openedx.features.offline_mode.assets_management.default_storage.get_modified_time', + return_value=datetime(2024, 6, 12, tzinfo=UTC) + ) + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + def test_is_modified_true( + self, + os_path_join_mock: MagicMock, + block_storage_path_mock: MagicMock, + get_created_time_mock: MagicMock, + ) -> None: + xblock_mock = Mock(published_on=datetime(2024, 6, 13, tzinfo=UTC)) + + result = is_modified(xblock_mock) + + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip') + get_created_time_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, True) + + @patch( + 'openedx.features.offline_mode.assets_management.default_storage.get_modified_time', + return_value=datetime(2024, 6, 12, tzinfo=UTC) + ) + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + def test_is_modified_false( + self, + os_path_join_mock: MagicMock, + block_storage_path_mock: MagicMock, + get_created_time_mock: MagicMock, + ) -> None: + xblock_mock = Mock(published_on=datetime(2024, 6, 1, tzinfo=UTC)) + + result = is_modified(xblock_mock) + + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip') + get_created_time_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, False) + + @patch( + 'openedx.features.offline_mode.assets_management.default_storage.get_modified_time', + side_effect=OSError + ) + @patch('openedx.features.offline_mode.assets_management.block_storage_path') + @patch('openedx.features.offline_mode.assets_management.os.path.join') + def test_is_modified_os_error( + self, + os_path_join_mock: MagicMock, + block_storage_path_mock: MagicMock, + get_created_time_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + + result = is_modified(xblock_mock) + + os_path_join_mock.assert_called_once_with( + block_storage_path_mock.return_value, f'{xblock_mock.location.block_id}.zip') + get_created_time_mock.assert_called_once_with(os_path_join_mock.return_value) + self.assertEqual(result, True) + + @patch('openedx.features.offline_mode.assets_management.log.info') + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.requests.get') + @patch('openedx.features.offline_mode.assets_management.os') + def test_save_mathjax_to_xblock_assets_successfully( + self, + os_mock: MagicMock, + requests_get_mock: MagicMock, + context_manager_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + os_mock.path.exists.return_value = False + + save_mathjax_to_xblock_assets(temp_dir_mock) + + os_mock.path.join.assert_called_once_with(temp_dir_mock, MATHJAX_STATIC_PATH) + os_mock.path.exists.assert_called_once_with(os_mock.path.join.return_value) + requests_get_mock.assert_called_once_with(MATHJAX_CDN_URL) + context_manager_mock.assert_called_once_with(os_mock.path.join.return_value, 'wb') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + requests_get_mock.return_value.content + ) + logger_mock.assert_called_once_with(f'Successfully saved MathJax to {os_mock.path.join.return_value}') + + @patch('openedx.features.offline_mode.assets_management.log.info') + @patch('openedx.features.offline_mode.assets_management.open') + @patch('openedx.features.offline_mode.assets_management.requests.get') + @patch('openedx.features.offline_mode.assets_management.os') + def test_save_mathjax_to_xblock_assets_already_exists( + self, + os_mock: MagicMock, + requests_get_mock: MagicMock, + context_manager_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + temp_dir_mock = 'temp_dir_mock' + + save_mathjax_to_xblock_assets(temp_dir_mock) + + os_mock.path.join.assert_called_once_with(temp_dir_mock, MATHJAX_STATIC_PATH) + os_mock.path.exists.assert_called_once_with(os_mock.path.join.return_value) + requests_get_mock.assert_not_called() + context_manager_mock.assert_not_called() + logger_mock.assert_not_called() diff --git a/openedx/features/offline_mode/tests/test_html_manipulator.py b/openedx/features/offline_mode/tests/test_html_manipulator.py new file mode 100644 index 000000000000..cf280900177e --- /dev/null +++ b/openedx/features/offline_mode/tests/test_html_manipulator.py @@ -0,0 +1,163 @@ +""" +Tests for the testing methods for prepare HTML content for offline using. +""" + +from bs4 import BeautifulSoup +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from openedx.features.offline_mode.constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH +from openedx.features.offline_mode.html_manipulator import HtmlManipulator + + +class HtmlManipulatorTestCase(TestCase): + """ + Test case for the testing `HtmlManipulator` methods. + """ + + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_iframe') + @patch('openedx.features.offline_mode.html_manipulator.BeautifulSoup', return_value='soup_mock') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._copy_platform_fonts') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_external_links') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_mathjax_link') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_static_links') + @patch('openedx.features.offline_mode.html_manipulator.HtmlManipulator._replace_asset_links') + def test_process_html( + self, + replace_asset_links_mock: MagicMock, + replace_static_links_mock: MagicMock, + replace_mathjax_link_mock: MagicMock, + replace_external_links: MagicMock, + copy_platform_fonts: MagicMock, + beautiful_soup_mock: MagicMock, + replace_iframe_mock: MagicMock, + ) -> None: + html_data_mock = 'html_data_mock' + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + expected_result = 'soup_mock' + + result = html_manipulator.process_html() + + replace_asset_links_mock.assert_called_once_with() + replace_static_links_mock.assert_called_once_with() + replace_mathjax_link_mock.assert_called_once_with() + replace_external_links.assert_called_once_with() + copy_platform_fonts.assert_called_once_with() + beautiful_soup_mock.assert_called_once_with(html_manipulator.html_data, 'html.parser') + replace_iframe_mock.assert_called_once_with(beautiful_soup_mock.return_value) + self.assertEqual(result, expected_result) + + @patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets') + def test_replace_mathjax_link(self, save_mathjax_to_xblock_assets: MagicMock) -> None: + html_data_mock = f'' + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + + expected_html_data_after_replacing = f'' + + self.assertEqual(html_manipulator.html_data, html_data_mock) + + html_manipulator._replace_mathjax_link() # lint-amnesty, pylint: disable=protected-access + + save_mathjax_to_xblock_assets.assert_called_once_with(html_manipulator.temp_dir) + self.assertEqual(html_manipulator.html_data, expected_html_data_after_replacing) + + @patch('openedx.features.offline_mode.html_manipulator.save_asset_file') + def test_replace_static_links(self, save_asset_file_mock: MagicMock) -> None: + html_data_mock = '
' + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + + expected_html_data_after_replacing = ( + '' + ) + + self.assertEqual(html_manipulator.html_data, html_data_mock) + + html_manipulator._replace_static_links() # lint-amnesty, pylint: disable=protected-access + + save_asset_file_mock.assert_called_once_with( + html_manipulator.temp_dir, + html_manipulator.xblock, + '/static/images/professor-sandel.jpg', + 'images/professor-sandel.jpg', + ) + self.assertEqual(html_manipulator.html_data, expected_html_data_after_replacing) + + @patch('openedx.features.offline_mode.html_manipulator.save_asset_file') + def test_replace_asset_links(self, save_asset_file_mock: MagicMock) -> None: + html_data_mock = '/assets/courseware/v1/5b628a18f2ee3303081ffe4d6ab64ee4/asset-v1:OpenedX+DemoX+DemoCourse+type@asset+block/Pendleton_Sinking_Ship.jpeg' # lint-amnesty, pylint: disable=line-too-long + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + + expected_html_data_after_replacing = ( + 'assets/courseware/v1/5b628a18f2ee3303081ffe4d6ab64ee4/asset-v1:OpenedX+DemoX+DemoCourse+type@asset+block/Pendleton_Sinking_Ship.jpeg' # lint-amnesty, pylint: disable=line-too-long + ) + + self.assertEqual(html_manipulator.html_data, html_data_mock) + + html_manipulator._replace_asset_links() # lint-amnesty, pylint: disable=protected-access + + save_asset_file_mock.assert_called_once_with( + html_manipulator.temp_dir, + html_manipulator.xblock, + html_data_mock, + expected_html_data_after_replacing, + ) + self.assertEqual(html_manipulator.html_data, expected_html_data_after_replacing) + + def test_replace_iframe(self): + html_data_mock = """ + + """ + soup = BeautifulSoup(html_data_mock, 'html.parser') + expected_html_markup = """""" + + HtmlManipulator._replace_iframe(soup) # lint-amnesty, pylint: disable=protected-access + + self.assertEqual(f'{soup.find_all("p")[0]}', expected_html_markup) + + @patch('openedx.features.offline_mode.html_manipulator.save_external_file') + def test_replace_external_links(self, save_external_file_mock: MagicMock) -> None: + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_data_mock = """ + + + """ + + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + html_manipulator._replace_external_links() # lint-amnesty, pylint: disable=protected-access + + self.assertEqual(save_external_file_mock.call_count, 2) + + @patch('openedx.features.offline_mode.html_manipulator.uuid.uuid4') + @patch('openedx.features.offline_mode.html_manipulator.save_external_file') + def test_replace_external_link( + self, + save_external_file_mock: MagicMock, + uuid_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + temp_dir_mock = 'temp_dir_mock' + html_data_mock = 'html_data_mock' + external_url_mock = 'https://cdn.example.com/image.jpg' + uuid_result_mock = '123e4567-e89b-12d3-a456-426655440000' + uuid_mock.return_value = uuid_result_mock + mock_match = MagicMock() + mock_match.group.side_effect = [external_url_mock, 'jpg'] + + expected_result = 'assets/external/123e4567-e89b-12d3-a456-426655440000.jpg' + expected_save_external_file_args = [call(temp_dir_mock, external_url_mock, expected_result)] + + html_manipulator = HtmlManipulator(xblock_mock, html_data_mock, temp_dir_mock) + result = html_manipulator._replace_external_link(mock_match) # lint-amnesty, pylint: disable=protected-access + + self.assertListEqual(save_external_file_mock.call_args_list, expected_save_external_file_args) + self.assertEqual(result, expected_result) diff --git a/openedx/features/offline_mode/tests/test_renderer.py b/openedx/features/offline_mode/tests/test_renderer.py new file mode 100644 index 000000000000..b46d75431cf4 --- /dev/null +++ b/openedx/features/offline_mode/tests/test_renderer.py @@ -0,0 +1,31 @@ +""" +Tests for the testing xBlock renderers for Offline Mode. +""" + +from openedx.features.offline_mode.renderer import XBlockRenderer +from openedx.features.offline_mode.tests.base import CourseForOfflineTestCase + + +class XBlockRendererTestCase(CourseForOfflineTestCase): + """ + Test case for the testing `XBlockRenderer`. + """ + + def test_render_xblock_from_lms_html_block(self): + xblock_renderer = XBlockRenderer(str(self.html_block.location), user=self.user) + + result = xblock_renderer.render_xblock_from_lms() + + self.assertIsNotNone(result) + self.assertEqual(type(result), str) + self.assertIn('HTML xblock for Offline', result) + self.assertIn('Test HTML Content
', result) + + def test_render_xblock_from_lms_problem_block(self): + xblock_renderer = XBlockRenderer(str(self.problem_block.location), user=self.user) + + result = xblock_renderer.render_xblock_from_lms() + + self.assertIsNotNone(result) + self.assertEqual(type(result), str) + self.assertIn('Problem xblock for Offline', result) diff --git a/openedx/features/offline_mode/tests/test_storage_management.py b/openedx/features/offline_mode/tests/test_storage_management.py new file mode 100644 index 000000000000..f77f2ba614cd --- /dev/null +++ b/openedx/features/offline_mode/tests/test_storage_management.py @@ -0,0 +1,268 @@ +""" +Tests for the testing Offline Mode storage management. +""" + +import os +import shutil +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from django.http.response import Http404 + +from openedx.features.offline_mode.constants import MATHJAX_STATIC_PATH +from openedx.features.offline_mode.storage_management import OfflineContentGenerator +from openedx.features.offline_mode.tests.base import CourseForOfflineTestCase + + +class OfflineContentGeneratorTestCase(TestCase): + """ + Test case for the testing Offline Mode utils. + """ + @patch('openedx.features.offline_mode.storage_management.XBlockRenderer') + def test_render_block_html_data_successful(self, xblock_renderer_mock: MagicMock) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + result = OfflineContentGenerator(xblock_mock, html_data_mock).render_block_html_data() + + xblock_renderer_mock.assert_called_once_with(str(xblock_mock.location)) + xblock_renderer_mock.return_value.render_xblock_from_lms.assert_called_once_with() + self.assertEqual(result, xblock_renderer_mock.return_value.render_xblock_from_lms.return_value) + + @patch('openedx.features.offline_mode.storage_management.XBlockRenderer') + def test_render_block_html_data_successful_no_html_data(self, xblock_renderer_mock: MagicMock) -> None: + xblock_mock = Mock() + expected_xblock_renderer_args_list = [call(str(xblock_mock.location)), call(str(xblock_mock.location))] + + result = OfflineContentGenerator(xblock_mock).render_block_html_data() + + self.assertListEqual(xblock_renderer_mock.call_args_list, expected_xblock_renderer_args_list) + self.assertListEqual( + xblock_renderer_mock.return_value.render_xblock_from_lms.call_args_list, [call(), call()] + ) + self.assertEqual(result, xblock_renderer_mock.return_value.render_xblock_from_lms.return_value) + + @patch('openedx.features.offline_mode.storage_management.log.error') + @patch('openedx.features.offline_mode.storage_management.XBlockRenderer', side_effect=Http404) + def test_render_block_html_data_http404( + self, + xblock_renderer_mock: MagicMock, + logger_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + with self.assertRaises(Http404): + OfflineContentGenerator(xblock_mock, html_data_mock).render_block_html_data() + + xblock_renderer_mock.assert_called_once_with(str(xblock_mock.location)) + logger_mock.assert_called_once_with( + f'Block {str(xblock_mock.location)} cannot be fetched from course' + f' {xblock_mock.location.course_key} during offline content generation.' + ) + + @patch('openedx.features.offline_mode.storage_management.shutil.rmtree') + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.create_zip_file') + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.save_xblock_html') + @patch('openedx.features.offline_mode.storage_management.mkdtemp') + @patch('openedx.features.offline_mode.storage_management.clean_outdated_xblock_files') + @patch('openedx.features.offline_mode.storage_management.block_storage_path') + def test_generate_offline_content_for_modified_xblock( + self, + block_storage_path_mock: MagicMock, + clean_outdated_xblock_files_mock: MagicMock, + mkdtemp_mock: MagicMock, + save_xblock_html_mock: MagicMock, + create_zip_file_mock: MagicMock, + shutil_rmtree_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).generate_offline_content() + + block_storage_path_mock.assert_called_once_with(xblock_mock) + clean_outdated_xblock_files_mock.assert_called_once_with(xblock_mock) + mkdtemp_mock.assert_called_once_with() + save_xblock_html_mock.assert_called_once_with(mkdtemp_mock.return_value) + create_zip_file_mock.assert_called_once_with( + mkdtemp_mock.return_value, + block_storage_path_mock.return_value, + f'{xblock_mock.location.block_id}.zip' + ) + shutil_rmtree_mock.assert_called_once_with(mkdtemp_mock.return_value, ignore_errors=True) + + @patch('openedx.features.offline_mode.storage_management.os.path.join') + @patch('openedx.features.offline_mode.storage_management.open') + @patch('openedx.features.offline_mode.storage_management.HtmlManipulator') + def test_save_xblock_html( + self, + html_manipulator_mock: MagicMock, + context_manager_mock: MagicMock, + os_path_join_mock: MagicMock, + ) -> None: + tmp_dir_mock = Mock() + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).save_xblock_html(tmp_dir_mock) + + html_manipulator_mock.assert_called_once_with(xblock_mock, html_data_mock, tmp_dir_mock) + html_manipulator_mock.return_value.process_html.assert_called_once_with() + context_manager_mock.assert_called_once_with(os_path_join_mock.return_value, 'w') + os_path_join_mock.assert_called_once_with(tmp_dir_mock, 'index.html') + context_manager_mock.return_value.__enter__.return_value.write.assert_called_once_with( + html_manipulator_mock.return_value.process_html.return_value + ) + + @patch('openedx.features.offline_mode.storage_management.log.info') + @patch('openedx.features.offline_mode.storage_management.ContentFile') + @patch('openedx.features.offline_mode.storage_management.open') + @patch('openedx.features.offline_mode.storage_management.get_storage') + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.add_files_to_zip_recursively') + @patch('openedx.features.offline_mode.storage_management.ZipFile') + def test_create_zip_file( + self, + zip_file_context_manager: MagicMock, + add_files_to_zip_recursively_mock: MagicMock, + storage_mock: MagicMock, + open_context_manager_mock: MagicMock, + content_file_mock: MagicMock, + log_info_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + temp_dir_mock = 'temp_dir_mock' + base_path_mock = 'base_path_mock' + file_name_mock = 'file_name_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).create_zip_file( + temp_dir_mock, base_path_mock, file_name_mock + ) + + zip_file_context_manager.assert_called_once_with(os.path.join(temp_dir_mock, file_name_mock), 'w') + zip_file_context_manager.return_value.__enter__.return_value.write.assert_called_once_with( + os.path.join(temp_dir_mock, 'index.html'), 'index.html' + ) + add_files_to_zip_recursively_mock.assert_called_once_with( + zip_file_context_manager.return_value.__enter__.return_value, + current_base_path=os.path.join(temp_dir_mock, 'assets'), + current_path_in_zip='assets', + ) + open_context_manager_mock.assert_called_once_with(os.path.join(temp_dir_mock, file_name_mock), 'rb') + content_file_mock.assert_called_once_with( + open_context_manager_mock.return_value.__enter__.return_value.read.return_value + ) + storage_mock.return_value.save.assert_called_once_with( + os.path.join(base_path_mock + file_name_mock), content_file_mock.return_value + ) + log_info_mock.assert_called_once_with( + f'Offline content for {file_name_mock} has been generated.' + ) + + @patch('openedx.features.offline_mode.storage_management.os') + def test_add_files_to_zip_recursively_successfully_for_file( + self, + os_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + zip_file_mock = Mock() + current_base_path_mock = 'current_base_path_mock' + current_path_in_zip_mock = 'current_path_in_zip_mock' + resource_path_mock = 'resource_path_mock' + os_mock.listdir.return_value = [resource_path_mock] + + expected_os_mock_path_join_calls = [ + call(current_base_path_mock, resource_path_mock), + call(current_path_in_zip_mock, resource_path_mock) + ] + + OfflineContentGenerator(xblock_mock, html_data_mock).add_files_to_zip_recursively( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + os_mock.listdir.assert_called_once_with(current_base_path_mock) + self.assertListEqual(os_mock.path.join.call_args_list, expected_os_mock_path_join_calls) + zip_file_mock.write.assert_called_once_with(os_mock.path.join.return_value, os_mock.path.join.return_value) + + @patch('openedx.features.offline_mode.storage_management.OfflineContentGenerator.add_files_to_zip_recursively') + @patch('openedx.features.offline_mode.storage_management.os.listdir') + def test_add_files_to_zip_recursively_successfully_recursively_path( + self, + os_listdir_mock: MagicMock, + add_files_to_zip_recursively_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + zip_file_mock = Mock() + current_base_path_mock = 'current_base_path_mock' + current_path_in_zip_mock = 'current_path_in_zip_mock' + resource_path_mock = 'resource_path_mock' + os_listdir_mock.listdir.return_value = [resource_path_mock] + + OfflineContentGenerator(xblock_mock, html_data_mock).add_files_to_zip_recursively( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + add_files_to_zip_recursively_mock.assert_called_once_with( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + @patch('openedx.features.offline_mode.storage_management.log.error') + @patch('openedx.features.offline_mode.storage_management.os.listdir', side_effect=OSError) + def test_add_files_to_zip_recursively_with_os_error( + self, + os_mock: MagicMock, + log_error_mock: MagicMock, + ) -> None: + xblock_mock = Mock() + html_data_mock = 'html_markup_data_mock' + zip_file_mock = Mock() + current_base_path_mock = 'current_base_path_mock' + current_path_in_zip_mock = 'current_path_in_zip_mock' + + OfflineContentGenerator(xblock_mock, html_data_mock).add_files_to_zip_recursively( + zip_file_mock, current_base_path_mock, current_path_in_zip_mock + ) + + os_mock.assert_called_once_with(current_base_path_mock) + log_error_mock.assert_called_once_with(f'Error while reading the directory: {current_base_path_mock}') + + +class OfflineContentGeneratorFunctionalTestCase(CourseForOfflineTestCase): + """ + Tests creating Offline Content in storage. + """ + + def setUp(self): + super().setUp() + self.html_data = '
Test HTML Content
' # lint-amnesty, pylint: disable=attribute-defined-outside-init + + @patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets') + def test_generate_offline_content(self, save_mathjax_to_xblock_assets_mock): + OfflineContentGenerator(self.html_block, self.html_data).generate_offline_content() + + expected_offline_content_path = 'test_root/uploads/course-v1:RaccoonGang+1+2024/HTML_xblock_for_Offline.zip' + + save_mathjax_to_xblock_assets_mock.assert_called_once() + self.assertTrue(os.path.exists(expected_offline_content_path)) + shutil.rmtree('test_root/uploads/course-v1:RaccoonGang+1+2024', ignore_errors=True) + + def test_save_xblock_html_to_temp_dir(self): + shutil.rmtree('test_root/assets', ignore_errors=True) + temp_dir = 'test_root/' + os.makedirs('test_root/assets/js/') + OfflineContentGenerator(self.html_block, self.html_data).save_xblock_html(temp_dir) + + expected_index_html_path = 'test_root/index.html' + expected_mathjax_static_path = os.path.join(temp_dir, MATHJAX_STATIC_PATH) + + self.assertTrue(os.path.exists(expected_index_html_path)) + self.assertTrue(os.path.exists(expected_mathjax_static_path)) + with open(expected_index_html_path, 'r') as content: + html_data = content.read() + self.assertIn(self.html_data, html_data) + + shutil.rmtree('test_root/assets', ignore_errors=True) + os.remove(expected_index_html_path) diff --git a/openedx/features/offline_mode/tests/test_tasks.py b/openedx/features/offline_mode/tests/test_tasks.py new file mode 100644 index 000000000000..ef468d825d7b --- /dev/null +++ b/openedx/features/offline_mode/tests/test_tasks.py @@ -0,0 +1,141 @@ +""" +Tests for the testing Offline Mode tacks. +""" + +from unittest import TestCase +from unittest.mock import MagicMock, Mock, call, patch + +from ddt import data, ddt, unpack +from django.http.response import Http404 +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx.features.offline_mode.constants import MAX_RETRY_ATTEMPTS, OFFLINE_SUPPORTED_XBLOCKS +from openedx.features.offline_mode.tasks import ( + generate_offline_content_for_block, + generate_offline_content_for_course, +) + + +@ddt +class GenerateOfflineContentTasksTestCase(TestCase): + """ + Test case for the testing generating offline content tacks. + """ + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator') + @patch('openedx.features.offline_mode.tasks.modulestore') + def test_generate_offline_content_for_block_success( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + block_id_mock = 'block-v1:a+a+a+type@problem+block@fb81e4dbfd4945cb9318d6bc460a956c' + + generate_offline_content_for_block(block_id_mock) + + modulestore_mock.assert_called_once_with() + modulestore_mock.return_value.get_item.assert_called_once_with(UsageKey.from_string(block_id_mock)) + offline_content_generator_mock.assert_called_once_with(modulestore_mock.return_value.get_item.return_value) + offline_content_generator_mock.return_value.generate_offline_content.assert_called_once_with() + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator') + @patch('openedx.features.offline_mode.tasks.modulestore', side_effect=Http404) + def test_generate_offline_content_for_block_with_exception_in_modulestore( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + block_id_mock = 'block-v1:a+a+a+type@problem+block@fb81e4dbfd4945cb9318d6bc460a956c' + + generate_offline_content_for_block.delay(block_id_mock) + + self.assertEqual(modulestore_mock.call_count, MAX_RETRY_ATTEMPTS + 1) + offline_content_generator_mock.assert_not_called() + + @patch('openedx.features.offline_mode.tasks.OfflineContentGenerator', side_effect=Http404) + @patch('openedx.features.offline_mode.tasks.modulestore') + def test_generate_offline_content_for_block_with_exception_in_offline_content_generation( + self, + modulestore_mock: MagicMock, + offline_content_generator_mock: MagicMock, + ) -> None: + block_id_mock = 'block-v1:a+a+a+type@problem+block@fb81e4dbfd4945cb9318d6bc460a956c' + + generate_offline_content_for_block.delay(block_id_mock) + + self.assertEqual(modulestore_mock.call_count, MAX_RETRY_ATTEMPTS + 1) + self.assertEqual(offline_content_generator_mock.call_count, MAX_RETRY_ATTEMPTS + 1) + + @patch('openedx.features.offline_mode.tasks.generate_offline_content_for_block') + @patch('openedx.features.offline_mode.tasks.is_modified') + @patch('openedx.features.offline_mode.tasks.modulestore') + def test_generate_offline_content_for_course_supported_block_types( + self, + modulestore_mock: MagicMock, + is_modified_mock: MagicMock, + generate_offline_content_for_block_mock: MagicMock, + ) -> None: + course_id_mock = 'course-v1:a+a+a' + xblock_location_mock = 'xblock_location_mock' + modulestore_mock.return_value.get_items.return_value = [ + Mock(location=xblock_location_mock, closed=Mock(return_value=False)) + ] + + expected_call_args_for_modulestore_get_items = [ + call(CourseKey.from_string(course_id_mock), qualifiers={'category': offline_supported_block_type}) + for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS + ] + expected_call_args_is_modified_mock = [ + call(modulestore_mock.return_value.get_items.return_value[0]) for _ in OFFLINE_SUPPORTED_XBLOCKS + ] + expected_call_args_for_generate_offline_content_for_block_mock = [ + call([xblock_location_mock]) for _ in OFFLINE_SUPPORTED_XBLOCKS + ] + + generate_offline_content_for_course(course_id_mock) + + self.assertEqual(modulestore_mock.call_count, len(OFFLINE_SUPPORTED_XBLOCKS)) + self.assertListEqual( + modulestore_mock.return_value.get_items.call_args_list, expected_call_args_for_modulestore_get_items + ) + self.assertListEqual(is_modified_mock.call_args_list, expected_call_args_is_modified_mock) + self.assertListEqual( + generate_offline_content_for_block_mock.apply_async.call_args_list, + expected_call_args_for_generate_offline_content_for_block_mock + ) + + @patch('openedx.features.offline_mode.tasks.generate_offline_content_for_block') + @patch('openedx.features.offline_mode.tasks.is_modified') + @patch('openedx.features.offline_mode.tasks.modulestore') + @data( + (False, False), + (True, False), + (False, True), + ) + @unpack + def test_generate_offline_content_for_course_supported_block_types_for_closed_or_not_modified_xblock( + self, + is_modified_value_mock: bool, + is_closed_value_mock: bool, + modulestore_mock: MagicMock, + is_modified_mock: MagicMock, + generate_offline_content_for_block_mock: MagicMock, + ) -> None: + course_id_mock = 'course-v1:a+a+a' + xblock_location_mock = 'xblock_location_mock' + modulestore_mock.return_value.get_items.return_value = [ + Mock(location=xblock_location_mock, closed=Mock(return_value=is_closed_value_mock)) + ] + is_modified_mock.return_value = is_modified_value_mock + + expected_call_args_for_modulestore_get_items = [ + call(CourseKey.from_string(course_id_mock), qualifiers={'category': offline_supported_block_type}) + for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS + ] + + generate_offline_content_for_course(course_id_mock) + + self.assertEqual(modulestore_mock.call_count, len(OFFLINE_SUPPORTED_XBLOCKS)) + self.assertListEqual( + modulestore_mock.return_value.get_items.call_args_list, expected_call_args_for_modulestore_get_items + ) + generate_offline_content_for_block_mock.assert_not_called()