test_signals.py 9.21 KB
Newer Older
1 2 3
"""
Tests for the score change signals defined in the courseware models module.
"""
4
import re
5
from datetime import datetime
6

7
import ddt
8
import pytz
9 10 11 12
from django.test import TestCase
from mock import MagicMock, patch

from submissions.models import score_reset, score_set
13
from util.date_utils import to_timestamp
14

15
from ..constants import ScoreDatabaseTableEnum
16
from ..signals.handlers import (
17
    disconnect_submissions_signal_receiver,
18
    problem_raw_score_changed_handler,
19
    submissions_score_reset_handler,
20
    submissions_score_set_handler,
21
)
22
from ..signals.signals import PROBLEM_RAW_SCORE_CHANGED
23

24
UUID_REGEX = re.compile(ur'%(hex)s{8}-%(hex)s{4}-%(hex)s{4}-%(hex)s{4}-%(hex)s{12}' % {'hex': u'[0-9a-f]'})
25

26 27 28
FROZEN_NOW_DATETIME = datetime.now().replace(tzinfo=pytz.UTC)
FROZEN_NOW_TIMESTAMP = to_timestamp(FROZEN_NOW_DATETIME)

Jeremy Bowman committed
29 30 31 32 33
SUBMISSIONS_SCORE_SET_HANDLER = 'submissions_score_set_handler'
SUBMISSIONS_SCORE_RESET_HANDLER = 'submissions_score_reset_handler'
HANDLERS = {
    SUBMISSIONS_SCORE_SET_HANDLER: submissions_score_set_handler,
    SUBMISSIONS_SCORE_RESET_HANDLER: submissions_score_reset_handler,
34 35
}

Jeremy Bowman committed
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
SUBMISSION_SET_KWARGS = 'submission_set_kwargs'
SUBMISSION_RESET_KWARGS = 'submission_reset_kwargs'
SUBMISSION_KWARGS = {
    SUBMISSION_SET_KWARGS: {
        'points_possible': 10,
        'points_earned': 5,
        'anonymous_user_id': 'anonymous_id',
        'course_id': 'CourseID',
        'item_id': 'i4x://org/course/usage/123456',
        'created_at': FROZEN_NOW_TIMESTAMP,
    },
    SUBMISSION_RESET_KWARGS: {
        'anonymous_user_id': 'anonymous_id',
        'course_id': 'CourseID',
        'item_id': 'i4x://org/course/usage/123456',
        'created_at': FROZEN_NOW_TIMESTAMP,
    },
53 54
}

55 56 57 58 59 60 61 62 63
PROBLEM_RAW_SCORE_CHANGED_KWARGS = {
    'raw_earned': 1.0,
    'raw_possible': 2.0,
    'weight': 4,
    'user_id': 'UserID',
    'course_id': 'CourseID',
    'usage_id': 'i4x://org/course/usage/123456',
    'only_if_higher': False,
    'score_deleted': True,
64
    'modified': FROZEN_NOW_TIMESTAMP,
65 66 67 68 69 70 71 72 73 74 75 76 77 78
    'score_db_table': ScoreDatabaseTableEnum.courseware_student_module,
}

PROBLEM_WEIGHTED_SCORE_CHANGED_KWARGS = {
    'sender': None,
    'weighted_earned': 2.0,
    'weighted_possible': 4.0,
    'user_id': 'UserID',
    'course_id': 'CourseID',
    'usage_id': 'i4x://org/course/usage/123456',
    'only_if_higher': False,
    'score_deleted': True,
    'modified': FROZEN_NOW_TIMESTAMP,
    'score_db_table': ScoreDatabaseTableEnum.courseware_student_module,
79 80
}

81

82
@ddt.ddt
83
class ScoreChangedSignalRelayTest(TestCase):
84
    """
85 86 87 88 89 90 91
    Tests to ensure that the courseware module correctly catches
    (a) score_set and score_reset signals from the Submissions API
    (b) LMS PROBLEM_RAW_SCORE_CHANGED signals
    and recasts them as LMS PROBLEM_WEIGHTED_SCORE_CHANGED signals.

    This ensures that listeners in the LMS only have to handle one type
    of signal for all scoring events regardless of their origin.
92
    """
Jeremy Bowman committed
93 94 95 96
    SIGNALS = {
        'score_set': score_set,
        'score_reset': score_reset,
    }
97 98 99 100 101

    def setUp(self):
        """
        Configure mocks for all the dependencies of the render method
        """
102 103 104 105 106
        super(ScoreChangedSignalRelayTest, self).setUp()
        self.signal_mock = self.setup_patch(
            'lms.djangoapps.grades.signals.signals.PROBLEM_WEIGHTED_SCORE_CHANGED.send',
            None,
        )
107 108
        self.user_mock = MagicMock()
        self.user_mock.id = 42
109 110 111 112
        self.get_user_mock = self.setup_patch(
            'lms.djangoapps.grades.signals.handlers.user_by_anonymous_id',
            self.user_mock
        )
113 114 115 116 117 118 119 120 121 122 123

    def setup_patch(self, function_name, return_value):
        """
        Patch a function with a given return value, and return the mock
        """
        mock = MagicMock(return_value=return_value)
        new_patch = patch(function_name, new=mock)
        new_patch.start()
        self.addCleanup(new_patch.stop)
        return mock

124
    @ddt.data(
Jeremy Bowman committed
125 126
        [SUBMISSIONS_SCORE_SET_HANDLER, SUBMISSION_SET_KWARGS, 5, 10],
        [SUBMISSIONS_SCORE_RESET_HANDLER, SUBMISSION_RESET_KWARGS, 0, 0],
127 128
    )
    @ddt.unpack
Jeremy Bowman committed
129
    def test_score_set_signal_handler(self, handler_name, kwargs, earned, possible):
130
        """
131
        Ensure that on receipt of a score_(re)set signal from the Submissions API,
132 133
        the signal handler correctly converts it to a PROBLEM_WEIGHTED_SCORE_CHANGED
        signal.
134 135

        Also ensures that the handler calls user_by_anonymous_id correctly.
136
        """
Jeremy Bowman committed
137 138 139
        local_kwargs = SUBMISSION_KWARGS[kwargs].copy()
        handler = HANDLERS[handler_name]
        handler(None, **local_kwargs)
140 141
        expected_set_kwargs = {
            'sender': None,
142 143
            'weighted_possible': possible,
            'weighted_earned': earned,
144
            'user_id': self.user_mock.id,
145
            'anonymous_user_id': 'anonymous_id',
146
            'course_id': 'CourseID',
147 148
            'usage_id': 'i4x://org/course/usage/123456',
            'modified': FROZEN_NOW_TIMESTAMP,
149
            'score_db_table': 'submissions',
150
        }
Jeremy Bowman committed
151
        if kwargs == SUBMISSION_RESET_KWARGS:
152
            expected_set_kwargs['score_deleted'] = True
153
        self.signal_mock.assert_called_once_with(**expected_set_kwargs)
Jeremy Bowman committed
154
        self.get_user_mock.assert_called_once_with(local_kwargs['anonymous_user_id'])
155

156 157 158 159 160
    def test_tnl_6599_zero_possible_bug(self):
        """
        Ensure that, if coming from the submissions API, signals indicating a
        a possible score of 0 are swallowed for reasons outlined in TNL-6559.
        """
Jeremy Bowman committed
161
        local_kwargs = SUBMISSION_KWARGS[SUBMISSION_SET_KWARGS].copy()
162 163 164 165 166
        local_kwargs['points_earned'] = 0
        local_kwargs['points_possible'] = 0
        submissions_score_set_handler(None, **local_kwargs)
        self.signal_mock.assert_not_called()

167
    @ddt.data(
Jeremy Bowman committed
168 169
        [SUBMISSIONS_SCORE_SET_HANDLER, SUBMISSION_SET_KWARGS],
        [SUBMISSIONS_SCORE_RESET_HANDLER, SUBMISSION_RESET_KWARGS]
170 171
    )
    @ddt.unpack
Jeremy Bowman committed
172
    def test_score_set_missing_kwarg(self, handler_name, kwargs):
173
        """
174
        Ensure that, on receipt of a score_(re)set signal from the Submissions API
175 176 177
        that does not have the correct kwargs, the courseware model does not
        generate a signal.
        """
Jeremy Bowman committed
178 179 180
        handler = HANDLERS[handler_name]
        for missing in SUBMISSION_KWARGS[kwargs]:
            local_kwargs = SUBMISSION_KWARGS[kwargs].copy()
181
            del local_kwargs[missing]
182

183
            with self.assertRaises(KeyError):
184
                handler(None, **local_kwargs)
185 186
            self.signal_mock.assert_not_called()

187
    @ddt.data(
Jeremy Bowman committed
188 189
        [SUBMISSIONS_SCORE_SET_HANDLER, SUBMISSION_SET_KWARGS],
        [SUBMISSIONS_SCORE_RESET_HANDLER, SUBMISSION_RESET_KWARGS]
190 191
    )
    @ddt.unpack
Jeremy Bowman committed
192
    def test_score_set_bad_user(self, handler_name, kwargs):
193
        """
194
        Ensure that, on receipt of a score_(re)set signal from the Submissions API
195 196 197
        that has an invalid user ID, the courseware model does not generate a
        signal.
        """
Jeremy Bowman committed
198
        handler = HANDLERS[handler_name]
199
        self.get_user_mock = self.setup_patch('lms.djangoapps.grades.signals.handlers.user_by_anonymous_id', None)
Jeremy Bowman committed
200
        handler(None, **SUBMISSION_KWARGS[kwargs])
201
        self.signal_mock.assert_not_called()
202

203 204
    def test_raw_score_changed_signal_handler(self):
        problem_raw_score_changed_handler(None, **PROBLEM_RAW_SCORE_CHANGED_KWARGS)
205
        expected_set_kwargs = PROBLEM_WEIGHTED_SCORE_CHANGED_KWARGS.copy()
206 207 208 209 210 211
        self.signal_mock.assert_called_with(**expected_set_kwargs)

    def test_raw_score_changed_score_deleted_optional(self):
        local_kwargs = PROBLEM_RAW_SCORE_CHANGED_KWARGS.copy()
        del local_kwargs['score_deleted']
        problem_raw_score_changed_handler(None, **local_kwargs)
212 213
        expected_set_kwargs = PROBLEM_WEIGHTED_SCORE_CHANGED_KWARGS.copy()
        expected_set_kwargs['score_deleted'] = False
214 215
        self.signal_mock.assert_called_with(**expected_set_kwargs)

216
    @ddt.data(
Jeremy Bowman committed
217 218 219 220
        ['score_set', 'lms.djangoapps.grades.signals.handlers.submissions_score_set_handler',
         SUBMISSION_SET_KWARGS],
        ['score_reset', 'lms.djangoapps.grades.signals.handlers.submissions_score_reset_handler',
         SUBMISSION_RESET_KWARGS]
221 222
    )
    @ddt.unpack
Jeremy Bowman committed
223
    def test_disconnect_manager(self, signal_name, handler, kwargs):
224 225 226
        """
        Tests to confirm the disconnect_submissions_signal_receiver context manager is working correctly.
        """
Jeremy Bowman committed
227 228
        signal = self.SIGNALS[signal_name]
        kwargs = SUBMISSION_KWARGS[kwargs].copy()
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
        handler_mock = self.setup_patch(handler, None)

        # Receiver connected before we start
        signal.send(None, **kwargs)
        handler_mock.assert_called_once()
        handler_mock.reset_mock()

        # Disconnect is functioning
        with disconnect_submissions_signal_receiver(signal):
            signal.send(None, **kwargs)
            handler_mock.assert_not_called()
            handler_mock.reset_mock()

        # And we reconnect properly afterwards
        signal.send(None, **kwargs)
        handler_mock.assert_called_once()

    def test_disconnect_manager_bad_arg(self):
        """
        Tests that the disconnect context manager errors when given an invalid signal.
        """
        with self.assertRaises(ValueError):
            with disconnect_submissions_signal_receiver(PROBLEM_RAW_SCORE_CHANGED):
                pass