Files
edx-platform/lms/djangoapps/bulk_email/tests/test_email.py
2021-04-02 12:08:51 -04:00

700 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unit tests for sending course email
"""
import json
import os
from unittest import skipIf
from unittest.mock import Mock, patch
import ddt
from django.conf import settings
from django.core import mail
from django.core.mail.message import forbid_multi_line_headers
from django.core.management import call_command
from django.test.utils import override_settings
from django.urls import reverse
from django.utils.translation import get_language
from markupsafe import escape
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.bulk_email.tasks import _get_course_email_context, _get_source_address
from lms.djangoapps.courseware.tests.factories import InstructorFactory, StaffFactory
from lms.djangoapps.instructor_task.subtasks import update_subtask_status
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
from openedx.core.djangoapps.course_groups.models import CourseCohort
from openedx.core.djangoapps.enrollments.api import update_enrollment
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..models import BulkEmailFlag, Optout
STAFF_COUNT = 3
STUDENT_COUNT = 10
LARGE_NUM_EMAILS = 137
class MockCourseEmailResult:
"""
A small closure-like class to keep count of emails sent over all tasks, recorded
by mock object side effects
"""
emails_sent = 0
def get_mock_update_subtask_status(self):
"""Wrapper for mock email function."""
def mock_update_subtask_status(entry_id, current_task_id, new_subtask_status):
"""Increments count of number of emails sent."""
self.emails_sent += new_subtask_status.succeeded
return update_subtask_status(entry_id, current_task_id, new_subtask_status)
return mock_update_subtask_status
class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
"""
Test that emails send correctly.
"""
def create_staff_and_instructor(self):
"""
Creates one instructor and several course staff for self.course. Assigns
them to self.instructor (single user) and self.staff (list of users),
respectively.
"""
self.instructor = InstructorFactory(course_key=self.course.id)
self.staff = [
StaffFactory(course_key=self.course.id) for __ in range(STAFF_COUNT)
]
def create_students(self):
"""
Creates users and enrolls them in self.course. Assigns these users to
self.students.
"""
self.students = [UserFactory() for _ in range(STUDENT_COUNT)]
for student in self.students:
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
def login_as_user(self, user):
"""
Log in self.client as user.
"""
self.client.login(username=user.username, password="test")
def goto_instructor_dash_email_view(self):
"""
Goes to the instructor dashboard to verify that the email section is
there.
"""
url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course.id)})
# Response loads the whole instructor dashboard, so no need to explicitly
# navigate to a particular email section
response = self.client.get(url)
email_section = '<div class="vert-left send-email" id="section-send-email">'
# If this fails, it is likely because bulk_email.api.is_bulk_email_feature_enabled is set to False
self.assertContains(response, email_section)
@classmethod
def setUpClass(cls):
super().setUpClass()
course_title = "ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
cls.course = CourseFactory.create(
display_name=course_title,
default_store=ModuleStoreEnum.Type.split
)
def setUp(self):
super().setUp()
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False)
self.create_staff_and_instructor()
self.create_students()
# load initial content (since we don't run migrations as part of tests):
call_command("loaddata", "course_email_template.json")
self.login_as_user(self.instructor)
# Pulling up the instructor dash email view here allows us to test sending emails in tests
self.goto_instructor_dash_email_view()
self.send_mail_url = reverse(
'send_email', kwargs={'course_id': str(self.course.id)}
)
self.success_content = {
'course_id': str(self.course.id),
'success': True,
}
def tearDown(self):
super().tearDown()
BulkEmailFlag.objects.all().delete()
class SendEmailWithMockedUgettextMixin:
"""
Mock uggetext for EmailSendFromDashboardTestCase.
"""
def send_email(self):
"""
Sends a dummy email to check the `from_addr` translation.
"""
test_email = {
'action': 'send',
'send_to': '["myself"]',
'subject': 'test subject for myself',
'message': 'test message for myself'
}
def mock_ugettext(text):
"""
Mocks ugettext to return the lang code with the original string.
e.g.
>>> mock_ugettext('Hello') == 'AR Hello'
"""
return '{lang} {text}'.format(
lang=get_language().upper(),
text=text,
)
with patch('lms.djangoapps.bulk_email.tasks._', side_effect=mock_ugettext):
self.client.post(self.send_mail_url, test_email)
return mail.outbox[0]
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
@ddt.ddt
class LocalizedFromAddressPlatformLangTestCase(SendEmailWithMockedUgettextMixin, EmailSendFromDashboardTestCase):
"""
Tests to ensure that the bulk email has the "From" address localized according to LANGUAGE_CODE.
"""
@override_settings(LANGUAGE_CODE='en')
def test_english_platform(self):
"""
Ensures that the source-code language (English) works well.
"""
assert self.course.language is None
# Sanity check
message = self.send_email()
self.assertRegex(message.from_email, '.*Course Staff.*')
@override_settings(LANGUAGE_CODE='eo')
def test_esperanto_platform(self):
"""
Tests the fake Esperanto language to ensure proper gettext calls.
"""
assert self.course.language is None
# Sanity check
message = self.send_email()
self.assertRegex(message.from_email, 'EO .* Course Staff')
@patch.dict(settings.FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True, 'REQUIRE_COURSE_EMAIL_AUTH': False})
@ddt.ddt
class LocalizedFromAddressCourseLangTestCase(SendEmailWithMockedUgettextMixin, EmailSendFromDashboardTestCase):
"""
Test if the bulk email "From" address uses the course.language if present instead of LANGUAGE_CODE.
This is similiar to LocalizedFromAddressTestCase but creating a different test case to allow
changing the class-wide course object.
"""
@classmethod
def setUpClass(cls):
"""
Creates a different course.
"""
super().setUpClass()
course_title = "ẗëṡẗ イэ"
cls.course = CourseFactory.create(
display_name=course_title,
language='ar',
default_store=ModuleStoreEnum.Type.split
)
@override_settings(LANGUAGE_CODE='eo')
def test_esperanto_platform_arabic_course(self):
"""
The course language should override the platform's.
"""
message = self.send_email()
self.assertRegex(message.from_email, 'AR .* Course Staff')
@patch('lms.djangoapps.bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) # lint-amnesty, pylint: disable=line-too-long
class TestEmailSendFromDashboardMockedHtmlToText(EmailSendFromDashboardTestCase):
"""
Tests email sending with mocked html_to_text.
"""
def test_email_disabled(self):
"""
Test response when email is disabled for course.
"""
BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=True)
test_email = {
'action': 'Send email',
'send_to': '["myself"]',
'subject': 'test subject for myself',
'message': 'test message for myself'
}
response = self.client.post(self.send_mail_url, test_email)
# We should get back a HttpResponseForbidden (status code 403)
self.assertContains(response, "Email is not enabled for this course.", status_code=403)
@patch('lms.djangoapps.bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) # lint-amnesty, pylint: disable=line-too-long
def test_send_to_self(self):
"""
Make sure email send to myself goes to myself.
"""
test_email = {
'action': 'send',
'send_to': '["myself"]',
'subject': 'test subject for myself',
'message': 'test message for myself'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
# Check that outbox is as expected
assert len(mail.outbox) == 1
assert len(mail.outbox[0].to) == 1
assert mail.outbox[0].to[0] == self.instructor.email
assert mail.outbox[0].subject == 'test subject for myself'
assert mail.outbox[0].from_email == \
f'"{self.course.display_name}" Course Staff <{self.course.id.course}-no-reply@example.com>'
def test_send_to_staff(self):
"""
Make sure email send to staff and instructors goes there.
"""
test_email = {
'action': 'Send email',
'send_to': '["staff"]',
'subject': 'test subject for staff',
'message': 'test message for subject'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
# the 1 is for the instructor in this test and others
assert len(mail.outbox) == (1 + len(self.staff))
assert len([e.to[0] for e in mail.outbox]) == len([self.instructor.email] + [s.email for s in self.staff])
@override_settings(DEFAULT_FROM_EMAIL='test@example.com', EMAIL_USE_DEFAULT_FROM_FOR_BULK=True)
def test_email_from_address(self):
"""
Make sure the from_address should be the DEFAULT_FROM_EMAIL when corresponding flag is enabled.
"""
test_email = {
'action': 'Send email',
'send_to': '["staff"]',
'subject': 'test subject for staff',
'message': 'test message for subject'
}
self.client.post(self.send_mail_url, test_email)
from_email = mail.outbox[0].from_email
self.assertEqual(
from_email,
'test@example.com'
)
def test_send_to_cohort(self):
"""
Make sure email sent to a cohort goes there.
"""
cohort = CourseCohort.create(cohort_name='test cohort', course_id=self.course.id)
for student in self.students:
add_user_to_cohort(cohort.course_user_group, student.username)
test_email = {
'action': 'Send email',
'send_to': f'["cohort:{cohort.course_user_group.name}"]',
'subject': 'test subject for cohort',
'message': 'test message for cohort'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert len([e.to[0] for e in mail.outbox]) == len([s.email for s in self.students])
def test_send_to_cohort_unenrolled(self):
"""
Make sure email sent to a cohort does not go to unenrolled members of the cohort.
"""
self.students.append(UserFactory()) # user will be added to cohort, but not enrolled in course
cohort = CourseCohort.create(cohort_name='test cohort', course_id=self.course.id)
for student in self.students:
add_user_to_cohort(cohort.course_user_group, student.username)
test_email = {
'action': 'Send email',
'send_to': f'["cohort:{cohort.course_user_group.name}"]',
'subject': 'test subject for cohort',
'message': 'test message for cohort'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert len(mail.outbox) == (len(self.students) - 1)
assert self.students[(- 1)].email not in [e.to[0] for e in mail.outbox]
def test_send_to_track(self):
"""
Make sure email sent to a registration track goes there.
"""
CourseMode.objects.create(mode_slug='test', course_id=self.course.id)
for student in self.students:
update_enrollment(student, str(self.course.id), 'test')
test_email = {
'action': 'Send email',
'send_to': '["track:test"]',
'subject': 'test subject for test track',
'message': 'test message for test track',
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert len([e.to[0] for e in mail.outbox]) == len([s.email for s in self.students])
def test_send_to_track_other_enrollments(self):
"""
Failing test for EDUCATOR-217: verifies that emails are only sent to
users in a specific track if they're in that track in the course the
email is being sent from.
"""
# Create a mode and designate an enrolled user to be placed in that mode
CourseMode.objects.create(mode_slug='test_mode', course_id=self.course.id)
test_mode_student = self.students[0]
update_enrollment(test_mode_student, str(self.course.id), 'test_mode')
# Take another user already enrolled in the course, then enroll them in
# another course but in that same test mode
test_mode_student_other_course = self.students[1]
other_course = CourseFactory.create()
CourseMode.objects.create(mode_slug='test_mode', course_id=other_course.id)
CourseEnrollmentFactory.create(
user=test_mode_student_other_course,
course_id=other_course.id
)
update_enrollment(test_mode_student_other_course, str(other_course.id), 'test_mode')
# Send the emails...
test_email = {
'action': 'Send email',
'send_to': '["track:test_mode"]',
'subject': 'test subject for test_mode track',
'message': 'test message for test_mode track',
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
# Only the the student in the test mode in the course the email was
# sent from should receive an email
assert len(mail.outbox) == 1
assert mail.outbox[0].to[0] == test_mode_student.email
def test_send_to_all(self):
"""
Make sure email send to all goes there.
"""
test_email = {
'action': 'Send email',
'send_to': '["myself", "staff", "learners"]',
'subject': 'test subject for all',
'message': 'test message for all'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
# the 1 is for the instructor
assert len(mail.outbox) == ((1 + len(self.staff)) + len(self.students))
assert len([e.to[0] for e in mail.outbox]) == \
len([self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students])
@override_settings(BULK_EMAIL_JOB_SIZE_THRESHOLD=1)
def test_send_to_all_high_queue(self):
"""
Test that email is still sent when the high priority queue is used
"""
self.test_send_to_all()
def test_no_duplicate_emails_staff_instructor(self):
"""
Test that no duplicate emails are sent to a course instructor that is
also course staff
"""
CourseStaffRole(self.course.id).add_users(self.instructor)
self.test_send_to_all()
def test_no_duplicate_emails_enrolled_staff(self):
"""
Test that no duplicate emails are sent to a course instructor that is
also enrolled in the course
"""
CourseEnrollment.enroll(self.instructor, self.course.id)
self.test_send_to_all()
def test_no_duplicate_emails_unenrolled_staff(self):
"""
Test that no duplicate emails are sent to a course staff that is
not enrolled in the course, but is enrolled in other courses
"""
course_1 = CourseFactory.create()
course_2 = CourseFactory.create()
# make sure self.instructor isn't enrolled in the course
assert not CourseEnrollment.is_enrolled(self.instructor, self.course.id)
CourseEnrollment.enroll(self.instructor, course_1.id)
CourseEnrollment.enroll(self.instructor, course_2.id)
self.test_send_to_all()
def test_unicode_subject_send_to_all(self):
"""
Make sure email (with Unicode characters) send to all goes there.
"""
uni_subject = 'téśt śúbjéćt főŕ áĺĺ'
test_email = {
'action': 'Send email',
'send_to': '["myself", "staff", "learners"]',
'subject': uni_subject,
'message': 'test message for all'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert len(mail.outbox) == ((1 + len(self.staff)) + len(self.students))
assert len([e.to[0] for e in mail.outbox]) ==\
len([self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students])
assert mail.outbox[0].subject == uni_subject
def test_unicode_students_send_to_all(self):
"""
Make sure email (with Unicode characters) send to all goes there.
"""
# Create a student with Unicode in their first & last names
unicode_user = UserFactory(first_name='Ⓡⓞⓑⓞⓣ', last_name='ՇﻉรՇ')
CourseEnrollmentFactory.create(user=unicode_user, course_id=self.course.id)
self.students.append(unicode_user)
test_email = {
'action': 'Send email',
'send_to': '["myself", "staff", "learners"]',
'subject': 'test subject for all',
'message': 'test message for all'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert len(mail.outbox) == ((1 + len(self.staff)) + len(self.students))
assert len([e.to[0] for e in mail.outbox]) ==\
len([self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students])
@override_settings(BULK_EMAIL_DEFAULT_FROM_EMAIL="no-reply@courseupdates.edx.org")
def test_long_course_display_name(self):
"""
This test tests that courses with exorbitantly large display names
can still send emails, since it appears that 320 appears to be the
character length limit of from emails for Amazon SES.
"""
test_email = {
'action': 'Send email',
'send_to': '["myself", "staff", "learners"]',
'subject': 'test subject for self',
'message': 'test message for self'
}
# make display_name that's longer than 320 characters when encoded
# to ascii and escaped, but shorter than 320 unicode characters
long_name = "Финансовое программирование и политика, часть 1: макроэкономические счета и анализ"
course = CourseFactory.create(
display_name=long_name,
org="IMF",
number="FPP.1x",
run="2016",
)
instructor = InstructorFactory(course_key=course.id)
unexpected_from_addr = _get_source_address(
course.id, course.display_name, course_language=None, truncate=False
)
__, encoded_unexpected_from_addr = forbid_multi_line_headers(
"from", unexpected_from_addr, 'utf-8'
)
escaped_encoded_unexpected_from_addr = escape(encoded_unexpected_from_addr)
# it's shorter than 320 characters when just encoded
assert len(encoded_unexpected_from_addr) == 318
# escaping it brings it over that limit
assert len(escaped_encoded_unexpected_from_addr) == 324
# when not escaped or encoded, it's well below 320 characters
assert len(unexpected_from_addr) == 137
self.login_as_user(instructor)
send_mail_url = reverse('send_email', kwargs={'course_id': str(course.id)})
response = self.client.post(send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8'))['success']
assert len(mail.outbox) == 1
from_email = mail.outbox[0].from_email
expected_from_addr = (
'"{course_name}" Course Staff <{course_name}-no-reply@courseupdates.edx.org>'
).format(course_name=course.id.course)
assert from_email == expected_from_addr
assert len(from_email) == 61
@override_settings(BULK_EMAIL_EMAILS_PER_TASK=3)
@patch('lms.djangoapps.bulk_email.tasks.update_subtask_status')
def test_chunked_queries_send_numerous_emails(self, email_mock):
"""
Test sending a large number of emails, to test the chunked querying
"""
mock_factory = MockCourseEmailResult()
email_mock.side_effect = mock_factory.get_mock_update_subtask_status()
added_users = []
for _ in range(LARGE_NUM_EMAILS):
user = UserFactory()
added_users.append(user)
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
optouts = []
for i in [1, 3, 9, 10, 18]: # 5 random optouts
user = added_users[i]
optouts.append(user)
optout = Optout(user=user, course_id=self.course.id)
optout.save()
test_email = {
'action': 'Send email',
'send_to': '["myself", "staff", "learners"]',
'subject': 'test subject for all',
'message': 'test message for all'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert mock_factory.emails_sent == \
((((1 + len(self.staff)) + len(self.students)) + LARGE_NUM_EMAILS) - len(optouts))
outbox_contents = [e.to[0] for e in mail.outbox]
should_send_contents = ([self.instructor.email] +
[s.email for s in self.staff] +
[s.email for s in self.students] +
[s.email for s in added_users if s not in optouts])
assert len(outbox_contents) == len(should_send_contents)
def test_unsubscribe_link_in_email(self):
"""
Make sure opt out link is present in email.
"""
test_email = {
'action': 'Send email',
'send_to': '["learners"]',
'subject': 'Checking unsubscribe link in email',
'message': 'test message for all'
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
# check unsubscribe link in template
for m in mail.outbox:
plain_template = m.body
html_template = m.alternatives[0][0]
assert 'bulk_email/email/optout/' in plain_template
assert 'bulk_email/email/optout/' in html_template
@skipIf(os.environ.get("TRAVIS") == 'true', "Skip this test in Travis CI.")
class TestEmailSendFromDashboard(EmailSendFromDashboardTestCase):
"""
Tests email sending without mocked html_to_text.
Note that these tests are skipped on Travis because we can't use the
function `html_to_text` as it is currently implemented on Travis.
"""
def test_unicode_message_send_to_all(self):
"""
Make sure email (with Unicode characters) send to all goes there.
"""
uni_message = 'ẗëṡẗ ṁëṡṡäġë ḟöṛ äḷḷ イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ fоѓ аll'
test_email = {
'action': 'Send email',
'send_to': '["myself", "staff", "learners"]',
'subject': 'test subject for all',
'message': uni_message
}
response = self.client.post(self.send_mail_url, test_email)
assert json.loads(response.content.decode('utf-8')) == self.success_content
assert len(mail.outbox) == ((1 + len(self.staff)) + len(self.students))
assert len([e.to[0] for e in mail.outbox]) ==\
len([self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students])
message_body = mail.outbox[0].body
assert uni_message in message_body
class TestCourseEmailContext(SharedModuleStoreTestCase):
"""
Test the course email context hash used to send bulk emails.
"""
@classmethod
def setUpClass(cls):
"""
Create a course shared by all tests.
"""
super().setUpClass()
cls.course_title = "Финансовое программирование и политика, часть 1: макроэкономические счета и анализ"
cls.course_org = 'IMF'
cls.course_number = "FPP.1x"
cls.course_run = "2016"
cls.course = CourseFactory.create(
display_name=cls.course_title,
org=cls.course_org,
number=cls.course_number,
run=cls.course_run,
)
def verify_email_context(self, email_context, scheme):
"""
This test tests that the bulk email context uses http or https urls as appropriate.
"""
assert email_context['platform_name'] == settings.PLATFORM_NAME
assert email_context['course_title'] == self.course_title
assert email_context['course_url'] == \
f'{scheme}://edx.org/courses/{self.course_org}/{self.course_number}/{self.course_run}/'
assert email_context['course_image_url'] == \
f'{scheme}://edx.org/c4x/{self.course_org}/{self.course_number}/asset/images_course_image.jpg'
assert email_context['email_settings_url'] == '{}://edx.org/dashboard'.format(scheme)
assert email_context['account_settings_url'] == '{}://edx.org/account/settings'.format(scheme)
@override_settings(LMS_ROOT_URL="http://edx.org")
def test_insecure_email_context(self):
"""
This test tests that the bulk email context uses http urls
"""
email_context = _get_course_email_context(self.course)
self.verify_email_context(email_context, 'http')
@override_settings(LMS_ROOT_URL="https://edx.org")
def test_secure_email_context(self):
"""
This test tests that the bulk email context uses https urls
"""
email_context = _get_course_email_context(self.course)
self.verify_email_context(email_context, 'https')