Skip to content

Commit 1369128

Browse files
committed
test: add signal tests
1 parent f28c502 commit 1369128

File tree

1 file changed

+321
-10
lines changed

1 file changed

+321
-10
lines changed

lti_consumer/tests/unit/test_signals.py

Lines changed: 321 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""
2-
Tests for LTI Advantage Assignments and Grades Service views.
2+
Tests for LTI Consumer signal handlers.
33
"""
44
from datetime import datetime
55
from unittest.mock import Mock, patch
66

7+
from ddt import data, ddt, unpack
78
from django.test import TestCase
89
from opaque_keys.edx.keys import UsageKey
10+
from openedx_events.content_authoring.data import LibraryBlockData, XBlockData
911

1012
from lti_consumer.models import LtiAgsLineItem, LtiAgsScore, LtiConfiguration
13+
from lti_consumer.signals.signals import delete_lti_configuration
1114

1215

1316
class 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

Comments
 (0)