11"""
2- Tests for LTI Advantage Assignments and Grades Service views .
2+ Tests for LTI Consumer signal handlers .
33"""
44from datetime import datetime
55from unittest .mock import Mock , patch
66
7+ from ddt import data , ddt , unpack
78from django .test import TestCase
89from opaque_keys .edx .keys import UsageKey
10+ from openedx_events .content_authoring .data import LibraryBlockData , XBlockData
911
1012from lti_consumer .models import LtiAgsLineItem , LtiAgsScore , LtiConfiguration
13+ from lti_consumer .signals .signals import delete_lti_configuration
1114
1215
1316class PublishGradeOnScoreUpdateTest (TestCase ):
@@ -23,19 +26,28 @@ def setUp(self):
2326 "block-v1:course+test+2020+type@problem+block@test"
2427 )
2528
26- # Create configuration
27- self .lti_config = LtiConfiguration .objects .create (
28- location = self .location ,
29- version = LtiConfiguration .LTI_1P3 ,
30- )
31-
3229 # Patch internal method to avoid calls to modulestore
3330 self ._block_mock = Mock ()
34- compat_mock = patch ("lti_consumer.signals.signals .compat" )
31+ compat_mock = patch ("lti_consumer.models .compat" )
3532 self .addCleanup (compat_mock .stop )
3633 self ._compat_mock = compat_mock .start ()
3734 self ._compat_mock .get_user_from_external_user_id .return_value = Mock ()
3835 self ._compat_mock .load_block_as_user .return_value = self ._block_mock
36+ self ._compat_mock .load_enough_xblock .return_value = self ._block_mock
37+ self ._block_mock .lti_1p3_passport_id = "e9feb139-4e4c-4fb1-96ee-e614f1e04356"
38+
39+ signals_compat_mock = patch ("lti_consumer.signals.signals.compat" )
40+ self .addCleanup (signals_compat_mock .stop )
41+ self ._signals_compat_mock = signals_compat_mock .start ()
42+ self ._signals_compat_mock .get_user_from_external_user_id .return_value = Mock ()
43+ self ._signals_compat_mock .load_block_as_user .return_value = self ._block_mock
44+ self ._signals_compat_mock .load_enough_xblock .return_value = self ._block_mock
45+ self ._block_mock .lti_1p3_passport_id = "e9feb139-4e4c-4fb1-96ee-e614f1e04356"
46+ # Create configuration
47+ self .lti_config = LtiConfiguration .objects .create (
48+ location = self .location ,
49+ version = LtiConfiguration .LTI_1P3 ,
50+ )
3951
4052 def test_grade_publish_not_done_when_wrong_line_item (self ):
4153 """
@@ -96,5 +108,304 @@ def test_grade_publish(self):
96108
97109 # Check that methods to save grades are called
98110 self ._block_mock .set_user_module_score .assert_called_once ()
99- self ._compat_mock .get_user_from_external_user_id .assert_called_once ()
100- self ._compat_mock .load_block_as_user .assert_called_once ()
111+ self ._signals_compat_mock .get_user_from_external_user_id .assert_called_once ()
112+ self ._signals_compat_mock .load_block_as_user .assert_called_once ()
113+
114+
115+ @ddt
116+ class TestDeleteLtiConfiguration (TestCase ):
117+ """Tests for delete_lti_configuration function."""
118+
119+ def setUp (self ):
120+ """Set up test fixtures."""
121+ self .mock_usage_key = UsageKey .from_string ("block-v1:course+101+2024+type@lti_consumer+block@test" )
122+
123+ self .xblock_data = Mock (spec = XBlockData )
124+ self .xblock_data .usage_key = self .mock_usage_key
125+
126+ @patch ('lti_consumer.signals.signals.Lti1p3Passport' )
127+ @patch ('lti_consumer.signals.signals.LtiConfiguration' )
128+ @patch ('lti_consumer.signals.signals.log' )
129+ def test_delete_lti_configuration_success (self , mock_log , mock_lti_config , mock_passport ):
130+ """Test successful deletion with various passport counts."""
131+ mock_lti_config .objects .filter .return_value .delete .return_value = None
132+
133+ # Test with multiple passports deleted
134+ mock_passport .objects .filter .return_value .delete .return_value = (5 , {'Lti1p3Passport' : 5 })
135+ delete_lti_configuration (xblock_info = self .xblock_data )
136+
137+ mock_lti_config .objects .filter .assert_called_with (location = str (self .xblock_data .usage_key ))
138+ mock_passport .objects .filter .assert_called_with (lticonfiguration__isnull = True )
139+ assert mock_log .info .call_count == 1
140+ assert "5" in mock_log .info .call_args [0 ][0 ]
141+
142+ # Reset and test with no passports deleted
143+ mock_log .reset_mock ()
144+ mock_lti_config .reset_mock ()
145+ mock_passport .reset_mock ()
146+ mock_lti_config .objects .filter .return_value .delete .return_value = None
147+ mock_passport .objects .filter .return_value .delete .return_value = (0 , {})
148+
149+ delete_lti_configuration (xblock_info = self .xblock_data )
150+ assert "0" in mock_log .info .call_args [0 ][0 ]
151+
152+ @data (
153+ None ,
154+ "invalid_string" ,
155+ {"usage_key" : "test" },
156+ 123 ,
157+ )
158+ @patch ('lti_consumer.signals.signals.log' )
159+ def test_delete_lti_configuration_invalid_input (self , invalid_input , mock_log ):
160+ """Test with invalid xblock_info inputs."""
161+ delete_lti_configuration (xblock_info = invalid_input )
162+ mock_log .error .assert_called_once_with ("Received null or incorrect data for event" )
163+
164+ @patch ('lti_consumer.signals.signals.log' )
165+ def test_delete_lti_configuration_missing_xblock_info (self , mock_log ):
166+ """Test with missing xblock_info kwarg."""
167+ delete_lti_configuration ()
168+ mock_log .error .assert_called_once_with ("Received null or incorrect data for event" )
169+
170+ @patch ('lti_consumer.signals.signals.Lti1p3Passport' )
171+ @patch ('lti_consumer.signals.signals.LtiConfiguration' )
172+ @patch ('lti_consumer.signals.signals.log' )
173+ def test_delete_lti_configuration_extra_kwargs_ignored (self , mock_log , mock_lti_config , mock_passport ):
174+ """Test that extra kwargs are safely ignored."""
175+ mock_lti_config .objects .filter .return_value .delete .return_value = None
176+ mock_passport .objects .filter .return_value .delete .return_value = (0 , {})
177+
178+ delete_lti_configuration (
179+ xblock_info = self .xblock_data ,
180+ extra_param = "ignored" ,
181+ another_param = 123
182+ )
183+
184+ mock_lti_config .objects .filter .assert_called_once ()
185+ mock_log .error .assert_not_called ()
186+
187+
188+ @ddt
189+ class TestDeleteChildLtiConfigurations (TestCase ):
190+ """Tests for delete_child_lti_configurations function."""
191+ def setUp (self ):
192+ """Set up test fixtures."""
193+ self .usage_key = UsageKey .from_string ("block-v1:course+test+2020+type@problem+block@parent" )
194+
195+ def _create_child_block (self , block_id ):
196+ """Helper to create a mock child block."""
197+ child = Mock ()
198+ child .location = UsageKey .from_string (
199+ f"block-v1:course+test+2020+type@problem+block@{ block_id } "
200+ )
201+ return child
202+
203+ def _setup_mocks (self , children_count = 0 , passport_count = 0 , load_error = None ):
204+ """Helper to setup common mock patches."""
205+ parent_block = Mock ()
206+ parent_block .location = self .usage_key
207+
208+ children = [self ._create_child_block (f"child{ i } " ) for i in range (children_count )]
209+
210+ patches = {
211+ 'compat' : patch ("lti_consumer.signals.signals.compat" ),
212+ 'lti_config' : patch ("lti_consumer.signals.signals.LtiConfiguration" ),
213+ 'passport' : patch ("lti_consumer.signals.signals.Lti1p3Passport" ),
214+ 'log' : patch ("lti_consumer.signals.signals.log" ),
215+ }
216+
217+ mocks = {name : p .start () for name , p in patches .items ()}
218+ self .addCleanup (lambda : [p .stop () for p in patches .values ()])
219+
220+ if load_error :
221+ mocks ['compat' ].load_enough_xblock .side_effect = load_error
222+ else :
223+ mocks ['compat' ].load_enough_xblock .return_value = parent_block
224+ mocks ['compat' ].yield_dynamic_block_descendants .return_value = children
225+
226+ mocks ['lti_config' ].objects .filter .return_value .delete .return_value = None
227+ mocks ['passport' ].objects .filter .return_value .delete .return_value = (
228+ passport_count ,
229+ {'Lti1p3Passport' : passport_count } if passport_count else {}
230+ )
231+
232+ return mocks , parent_block , children
233+
234+ @data (
235+ (0 , 0 ), # no children, no passports deleted
236+ (2 , 2 ), # 2 children, 2 passports deleted
237+ (1 , 5 ), # 1 child, 5 passports deleted
238+ )
239+ @unpack
240+ def test_delete_child_lti_configurations_success (self , children_count , passport_count ):
241+ """Test successful deletion with various child/passport counts."""
242+ from lti_consumer .signals .signals import delete_child_lti_configurations
243+
244+ mocks , parent_block , children = self ._setup_mocks (children_count , passport_count )
245+
246+ delete_child_lti_configurations (usage_key = self .usage_key , user_id = "test_user" )
247+
248+ # Verify load_enough_xblock called with stripped branch
249+ mocks ['compat' ].load_enough_xblock .assert_called_once_with (self .usage_key .for_branch (None ))
250+
251+ # Verify descendants iterator called
252+ mocks ['compat' ].yield_dynamic_block_descendants .assert_called_once_with (parent_block , "test_user" )
253+
254+ # Verify correct locations in filter
255+ call_args = mocks ['lti_config' ].objects .filter .call_args
256+ locations = call_args [1 ]['location__in' ]
257+ assert len (locations ) == children_count + 1 # parent + children
258+ assert str (parent_block .location ) in locations
259+ for child in children :
260+ assert str (child .location ) in locations
261+
262+ # Verify deletion logged
263+ assert mocks ['log' ].info .call_count >= 1
264+
265+ @data (
266+ None ,
267+ UsageKey .from_string ("block-v1:course+test+2020+type@problem+block@parent" ).for_branch ("branch" ),
268+ )
269+ def test_delete_child_lti_configurations_invalid_usage_key (self , usage_key ):
270+ """Test with None or missing usage_key."""
271+ from lti_consumer .signals .signals import delete_child_lti_configurations
272+
273+ mocks , _ , _ = self ._setup_mocks ()
274+
275+ if usage_key is None :
276+ delete_child_lti_configurations (usage_key = None )
277+ else :
278+ # Test branch stripping
279+ delete_child_lti_configurations (usage_key = usage_key , user_id = "test_user" )
280+ mocks ['compat' ].load_enough_xblock .assert_called_once_with (self .usage_key .for_branch (None ))
281+
282+ @data (
283+ Exception ("Block not found" ),
284+ ValueError ("Invalid block" ),
285+ RuntimeError ("Load failed" ),
286+ )
287+ def test_delete_child_lti_configurations_load_block_fails (self , error ):
288+ """Test when load_enough_xblock raises exceptions."""
289+ from lti_consumer .signals .signals import delete_child_lti_configurations
290+
291+ mocks , _ , _ = self ._setup_mocks (load_error = error )
292+
293+ delete_child_lti_configurations (usage_key = self .usage_key , user_id = "test_user" )
294+
295+ # Verify warning logged with error details
296+ mocks ['log' ].warning .assert_called_once ()
297+ warning_msg = mocks ['log' ].warning .call_args [0 ][0 ]
298+ assert "Cannot find xblock for key" in warning_msg
299+ assert str (error ) in warning_msg
300+
301+ # Verify no deletion attempted
302+ mocks ['lti_config' ].objects .filter .assert_not_called ()
303+
304+ def test_delete_child_lti_configurations_no_usage_key (self ):
305+ """Test when usage_key is not provided."""
306+ with patch ("lti_consumer.signals.signals.log" ) as mock_log :
307+ from lti_consumer .signals .signals import delete_child_lti_configurations
308+ delete_child_lti_configurations (usage_key = None )
309+
310+ # Should return early without logging
311+ mock_log .warning .assert_not_called ()
312+ mock_log .info .assert_not_called ()
313+
314+
315+ @ddt
316+ class TestDeleteLibLtiConfiguration (TestCase ):
317+ """Tests for delete_lib_lti_configuration function."""
318+
319+ def setUp (self ):
320+ """Set up test fixtures."""
321+ self .library_block = Mock (spec = LibraryBlockData )
322+ self .library_block .usage_key = UsageKey .from_string (
323+ "lb:TestOrg:TestLibrary:problem:test_problem"
324+ )
325+
326+ def _setup_mocks (self , passport_count = 0 ):
327+ """Helper to setup common mock patches."""
328+ patches = {
329+ 'lti_config' : patch ("lti_consumer.signals.signals.LtiConfiguration" ),
330+ 'passport' : patch ("lti_consumer.signals.signals.Lti1p3Passport" ),
331+ 'log' : patch ("lti_consumer.signals.signals.log" ),
332+ }
333+
334+ mocks = {name : p .start () for name , p in patches .items ()}
335+ self .addCleanup (lambda : [p .stop () for p in patches .values ()])
336+
337+ mocks ['lti_config' ].objects .filter .return_value .delete .return_value = None
338+ mocks ['passport' ].objects .filter .return_value .delete .return_value = (
339+ passport_count ,
340+ {'Lti1p3Passport' : passport_count } if passport_count else {}
341+ )
342+
343+ return mocks
344+
345+ @data (0 , 1 , 5 , 3 )
346+ def test_delete_lib_lti_configuration_success (self , passport_count ):
347+ """Test successful deletion with various passport counts."""
348+ from lti_consumer .signals .signals import delete_lib_lti_configuration
349+
350+ mocks = self ._setup_mocks (passport_count )
351+
352+ delete_lib_lti_configuration (library_block = self .library_block )
353+
354+ # Verify LtiConfiguration filter called with correct location
355+ mocks ['lti_config' ].objects .filter .assert_called_once_with (
356+ location = str (self .library_block .usage_key )
357+ )
358+
359+ # Verify orphaned passports deleted
360+ mocks ['passport' ].objects .filter .assert_called_once_with (lticonfiguration__isnull = True )
361+
362+ # Verify info logged with passport count
363+ mocks ['log' ].info .assert_called_once ()
364+ log_msg = mocks ['log' ].info .call_args [0 ][0 ]
365+ assert str (passport_count ) in log_msg
366+ if passport_count > 0 :
367+ assert "lti 1.3 passport" in log_msg
368+
369+ @data (
370+ None ,
371+ "invalid_string" ,
372+ {"usage_key" : "test" },
373+ 123 ,
374+ Mock (), # Mock without LibraryBlockData spec
375+ )
376+ def test_delete_lib_lti_configuration_invalid_input (self , library_block ):
377+ """Test with invalid library_block inputs."""
378+ from lti_consumer .signals .signals import delete_lib_lti_configuration
379+
380+ mocks = self ._setup_mocks ()
381+
382+ delete_lib_lti_configuration (library_block = library_block )
383+
384+ mocks ['log' ].error .assert_called_once_with ("Received null or incorrect data for event" )
385+ mocks ['lti_config' ].objects .filter .assert_not_called ()
386+
387+ def test_delete_lib_lti_configuration_missing_library_block (self ):
388+ """Test when library_block kwarg is missing."""
389+ from lti_consumer .signals .signals import delete_lib_lti_configuration
390+
391+ mocks = self ._setup_mocks ()
392+
393+ delete_lib_lti_configuration ()
394+
395+ mocks ['log' ].error .assert_called_once_with ("Received null or incorrect data for event" )
396+
397+ def test_delete_lib_lti_configuration_extra_kwargs_ignored (self ):
398+ """Test that extra kwargs are safely ignored."""
399+ from lti_consumer .signals .signals import delete_lib_lti_configuration
400+
401+ mocks = self ._setup_mocks (passport_count = 2 )
402+
403+ delete_lib_lti_configuration (
404+ library_block = self .library_block ,
405+ extra_param = "ignored" ,
406+ another_param = 123
407+ )
408+
409+ mocks ['lti_config' ].objects .filter .assert_called_once ()
410+ mocks ['log' ].info .assert_called_once ()
411+
0 commit comments