diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index a6528c089bd0..408d16618569 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -20,13 +20,11 @@ ) from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, - LIBRARY_CONTAINER_CREATED, LIBRARY_CONTAINER_DELETED, LIBRARY_CONTAINER_UPDATED, ) @@ -738,420 +736,6 @@ def test_get_containers_contains_item(self): assert len(subsection_2_containers) == 1 assert subsection_2_containers[0].container_key == self.section1.container_key - def _validate_calls_of_html_block(self, event_mock): - """ - Validate that the `event_mock` has been called twice - using the `LIBRARY_CONTAINER_UPDATED` signal. - """ - assert event_mock.call_count == 2 - self.assertDictContainsEntries( - event_mock.call_args_list[0].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.unit1.container_key, - background=True, - ) - }, - ) - self.assertDictContainsEntries( - event_mock.call_args_list[1].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.unit2.container_key, - background=True, - ) - }, - ) - - def test_call_container_update_signal_when_delete_component(self) -> None: - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - - api.delete_library_block(self.html_block_usage_key) - self._validate_calls_of_html_block(container_update_event_receiver) - - def test_call_container_update_signal_when_restore_component(self) -> None: - api.delete_library_block(self.html_block_usage_key) - - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - api.restore_library_block(self.html_block_usage_key) - - self._validate_calls_of_html_block(container_update_event_receiver) - - def test_call_container_update_signal_when_update_olx(self) -> None: - block_olx = "Hello world!" - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - - self._set_library_block_olx(self.html_block_usage_key, block_olx) - self._validate_calls_of_html_block(container_update_event_receiver) - - def test_call_container_update_signal_when_update_component(self) -> None: - block_olx = "Hello world!" - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - - self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) - self._validate_calls_of_html_block(container_update_event_receiver) - - def test_call_container_update_signal_when_update_unit(self) -> None: - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - self._update_container(self.unit1.container_key, 'New Unit Display Name') - - assert container_update_event_receiver.call_count == 3 - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[0].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.unit1.container_key, - ) - }, - ) - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[1].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.subsection1.container_key, - ) - }, - ) - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[2].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.subsection2.container_key, - ) - }, - ) - - def test_call_container_update_signal_when_update_subsection(self) -> None: - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - self._update_container(self.subsection1.container_key, 'New Subsection Display Name') - - assert container_update_event_receiver.call_count == 3 - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[0].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.subsection1.container_key, - ) - }, - ) - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[1].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.section1.container_key, - ) - }, - ) - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[2].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.section2.container_key, - ) - }, - ) - - def test_call_container_update_signal_when_update_section(self) -> None: - container_update_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) - self._update_container(self.section1.container_key, 'New Section Display Name') - - assert container_update_event_receiver.call_count == 1 - self.assertDictContainsEntries( - container_update_event_receiver.call_args_list[0].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.section1.container_key, - ) - }, - ) - - def test_call_object_changed_signal_when_remove_component(self) -> None: - html_block_1 = self._add_block_to_library( - self.lib1.library_key, "html", "html3", - ) - api.update_container_children( - self.unit2.container_key, - [LibraryUsageLocatorV2.from_string(html_block_1["id"])], - None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - - event_reciver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) - api.update_container_children( - self.unit2.container_key, - [LibraryUsageLocatorV2.from_string(html_block_1["id"])], - None, - entities_action=content_api.ChildrenEntitiesAction.REMOVE, - ) - - assert event_reciver.call_count == 1 - self.assertDictContainsEntries( - event_reciver.call_args_list[0].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=html_block_1["id"], - changes=["units"], - ), - }, - ) - - def test_call_object_changed_signal_when_remove_unit(self) -> None: - unit4 = api.create_container(self.lib1.library_key, content_models.Unit, 'unit-4', 'Unit 4', None) - - api.update_container_children( - self.subsection2.container_key, - [unit4.container_key], - None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - - event_reciver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) - api.update_container_children( - self.subsection2.container_key, - [unit4.container_key], - None, - entities_action=content_api.ChildrenEntitiesAction.REMOVE, - ) - - assert event_reciver.call_count == 1 - self.assertDictContainsEntries( - event_reciver.call_args_list[0].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(unit4.container_key), - changes=["subsections"], - ), - }, - ) - - def test_call_object_changed_signal_when_remove_subsection(self) -> None: - subsection3 = api.create_container( - self.lib1.library_key, - content_models.Subsection, - 'subsection-3', - 'Subsection 3', - None, - ) - - api.update_container_children( - self.section2.container_key, - [subsection3.container_key], - None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - - event_reciver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) - api.update_container_children( - self.section2.container_key, - [subsection3.container_key], - None, - entities_action=content_api.ChildrenEntitiesAction.REMOVE, - ) - - assert event_reciver.call_count == 1 - self.assertDictContainsEntries( - event_reciver.call_args_list[0].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(subsection3.container_key), - changes=["sections"], - ), - }, - ) - - def test_call_object_changed_signal_when_add_component(self) -> None: - event_reciver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) - html_block_1 = self._add_block_to_library( - self.lib1.library_key, "html", "html4", - ) - html_block_2 = self._add_block_to_library( - self.lib1.library_key, "html", "html5", - ) - - api.update_container_children( - self.unit2.container_key, - [ - LibraryUsageLocatorV2.from_string(html_block_1["id"]), - LibraryUsageLocatorV2.from_string(html_block_2["id"]) - ], - None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - - assert event_reciver.call_count == 2 - self.assertDictContainsEntries( - event_reciver.call_args_list[0].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=html_block_1["id"], - changes=["units"], - ), - }, - ) - self.assertDictContainsEntries( - event_reciver.call_args_list[1].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=html_block_2["id"], - changes=["units"], - ), - }, - ) - - def test_call_object_changed_signal_when_add_unit(self) -> None: - event_reciver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) - - unit4 = api.create_container(self.lib1.library_key, content_models.Unit, 'unit-4', 'Unit 4', None) - unit5 = api.create_container(self.lib1.library_key, content_models.Unit, 'unit-5', 'Unit 5', None) - - api.update_container_children( - self.subsection2.container_key, - [unit4.container_key, unit5.container_key], - None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - assert event_reciver.call_count == 2 - self.assertDictContainsEntries( - event_reciver.call_args_list[0].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(unit4.container_key), - changes=["subsections"], - ), - }, - ) - self.assertDictContainsEntries( - event_reciver.call_args_list[1].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(unit5.container_key), - changes=["subsections"], - ), - }, - ) - - def test_call_object_changed_signal_when_add_subsection(self) -> None: - event_reciver = mock.Mock() - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) - - subsection3 = api.create_container( - self.lib1.library_key, - content_models.Subsection, - 'subsection-3', - 'Subsection 3', - None, - ) - subsection4 = api.create_container( - self.lib1.library_key, - content_models.Subsection, - 'subsection-4', - 'Subsection 4', - None, - ) - api.update_container_children( - self.section2.container_key, - [subsection3.container_key, subsection4.container_key], - None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - assert event_reciver.call_count == 2 - self.assertDictContainsEntries( - event_reciver.call_args_list[0].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(subsection3.container_key), - changes=["sections"], - ), - }, - ) - self.assertDictContainsEntries( - event_reciver.call_args_list[1].kwargs, - { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, - "sender": None, - "content_object": ContentObjectChangedData( - object_id=str(subsection4.container_key), - changes=["sections"], - ), - }, - ) - - def test_delete_component_and_revert(self) -> None: - """ - When a component is deleted and then the delete is reverted, signals - will be emitted to update any containing containers. - """ - # Add components and publish - api.update_container_children(self.unit3.container_key, [ - LibraryUsageLocatorV2.from_string(self.problem_block_2["id"]), - ], user_id=None) - api.publish_changes(self.lib1.library_key) - - # Delete component and revert - api.delete_library_block(LibraryUsageLocatorV2.from_string(self.problem_block_2["id"])) - - container_event_receiver = mock.Mock() - LIBRARY_CONTAINER_UPDATED.connect(container_event_receiver) - - api.revert_changes(self.lib1.library_key) - - assert container_event_receiver.call_count == 1 - self.assertDictContainsEntries( - container_event_receiver.call_args_list[0].kwargs, - { - "signal": LIBRARY_CONTAINER_UPDATED, - "sender": None, - "library_container": LibraryContainerData( - container_key=self.unit3.container_key - ), - }, - ) def test_copy_and_paste_container_same_library(self) -> None: # Copy a section with children @@ -1250,81 +834,6 @@ def test_set_library_block_olx_no_signal_on_rollback(self) -> None: assert event_receiver.call_count == 0 - def test_set_library_block_olx_signal_emitted_on_success(self) -> None: - """ - LIBRARY_BLOCK_UPDATED IS emitted when set_library_block_olx completes - successfully. - """ - event_receiver = mock.Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - self.addCleanup(LIBRARY_BLOCK_UPDATED.disconnect, event_receiver) - - api.set_library_block_olx( - self.problem_block_usage_key, - "Updated successfully", - ) - - assert event_receiver.call_count == 1 - self.assertDictContainsEntries( - event_receiver.call_args_list[0].kwargs, - { - "signal": LIBRARY_BLOCK_UPDATED, - "library_block": LibraryBlockData( - library_key=self.lib1.library_key, - usage_key=self.problem_block_usage_key, - ), - }, - ) - - def test_import_container_no_signals_on_failure(self) -> None: - """ - When import_staged_content_from_user_clipboard fails mid-way, none of - LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, or LIBRARY_BLOCK_UPDATED - are emitted, so the search index is not polluted with orphan entries. - """ - api.copy_container(self.unit1.container_key, self.user.id) - - event_receiver = mock.Mock() - for signal in [LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_UPDATED]: - signal.connect(event_receiver) - self.addCleanup(signal.disconnect, event_receiver) - - # Simulate a failure at the last step of the import (after the container - # and its child components have been created in the DB). - with mock.patch( - "openedx.core.djangoapps.content_libraries.api.blocks.update_container_children", - side_effect=RuntimeError("Simulated failure"), - ), self.assertRaises(RuntimeError): # noqa: PT027 - api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user) - - assert event_receiver.call_count == 0 - - def test_import_container_signals_emitted_on_success(self) -> None: - """ - When import_staged_content_from_user_clipboard succeeds, LIBRARY_CONTAINER_CREATED - is emitted for the new container. - """ - api.copy_container(self.unit1.container_key, self.user.id) - - container_created_receiver = mock.Mock() - LIBRARY_CONTAINER_CREATED.connect(container_created_receiver) - self.addCleanup(LIBRARY_CONTAINER_CREATED.disconnect, container_created_receiver) - - new_container = api.import_staged_content_from_user_clipboard(self.lib1.library_key, self.user) - - assert container_created_receiver.call_count == 1 - assert hasattr(new_container, "container_key") - self.assertDictContainsEntries( - container_created_receiver.call_args_list[0].kwargs, - { - "signal": LIBRARY_CONTAINER_CREATED, - "library_container": LibraryContainerData( - container_key=new_container.container_key, # type: ignore[union-attr] - ), - }, - ) - - class ContentLibraryExportTest(ContentLibrariesRestApiTest): """ Tests for Content Library API export methods. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_events.py b/openedx/core/djangoapps/content_libraries/tests/test_events.py index dd5cb2dd3c5d..bf4857a3cae7 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_events.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_events.py @@ -1,16 +1,23 @@ """ Tests for openedx_content-based Content Libraries """ + +from unittest import mock + +from django.db import transaction from opaque_keys.edx.locator import ( LibraryCollectionLocator, LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2, ) +from openedx_content import api as content_api +from openedx_content import models_api as content_models from openedx_events.content_authoring.signals import ( CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_PUBLISHED, @@ -23,6 +30,7 @@ LIBRARY_CONTAINER_PUBLISHED, LIBRARY_CONTAINER_UPDATED, ContentLibraryData, + ContentObjectChangedData, LibraryBlockData, LibraryCollectionData, LibraryContainerData, @@ -31,14 +39,16 @@ from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest from openedx.core.djangolib.testing.utils import skip_unless_cms +from .. import api -@skip_unless_cms -class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest): + +class BaseEventsTestCase(ContentLibrariesRestApiTest): """ - Event tests for openedx_content-based Content Libraries + Base class for testing library events These tests use the REST API, which in turn relies on the Python API. """ + # Note: we assume all events are already enabled, as they should be. We do # NOT use OpenEdxEventsTestMixin, because it disables any events that you # don't explicitly enable and does so in a way that interferes with other @@ -47,6 +57,7 @@ class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest): CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, @@ -109,6 +120,13 @@ def expect_new_events(self, *expected_events: dict) -> None: raise AssertionError(f"Events were emitted but not expected: {self.new_events}") self.clear_events() + +@skip_unless_cms +class ContentLibrariesEventsTestCase(BaseEventsTestCase): + """ + Event tests for openedx_content-based Content Libraries + """ + ############################## Libraries ################################## def test_content_library_crud_events(self) -> None: @@ -520,10 +538,19 @@ def test_restore_unit(self) -> None: # Restore the unit self._restore_container(container_data["id"]) - self.expect_new_events({ - "signal": LIBRARY_CONTAINER_CREATED, - "library_container": LibraryContainerData(container_key), - }) + self.expect_new_events( + { + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(container_key), + changes=["collections", "tags"], + ), + }, + ) def test_restore_unit_via_revert(self) -> None: """ @@ -592,4 +619,549 @@ def test_collection_crud(self) -> None: "library_collection": LibraryCollectionData(collection_key), }) - # TODO: move more of the event-related collection tests from test_api.py to here, and convert them to use REST APIs +@skip_unless_cms +class ContentLibraryContainerEventsTest(BaseEventsTestCase): + """ + Event tests for container operations: signals emitted when components and + containers are created, updated, deleted, and associated with one another. + """ + + def setUp(self) -> None: + super().setUp() + + # Create Units + self.unit1 = api.create_container(self.lib1_key, content_models.Unit, 'unit-1', 'Unit 1', None) + self.unit2 = api.create_container(self.lib1_key, content_models.Unit, 'unit-2', 'Unit 2', None) + self.unit3 = api.create_container(self.lib1_key, content_models.Unit, 'unit-3', 'Unit 3', None) + + # Create Subsections + self.subsection1 = api.create_container( + self.lib1_key, content_models.Subsection, 'subsection-1', 'Subsection 1', None, + ) + self.subsection2 = api.create_container( + self.lib1_key, content_models.Subsection, 'subsection-2', 'Subsection 2', None, + ) + + # Create Sections + self.section1 = api.create_container( + self.lib1_key, content_models.Section, 'section-1', 'Section 1', None, + ) + self.section2 = api.create_container( + self.lib1_key, content_models.Section, 'section-2', 'Section 2', None, + ) + + # Create XBlocks + self.problem_block = self._add_block_to_library(self.lib1_key, "problem", "problem1") + self.problem_block_usage_key = LibraryUsageLocatorV2.from_string(self.problem_block["id"]) + self.problem_block_2 = self._add_block_to_library(self.lib1_key, "problem", "problem2") + self.html_block = self._add_block_to_library(self.lib1_key, "html", "html1") + self.html_block_usage_key = LibraryUsageLocatorV2.from_string(self.html_block["id"]) + + # Add content to units + api.update_container_children( + self.unit1.container_key, [self.problem_block_usage_key, self.html_block_usage_key], None, + ) + api.update_container_children( + self.unit2.container_key, [self.html_block_usage_key], None, + ) + + # Add units to subsections + api.update_container_children( + self.subsection1.container_key, [self.unit1.container_key, self.unit2.container_key], None, + ) + api.update_container_children( + self.subsection2.container_key, [self.unit1.container_key], None, + ) + + # Add subsections to sections + api.update_container_children( + self.section1.container_key, [self.subsection1.container_key, self.subsection2.container_key], None, + ) + api.update_container_children( + self.section2.container_key, [self.subsection1.container_key], None, + ) + + # Clear events emitted during setUp + self.clear_events() + + ############################## Component update signals ################################## + + def test_container_updated_when_component_deleted(self) -> None: + api.delete_library_block(self.html_block_usage_key) + self.expect_new_events( + { + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, self.html_block_usage_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, background=True, + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit2.container_key, background=True, + ), + }, + ) + + def test_container_updated_when_component_restored(self) -> None: + api.delete_library_block(self.html_block_usage_key) + self.clear_events() + + api.restore_library_block(self.html_block_usage_key) + self.expect_new_events( + { + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, self.html_block_usage_key), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.html_block_usage_key), + changes=["collections", "tags", "units"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, background=True, + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit2.container_key, background=True, + ), + }, + ) + + def test_container_updated_when_component_olx_updated(self) -> None: + self._set_library_block_olx(self.html_block_usage_key, "Hello world!") + self.expect_new_events( + { + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, self.html_block_usage_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, background=True, + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit2.container_key, background=True, + ), + }, + ) + + def test_container_updated_when_component_fields_updated(self) -> None: + block_olx = "Hello world!" + self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) + self.expect_new_events( + { + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, self.html_block_usage_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, background=True, + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit2.container_key, background=True, + ), + }, + ) + + ############################## Container update signals ################################## + + def test_container_updated_when_unit_updated(self) -> None: + self._update_container(self.unit1.container_key, 'New Unit Display Name') + self.expect_new_events( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.unit1.container_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.subsection1.container_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.subsection2.container_key), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.problem_block_usage_key), changes=["units"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.html_block_usage_key), changes=["units"], + ), + }, + ) + + def test_container_updated_when_subsection_updated(self) -> None: + self._update_container(self.subsection1.container_key, 'New Subsection Display Name') + self.expect_new_events( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.subsection1.container_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.section1.container_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.section2.container_key), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.unit1.container_key), changes=["subsections"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.unit2.container_key), changes=["subsections"], + ), + }, + ) + + def test_container_updated_when_section_updated(self) -> None: + self._update_container(self.section1.container_key, 'New Section Display Name') + self.expect_new_events( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.section1.container_key), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.subsection1.container_key), changes=["sections"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.subsection2.container_key), changes=["sections"], + ), + }, + ) + + ############################## Association change signals ################################## + + def test_associations_changed_when_component_removed(self) -> None: + html_block_1 = self._add_block_to_library(self.lib1_key, "html", "html3") + api.update_container_children( + self.unit2.container_key, + [LibraryUsageLocatorV2.from_string(html_block_1["id"])], + None, + entities_action=content_api.ChildrenEntitiesAction.APPEND, + ) + self.clear_events() + + api.update_container_children( + self.unit2.container_key, + [LibraryUsageLocatorV2.from_string(html_block_1["id"])], + None, + entities_action=content_api.ChildrenEntitiesAction.REMOVE, + ) + self.expect_new_events( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=html_block_1["id"], changes=["units"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.unit2.container_key), + }, + ) + + def test_associations_changed_when_unit_removed(self) -> None: + unit4 = api.create_container(self.lib1_key, content_models.Unit, 'unit-4', 'Unit 4', None) + api.update_container_children( + self.subsection2.container_key, + [unit4.container_key], + None, + entities_action=content_api.ChildrenEntitiesAction.APPEND, + ) + self.clear_events() + + api.update_container_children( + self.subsection2.container_key, + [unit4.container_key], + None, + entities_action=content_api.ChildrenEntitiesAction.REMOVE, + ) + self.expect_new_events( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(unit4.container_key), changes=["subsections"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.subsection2.container_key), + }, + ) + + def test_associations_changed_when_subsection_removed(self) -> None: + subsection3 = api.create_container( + self.lib1_key, content_models.Subsection, 'subsection-3', 'Subsection 3', None, + ) + api.update_container_children( + self.section2.container_key, + [subsection3.container_key], + None, + entities_action=content_api.ChildrenEntitiesAction.APPEND, + ) + self.clear_events() + + api.update_container_children( + self.section2.container_key, + [subsection3.container_key], + None, + entities_action=content_api.ChildrenEntitiesAction.REMOVE, + ) + self.expect_new_events( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(subsection3.container_key), changes=["sections"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.section2.container_key), + }, + ) + + def test_associations_changed_when_components_added(self) -> None: + html_block_1 = self._add_block_to_library(self.lib1_key, "html", "html4") + html_block_2 = self._add_block_to_library(self.lib1_key, "html", "html5") + self.clear_events() + + api.update_container_children( + self.unit2.container_key, + [ + LibraryUsageLocatorV2.from_string(html_block_1["id"]), + LibraryUsageLocatorV2.from_string(html_block_2["id"]), + ], + None, + entities_action=content_api.ChildrenEntitiesAction.APPEND, + ) + self.expect_new_events( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=html_block_1["id"], changes=["units"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=html_block_2["id"], changes=["units"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.unit2.container_key), + }, + ) + + def test_associations_changed_when_units_added(self) -> None: + unit4 = api.create_container(self.lib1_key, content_models.Unit, 'unit-4', 'Unit 4', None) + unit5 = api.create_container(self.lib1_key, content_models.Unit, 'unit-5', 'Unit 5', None) + self.clear_events() + + api.update_container_children( + self.subsection2.container_key, + [unit4.container_key, unit5.container_key], + None, + entities_action=content_api.ChildrenEntitiesAction.APPEND, + ) + self.expect_new_events( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(unit4.container_key), changes=["subsections"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(unit5.container_key), changes=["subsections"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.subsection2.container_key), + }, + ) + + def test_associations_changed_when_subsections_added(self) -> None: + subsection3 = api.create_container( + self.lib1_key, content_models.Subsection, 'subsection-3', 'Subsection 3', None, + ) + subsection4 = api.create_container( + self.lib1_key, content_models.Subsection, 'subsection-4', 'Subsection 4', None, + ) + self.clear_events() + + api.update_container_children( + self.section2.container_key, + [subsection3.container_key, subsection4.container_key], + None, + entities_action=content_api.ChildrenEntitiesAction.APPEND, + ) + self.expect_new_events( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(subsection3.container_key), changes=["sections"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(subsection4.container_key), changes=["sections"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.section2.container_key), + }, + ) + + ############################## Revert signals ################################## + + def test_container_updated_when_component_delete_reverted(self) -> None: + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing containers. + """ + problem_block_2_key = LibraryUsageLocatorV2.from_string(self.problem_block_2["id"]) + api.update_container_children(self.unit3.container_key, [problem_block_2_key], user_id=None) + api.publish_changes(self.lib1_key) + api.delete_library_block(problem_block_2_key) + self.clear_events() + + api.revert_changes(self.lib1_key) + self.expect_new_events( + { + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(library_key=self.lib1_key, usage_key=problem_block_2_key), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=self.unit3.container_key), + }, + ) + + ############################## Transaction/signal correctness ################################## + + def test_no_signal_on_set_block_olx_rollback(self) -> None: + """ + LIBRARY_BLOCK_UPDATED is NOT emitted when set_library_block_olx is called + within a transaction that is later rolled back. + """ + try: + with transaction.atomic(): + api.set_library_block_olx( + self.problem_block_usage_key, + "Updated inside rolled-back transaction", + ) + raise RuntimeError("Force rollback") + except RuntimeError: + pass + + self.expect_new_events() + + def test_signal_emitted_when_set_block_olx_succeeds(self) -> None: + """ + LIBRARY_BLOCK_UPDATED IS emitted when set_library_block_olx completes + successfully. + """ + api.set_library_block_olx( + self.problem_block_usage_key, + "Updated successfully", + ) + self.expect_new_events( + { + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData( + library_key=self.lib1_key, + usage_key=self.problem_block_usage_key, + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, background=True, + ), + }, + ) + + def test_no_signals_on_import_container_failure(self) -> None: + """ + When import_staged_content_from_user_clipboard fails mid-way, none of + LIBRARY_CONTAINER_CREATED, LIBRARY_BLOCK_CREATED, or LIBRARY_BLOCK_UPDATED + are emitted, so the search index is not polluted with orphan entries. + """ + api.copy_container(self.unit1.container_key, self.user.id) + + with mock.patch( + "openedx.core.djangoapps.content_libraries.api.blocks.update_container_children", + side_effect=RuntimeError("Simulated failure"), + ), self.assertRaises(RuntimeError): # noqa: PT027 + api.import_staged_content_from_user_clipboard(self.lib1_key, self.user) + + self.expect_new_events() + + def test_signals_emitted_on_import_container_success(self) -> None: + """ + When import_staged_content_from_user_clipboard succeeds, LIBRARY_CONTAINER_CREATED + is emitted for the new container, along with association change events for its children. + """ + api.copy_container(self.unit1.container_key, self.user.id) + new_container = api.import_staged_content_from_user_clipboard(self.lib1_key, self.user) + new_container_key = new_container.container_key # type: ignore[attr-defined] + self.expect_new_events( + { + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key=new_container_key), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.problem_block_usage_key), changes=["units"], + ), + }, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "content_object": ContentObjectChangedData( + object_id=str(self.html_block_usage_key), changes=["units"], + ), + }, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key=new_container_key), + }, + )