test_err_handling.py 9.69 KB
Newer Older
1 2 3
"""
Unit tests for handling email sending errors
"""
4
from itertools import cycle
5
from mock import patch
6 7
from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError

8 9
from django.test.utils import override_settings
from django.conf import settings
10
from django.core.management import call_command
11
from django.core.urlresolvers import reverse
12

13

14 15 16 17
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory
18

19 20
from bulk_email.models import CourseEmail, SEND_TO_ALL
from bulk_email.tasks import perform_delegate_email_batches, send_course_email
21
from instructor_task.models import InstructorTask
22
from instructor_task.subtasks import create_subtask_status
23 24


25
class EmailTestException(Exception):
Sarina Canelake committed
26
    """Mock exception for email testing."""
27 28 29
    pass


30
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
31
class TestEmailErrors(ModuleStoreTestCase):
32 33 34 35
    """
    Test that errors from sending email are handled properly.
    """

36 37
    def setUp(self):
        self.course = CourseFactory.create()
38 39
        self.instructor = AdminFactory.create()
        self.client.login(username=self.instructor.username, password="test")
40

41 42
        # load initial content (since we don't run migrations as part of tests):
        call_command("loaddata", "course_email_template.json")
43 44
        self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id})

45
    def tearDown(self):
46
        patch.stopall()
47

48
    @patch('bulk_email.tasks.get_connection', autospec=True)
49
    @patch('bulk_email.tasks.send_course_email.retry')
50
    def test_data_err_retry(self, retry, get_conn):
51 52 53
        """
        Test that celery handles transient SMTPDataErrors by retrying.
        """
54
        get_conn.return_value.send_messages.side_effect = SMTPDataError(455, "Throttling: Sending rate exceeded")
55 56 57 58 59 60 61 62 63
        test_email = {
            'action': 'Send email',
            'to_option': 'myself',
            'subject': 'test subject for myself',
            'message': 'test message for myself'
        }
        self.client.post(self.url, test_email)

        # Test that we retry upon hitting a 4xx error
64 65 66
        self.assertTrue(retry.called)
        (_, kwargs) = retry.call_args
        exc = kwargs['exc']
67
        self.assertIsInstance(exc, SMTPDataError)
68

69
    @patch('bulk_email.tasks.get_connection', autospec=True)
70
    @patch('bulk_email.tasks.increment_subtask_status')
71
    @patch('bulk_email.tasks.send_course_email.retry')
72
    def test_data_err_fail(self, retry, result, get_conn):
73 74 75
        """
        Test that celery handles permanent SMTPDataErrors by failing and not retrying.
        """
76
        # have every fourth email fail due to blacklisting:
77
        get_conn.return_value.send_messages.side_effect = cycle([SMTPDataError(554, "Email address is blacklisted"),
78
                                                                 None, None, None])
79
        students = [UserFactory() for _ in xrange(settings.BULK_EMAIL_EMAILS_PER_TASK)]
80 81 82
        for student in students:
            CourseEnrollmentFactory.create(user=student, course_id=self.course.id)

83 84 85 86 87 88 89
        test_email = {
            'action': 'Send email',
            'to_option': 'all',
            'subject': 'test subject for all',
            'message': 'test message for all'
        }
        self.client.post(self.url, test_email)
90

91 92 93
        # We shouldn't retry when hitting a 5xx error
        self.assertFalse(retry.called)
        # Test that after the rejected email, the rest still successfully send
94 95
        ((_initial_results), kwargs) = result.call_args
        self.assertEquals(kwargs['skipped'], 0)
96
        expected_fails = int((settings.BULK_EMAIL_EMAILS_PER_TASK + 3) / 4.0)
97
        self.assertEquals(kwargs['failed'], expected_fails)
98
        self.assertEquals(kwargs['succeeded'], settings.BULK_EMAIL_EMAILS_PER_TASK - expected_fails)
99

100
    @patch('bulk_email.tasks.get_connection', autospec=True)
101
    @patch('bulk_email.tasks.send_course_email.retry')
102
    def test_disconn_err_retry(self, retry, get_conn):
103 104 105
        """
        Test that celery handles SMTPServerDisconnected by retrying.
        """
106
        get_conn.return_value.open.side_effect = SMTPServerDisconnected(425, "Disconnecting")
107 108 109 110 111 112 113 114
        test_email = {
            'action': 'Send email',
            'to_option': 'myself',
            'subject': 'test subject for myself',
            'message': 'test message for myself'
        }
        self.client.post(self.url, test_email)

115 116 117
        self.assertTrue(retry.called)
        (_, kwargs) = retry.call_args
        exc = kwargs['exc']
118
        self.assertIsInstance(exc, SMTPServerDisconnected)
119

120
    @patch('bulk_email.tasks.get_connection', autospec=True)
121
    @patch('bulk_email.tasks.send_course_email.retry')
122
    def test_conn_err_retry(self, retry, get_conn):
123 124 125
        """
        Test that celery handles SMTPConnectError by retrying.
        """
126
        get_conn.return_value.open.side_effect = SMTPConnectError(424, "Bad Connection")
127 128 129 130 131 132 133 134 135

        test_email = {
            'action': 'Send email',
            'to_option': 'myself',
            'subject': 'test subject for myself',
            'message': 'test message for myself'
        }
        self.client.post(self.url, test_email)

136 137 138
        self.assertTrue(retry.called)
        (_, kwargs) = retry.call_args
        exc = kwargs['exc']
139
        self.assertIsInstance(exc, SMTPConnectError)
140

141
    @patch('bulk_email.tasks.increment_subtask_status')
142
    @patch('bulk_email.tasks.log')
143
    def test_nonexistent_email(self, mock_log, result):
144 145 146
        """
        Tests retries when the email doesn't exist
        """
147 148 149 150 151
        # create an InstructorTask object to pass through
        course_id = self.course.id
        entry = InstructorTask.create(course_id, "task_type", "task_key", "task_input", self.instructor)
        task_input = {"email_id": -1}
        with self.assertRaises(CourseEmail.DoesNotExist):
152
            perform_delegate_email_batches(entry.id, course_id, task_input, "action_name")  # pylint: disable=E1101
153
        ((log_str, _, email_id), _) = mock_log.warning.call_args
154 155 156 157 158
        self.assertTrue(mock_log.warning.called)
        self.assertIn('Failed to get CourseEmail with id', log_str)
        self.assertEqual(email_id, -1)
        self.assertFalse(result.called)

159
    def test_nonexistent_course(self):
160 161 162
        """
        Tests exception when the course in the email doesn't exist
        """
163 164
        course_id = "I/DONT/EXIST"
        email = CourseEmail(course_id=course_id)
165
        email.save()
166
        entry = InstructorTask.create(course_id, "task_type", "task_key", "task_input", self.instructor)
167
        task_input = {"email_id": email.id}  # pylint: disable=E1101
168
        with self.assertRaisesRegexp(ValueError, "Course not found"):
169
            perform_delegate_email_batches(entry.id, course_id, task_input, "action_name")  # pylint: disable=E1101
170

171
    def test_nonexistent_to_option(self):
172 173 174 175 176
        """
        Tests exception when the to_option in the email doesn't exist
        """
        email = CourseEmail(course_id=self.course.id, to_option="IDONTEXIST")
        email.save()
177
        entry = InstructorTask.create(self.course.id, "task_type", "task_key", "task_input", self.instructor)
178
        task_input = {"email_id": email.id}  # pylint: disable=E1101
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201
        with self.assertRaisesRegexp(Exception, 'Unexpected bulk email TO_OPTION found: IDONTEXIST'):
            perform_delegate_email_batches(entry.id, self.course.id, task_input, "action_name")  # pylint: disable=E1101

    def test_wrong_course_id_in_task(self):
        """
        Tests exception when the course_id in task is not the same as one explicitly passed in.
        """
        email = CourseEmail(course_id=self.course.id, to_option=SEND_TO_ALL)
        email.save()
        entry = InstructorTask.create("bogus_task_id", "task_type", "task_key", "task_input", self.instructor)
        task_input = {"email_id": email.id}  # pylint: disable=E1101
        with self.assertRaisesRegexp(ValueError, 'does not match task value'):
            perform_delegate_email_batches(entry.id, self.course.id, task_input, "action_name")  # pylint: disable=E1101

    def test_wrong_course_id_in_email(self):
        """
        Tests exception when the course_id in CourseEmail is not the same as one explicitly passed in.
        """
        email = CourseEmail(course_id="bogus_course_id", to_option=SEND_TO_ALL)
        email.save()
        entry = InstructorTask.create(self.course.id, "task_type", "task_key", "task_input", self.instructor)
        task_input = {"email_id": email.id}  # pylint: disable=E1101
        with self.assertRaisesRegexp(ValueError, 'does not match email value'):
202
            perform_delegate_email_batches(entry.id, self.course.id, task_input, "action_name")  # pylint: disable=E1101
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217

    def test_send_email_undefined_email(self):
        # test at a lower level, to ensure that the course gets checked down below too.
        entry = InstructorTask.create(self.course.id, "task_type", "task_key", "task_input", self.instructor)
        entry_id = entry.id  # pylint: disable=E1101
        to_list = ['test@test.com']
        global_email_context = {'course_title': 'dummy course'}
        subtask_id = "subtask-id-value"
        subtask_status = create_subtask_status(subtask_id)
        bogus_email_id = 1001
        with self.assertRaises(CourseEmail.DoesNotExist):
            # we skip the call that updates subtask status, since we've not set up the InstructorTask
            # for the subtask, and it's not important to the test.
            with patch('bulk_email.tasks.update_subtask_status'):
                send_course_email(entry_id, bogus_email_id, to_list, global_email_context, subtask_status)