44
55from __future__ import annotations
66
7+ from typing import Any , cast
78from unittest .mock import MagicMock , patch
89
910import ddt # type: ignore[import]
1718from openedx_tagging import api
1819from openedx_tagging .models import LanguageTaxonomy , ObjectTag , Tag , Taxonomy
1920from openedx_tagging .models .utils import RESERVED_TAG_CHARS
20- from openedx_tagging .tasks import emit_content_object_associations_changed_for_tag_task
21+ from openedx_tagging .signal_handlers import _is_explicit_tag_delete
22+ from openedx_tagging .tasks import (
23+ emit_content_object_associations_changed_for_object_ids_task ,
24+ emit_content_object_associations_changed_for_tag_task ,
25+ )
2126
2227from .utils import pretty_format_tags
2328
@@ -663,11 +668,21 @@ def test_object_tag_export_id(self):
663668 self .object_tag .save ()
664669 assert self .object_tag .export_id == self .taxonomy .export_id
665670
666- # But if the taxonomy is deleted, then the object_tag's export_id reverts to our cached export_id
667- self .taxonomy .delete ()
671+ # But if the taxonomy is deleted, then the object_tag's export_id reverts to our cached export_id.
672+ # Patch explicit-delete detection because this test is about ObjectTag fallback behavior,
673+ # not tag deletion event-origins.
674+ with patch ("openedx_tagging.signal_handlers._is_explicit_tag_delete" , return_value = False ):
675+ self .taxonomy .delete ()
668676 self .object_tag .refresh_from_db ()
669677 assert self .object_tag .export_id == "another-taxonomy"
670678
679+ def test_is_explicit_tag_delete_raises_for_unexpected_origin_type (self ):
680+ with pytest .raises (
681+ TypeError ,
682+ match = r"Expected origin to be Tag, QuerySet\[Tag\], or None; got Taxonomy" ,
683+ ):
684+ _is_explicit_tag_delete (instance = self .tag , origin = cast (Any , self .taxonomy ), using = "default" )
685+
671686 def test_object_tag_value (self ):
672687 # ObjectTag's value defaults to its tag's value
673688 object_tag = ObjectTag .objects .create (
@@ -824,8 +839,11 @@ def test_is_deleted(self):
824839 (self .bacteria .value , True ), # <--- deleted! But the value is preserved.
825840 ]
826841
827- # Then delete the whole free text taxonomy
828- self .free_text_taxonomy .delete ()
842+ # Then delete the whole free text taxonomy.
843+ # Patch explicit-delete detection because this
844+ # test validates ObjectTag deleted-state behavior, not tag deletion event-origins.
845+ with patch ("openedx_tagging.signal_handlers._is_explicit_tag_delete" , return_value = False ):
846+ self .free_text_taxonomy .delete ()
829847
830848 assert [(t .value , t .is_deleted ) for t in api .get_object_tags (object_id , include_deleted = True )] == [
831849 ("bar" , True ), # <--- Deleted, but the value is preserved
@@ -1095,16 +1113,18 @@ def test_rename(self):
10951113 assert self .bob .depth == 1
10961114 assert self .bob .lineage == "Charlie\t Bob\t "
10971115
1116+ # TODO: The following event-emission tests don't really belong in TestTagLineage.
1117+ # They should be moved to a separate test_events.py module.
10981118 @patch ("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_tag_task.delay" )
10991119 def test_rename_updates_search_index (self , mock_task_delay ) -> None :
11001120 """
11011121 Renaming a tag should enqueue an async task that emits
11021122 CONTENT_OBJECT_ASSOCIATIONS_CHANGED events.
11031123 """
1104- ObjectTag . objects . create (
1124+ api . tag_object (
11051125 object_id = "content-v1:org+course+run+type@unit+block@123" ,
11061126 taxonomy = self .alice .taxonomy ,
1107- tag = self .alice ,
1127+ tags = [ self .alice . value ] ,
11081128 )
11091129
11101130 with self .captureOnCommitCallbacks (execute = True ):
@@ -1114,20 +1134,89 @@ def test_rename_updates_search_index(self, mock_task_delay) -> None:
11141134 assert mock_task_delay .call_count == 1
11151135 assert mock_task_delay .call_args [1 ]['tag_id' ] == self .alice .id
11161136
1137+ @patch ("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_object_ids_task.delay" )
1138+ def test_delete_updates_search_index (self , mock_task_delay ) -> None :
1139+ """
1140+ Deleting a tag should enqueue an async task that emits
1141+ CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for affected objects.
1142+
1143+ Note: this tests deleting a ``Tag`` (not an ``ObjectTag``). Deleting a
1144+ ``Tag`` triggers the event here in openedx-learning. Deleting an
1145+ ``ObjectTag`` (i.e. untagging a content object) triggers the same event
1146+ in openedx-platform instead, so that case is not tested here.
1147+ """
1148+ object_id = "content-v1:org+course+run+type@unit+block@125"
1149+ api .tag_object (
1150+ object_id = object_id ,
1151+ taxonomy = self .bob .taxonomy ,
1152+ tags = [self .bob .value ],
1153+ )
1154+
1155+ with self .captureOnCommitCallbacks (execute = True ):
1156+ self .bob .delete ()
1157+
1158+ assert mock_task_delay .call_count == 1
1159+ assert mock_task_delay .call_args [1 ]["object_ids" ] == [object_id ]
1160+
1161+ @patch ("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_object_ids_task.delay" )
1162+ def test_delete_with_descendants_updates_search_index (self , mock_task_delay ) -> None :
1163+ """
1164+ Deleting a tag should also enqueue updates for any deleted descendants.
1165+ """
1166+ alice_object_id = "content-v1:org+course+run+type@unit+block@126"
1167+ delta_object_id = "content-v1:org+course+run+type@unit+block@127"
1168+ api .tag_object (
1169+ object_id = alice_object_id ,
1170+ taxonomy = self .alice .taxonomy ,
1171+ tags = [self .alice .value ],
1172+ )
1173+ api .tag_object (
1174+ object_id = delta_object_id ,
1175+ taxonomy = self .delta .taxonomy ,
1176+ tags = [self .delta .value ],
1177+ )
1178+
1179+ with self .captureOnCommitCallbacks (execute = True ):
1180+ api .delete_tags_from_taxonomy (self .alice .taxonomy , ["Alice" ], with_subtags = True )
1181+
1182+ assert mock_task_delay .call_count == 1
1183+ assert set (mock_task_delay .call_args .kwargs ["object_ids" ]) == {
1184+ alice_object_id ,
1185+ delta_object_id ,
1186+ }
1187+
1188+ @patch ("openedx_tagging.tasks.CONTENT_OBJECT_ASSOCIATIONS_CHANGED" , new_callable = MagicMock )
1189+ def test_emit_content_object_associations_changed_for_object_ids_task (self , mock_signal ) -> None :
1190+ """Task emits one CONTENT_OBJECT_ASSOCIATIONS_CHANGED event per distinct object."""
1191+ first_object_id = "content-v1:org+course+run+type@unit+block@123"
1192+ second_object_id = "content-v1:org+course+run+type@unit+block@124"
1193+
1194+ emitted_events = emit_content_object_associations_changed_for_object_ids_task (
1195+ [first_object_id , second_object_id , first_object_id ]
1196+ )
1197+
1198+ assert emitted_events == 2
1199+ assert mock_signal .send_event .call_count == 2
1200+ emitted_object_ids = {
1201+ call .kwargs ["content_object" ].object_id
1202+ for call in mock_signal .send_event .call_args_list
1203+ }
1204+ assert emitted_object_ids == {first_object_id , second_object_id }
1205+
11171206 @patch ("openedx_tagging.tasks.CONTENT_OBJECT_ASSOCIATIONS_CHANGED" , new_callable = MagicMock )
11181207 def test_emit_content_object_associations_changed_for_tag_task (self , mock_signal ) -> None :
11191208 """Task emits one CONTENT_OBJECT_ASSOCIATIONS_CHANGED event per associated object."""
11201209 first_object_id = "content-v1:org+course+run+type@unit+block@123"
11211210 second_object_id = "content-v1:org+course+run+type@unit+block@124"
1122- ObjectTag . objects . create (
1211+ api . tag_object (
11231212 object_id = first_object_id ,
11241213 taxonomy = self .alice .taxonomy ,
1125- tag = self .alice ,
1214+ tags = [ self .alice . value ] ,
11261215 )
1127- ObjectTag . objects . create (
1216+ api . tag_object (
11281217 object_id = second_object_id ,
11291218 taxonomy = self .alice .taxonomy ,
1130- tag = self .alice ,
1219+ tags = [ self .alice . value ] ,
11311220 )
11321221
11331222 emitted_events = emit_content_object_associations_changed_for_tag_task (self .alice .id )
0 commit comments