332 lines
14 KiB
Python
332 lines
14 KiB
Python
"""
|
|
Tests the execution of forum notification tasks.
|
|
"""
|
|
|
|
|
|
import json
|
|
import math
|
|
from datetime import datetime, timedelta
|
|
from unittest import mock
|
|
|
|
import ddt
|
|
from django.contrib.sites.models import Site
|
|
from edx_ace.channel import ChannelType, get_channel_for_message
|
|
from edx_ace.recipient import Recipient
|
|
from edx_ace.renderers import EmailRenderer
|
|
from edx_ace.utils import date
|
|
|
|
import openedx.core.djangoapps.django_comment_common.comment_client as cc
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
|
from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY
|
|
from lms.djangoapps.discussion.tasks import _should_send_message, _track_notification_sent
|
|
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
|
|
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
|
from openedx.core.djangoapps.django_comment_common.models import ForumsConfig
|
|
from openedx.core.djangoapps.django_comment_common.signals import comment_created
|
|
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory
|
|
from openedx.core.lib.celery.task_utils import emulate_http_request
|
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
|
|
|
NOW = datetime.utcnow()
|
|
ONE_HOUR_AGO = NOW - timedelta(hours=1)
|
|
TWO_HOURS_AGO = NOW - timedelta(hours=2)
|
|
|
|
|
|
def make_mock_responder(subscribed_thread_ids=None, thread_data=None, comment_data=None, per_page=1): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
def mock_subscribed_threads(method, url, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
subscribed_thread_collection = [
|
|
{'id': thread_id} for thread_id in subscribed_thread_ids
|
|
]
|
|
page = kwargs.get('params', {}).get('page', 1)
|
|
start_index = per_page * (page - 1)
|
|
end_index = per_page * page
|
|
data = {
|
|
'collection': subscribed_thread_collection[start_index: end_index],
|
|
'page': page,
|
|
'num_pages': int(math.ceil(len(subscribed_thread_collection) / float(per_page))),
|
|
'thread_count': len(subscribed_thread_collection)
|
|
}
|
|
return mock.Mock(status_code=200, text=json.dumps(data), json=mock.Mock(return_value=data))
|
|
|
|
def mock_comment_find(method, url, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
return mock.Mock(status_code=200, text=json.dumps(comment_data), json=mock.Mock(return_value=comment_data))
|
|
|
|
def mock_thread_find(method, url, **kwargs): # lint-amnesty, pylint: disable=unused-argument
|
|
return mock.Mock(status_code=200, text=json.dumps(thread_data), json=mock.Mock(return_value=thread_data))
|
|
|
|
def mock_request(method, url, **kwargs):
|
|
if '/subscribed_threads' in url:
|
|
return mock_subscribed_threads(method, url, **kwargs)
|
|
if '/comments' in url:
|
|
return mock_comment_find(method, url, **kwargs)
|
|
if '/threads' in url:
|
|
return mock_thread_find(method, url, **kwargs)
|
|
|
|
return mock_request
|
|
|
|
|
|
@ddt.ddt
|
|
class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
|
|
|
@classmethod
|
|
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.discussion_id = 'dummy_discussion_id'
|
|
cls.course = CourseOverviewFactory.create(language='fr')
|
|
|
|
# Patch the comment client user save method so it does not try
|
|
# to create a new cc user when creating a django user
|
|
with mock.patch('common.djangoapps.student.models.cc.User.save'):
|
|
cls.thread_author = UserFactory(
|
|
username='thread_author',
|
|
password='password',
|
|
email='email'
|
|
)
|
|
cls.comment_author = UserFactory(
|
|
username='comment_author',
|
|
password='password',
|
|
email='email'
|
|
)
|
|
|
|
CourseEnrollmentFactory(
|
|
user=cls.thread_author,
|
|
course_id=cls.course.id
|
|
)
|
|
CourseEnrollmentFactory(
|
|
user=cls.comment_author,
|
|
course_id=cls.course.id
|
|
)
|
|
|
|
config = ForumsConfig.current()
|
|
config.enabled = True
|
|
config.save()
|
|
|
|
cls.create_thread_and_comments()
|
|
|
|
@classmethod
|
|
def create_thread_and_comments(cls): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
cls.thread = {
|
|
'id': cls.discussion_id,
|
|
'course_id': str(cls.course.id),
|
|
'created_at': date.serialize(TWO_HOURS_AGO),
|
|
'title': 'thread-title',
|
|
'user_id': cls.thread_author.id,
|
|
'username': cls.thread_author.username,
|
|
'commentable_id': 'thread-commentable-id',
|
|
}
|
|
cls.comment = {
|
|
'id': 'comment',
|
|
'body': 'comment-body',
|
|
'created_at': date.serialize(ONE_HOUR_AGO),
|
|
'thread_id': cls.thread['id'],
|
|
'parent_id': None,
|
|
'user_id': cls.comment_author.id,
|
|
'username': cls.comment_author.username,
|
|
}
|
|
cls.comment2 = {
|
|
'id': 'comment2',
|
|
'body': 'comment2-body',
|
|
'created_at': date.serialize(NOW),
|
|
'thread_id': cls.thread['id'],
|
|
'parent_id': None,
|
|
'user_id': cls.comment_author.id,
|
|
'username': cls.comment_author.username
|
|
}
|
|
cls.subcomment = {
|
|
'id': 'subcomment',
|
|
'body': 'subcomment-body',
|
|
'created_at': date.serialize(NOW),
|
|
'thread_id': cls.thread['id'],
|
|
'parent_id': cls.comment['id'],
|
|
'user_id': cls.comment_author.id,
|
|
'username': cls.comment_author.username,
|
|
}
|
|
cls.thread['children'] = [cls.comment, cls.comment2]
|
|
cls.comment['child_count'] = 1
|
|
cls.thread2 = {
|
|
'id': cls.discussion_id,
|
|
'course_id': str(cls.course.id),
|
|
'created_at': date.serialize(TWO_HOURS_AGO),
|
|
'title': 'thread-title',
|
|
'user_id': cls.thread_author.id,
|
|
'username': cls.thread_author.username,
|
|
'commentable_id': 'thread-commentable-id-2',
|
|
}
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.request_patcher = mock.patch('requests.request')
|
|
self.mock_request = self.request_patcher.start()
|
|
|
|
self.ace_send_patcher = mock.patch('edx_ace.ace.send')
|
|
self.mock_ace_send = self.ace_send_patcher.start()
|
|
|
|
thread_permalink = '/courses/discussion/dummy_discussion_id'
|
|
self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink)
|
|
self.mock_permalink = self.permalink_patcher.start()
|
|
|
|
def tearDown(self):
|
|
super().tearDown()
|
|
self.request_patcher.stop()
|
|
self.ace_send_patcher.stop()
|
|
self.permalink_patcher.stop()
|
|
|
|
@ddt.data(True, False)
|
|
def test_send_discussion_email_notification(self, user_subscribed):
|
|
if user_subscribed:
|
|
non_matching_id = 'not-a-match'
|
|
# with per_page left with a default value of 1, this ensures
|
|
# that we test a multiple page result when calling
|
|
# comment_client.User.subscribed_threads()
|
|
subscribed_thread_ids = [non_matching_id, self.discussion_id]
|
|
else:
|
|
subscribed_thread_ids = []
|
|
|
|
self.mock_request.side_effect = make_mock_responder(
|
|
subscribed_thread_ids=subscribed_thread_ids,
|
|
comment_data=self.comment,
|
|
thread_data=self.thread,
|
|
)
|
|
user = mock.Mock()
|
|
comment = cc.Comment.find(id=self.comment['id']).retrieve()
|
|
site = Site.objects.get_current()
|
|
site_config = SiteConfigurationFactory.create(site=site)
|
|
site_config.site_values[ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY] = True
|
|
site_config.save()
|
|
with mock.patch('lms.djangoapps.discussion.signals.handlers.get_current_site', return_value=site):
|
|
comment_created.send(sender=None, user=user, post=comment)
|
|
|
|
if user_subscribed:
|
|
expected_message_context = get_base_template_context(site)
|
|
expected_message_context.update({
|
|
'comment_author_id': self.comment_author.id,
|
|
'comment_body': self.comment['body'],
|
|
'comment_created_at': ONE_HOUR_AGO,
|
|
'comment_id': self.comment['id'],
|
|
'comment_username': self.comment_author.username,
|
|
'course_id': self.course.id,
|
|
'thread_author_id': self.thread_author.id,
|
|
'thread_created_at': TWO_HOURS_AGO,
|
|
'thread_id': self.discussion_id,
|
|
'thread_title': 'thread-title',
|
|
'thread_username': self.thread_author.username,
|
|
'thread_commentable_id': self.thread['commentable_id'],
|
|
'post_link': 'https://{}{}'.format(site.domain, self.mock_permalink.return_value),
|
|
'site': site,
|
|
'site_id': site.id
|
|
})
|
|
expected_recipient = Recipient(self.thread_author.id, self.thread_author.email)
|
|
actual_message = self.mock_ace_send.call_args_list[0][0][0]
|
|
assert expected_message_context == actual_message.context
|
|
assert expected_recipient == actual_message.recipient
|
|
assert self.course.language == actual_message.language
|
|
self._assert_rendered_email(actual_message)
|
|
|
|
else:
|
|
assert not self.mock_ace_send.called
|
|
|
|
def _assert_rendered_email(self, message): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
# check that we can actually render the message
|
|
with emulate_http_request(
|
|
site=message.context['site'], user=self.thread_author
|
|
):
|
|
rendered_email = EmailRenderer().render(get_channel_for_message(ChannelType.EMAIL, message), message)
|
|
assert self.comment['body'] in rendered_email.body_html
|
|
assert self.comment_author.username in rendered_email.body_html
|
|
assert self.mock_permalink.return_value in rendered_email.body_html
|
|
assert message.context['site'].domain in rendered_email.body_html
|
|
|
|
def run_should_not_send_email_test(self, thread, comment_dict):
|
|
"""
|
|
assert email is not sent
|
|
"""
|
|
self.mock_request.side_effect = make_mock_responder(
|
|
subscribed_thread_ids=[self.discussion_id],
|
|
comment_data=comment_dict,
|
|
thread_data=thread,
|
|
)
|
|
user = mock.Mock()
|
|
comment = cc.Comment.find(id=comment_dict['id']).retrieve()
|
|
comment_created.send(sender=None, user=user, post=comment)
|
|
|
|
actual_result = _should_send_message({
|
|
'thread_author_id': self.thread_author.id,
|
|
'course_id': self.course.id,
|
|
'comment_id': comment_dict['id'],
|
|
'thread_id': thread['id'],
|
|
})
|
|
assert actual_result is False
|
|
assert not self.mock_ace_send.called
|
|
|
|
def test_subcomment_should_not_send_email(self):
|
|
self.run_should_not_send_email_test(self.thread, self.subcomment)
|
|
|
|
def test_second_comment_should_not_send_email(self):
|
|
self.run_should_not_send_email_test(self.thread, self.comment2)
|
|
|
|
def test_thread_without_children_should_not_send_email(self):
|
|
"""
|
|
test that email notification will not be sent for the thread
|
|
that doesn't have attribute 'children'
|
|
"""
|
|
self.run_should_not_send_email_test(self.thread2, self.comment)
|
|
|
|
@ddt.data((
|
|
{
|
|
'thread_id': 'dummy_discussion_id',
|
|
'thread_title': 'thread-title',
|
|
'thread_created_at': date.serialize(datetime(2000, 1, 1, 0, 0, 0)),
|
|
'course_id': 'fake_course_edx',
|
|
'thread_author_id': 'a_fake_dude'
|
|
},
|
|
{
|
|
'app_label': 'discussion',
|
|
'name': 'responsenotification',
|
|
'language': 'en',
|
|
'uuid': 'uuid1',
|
|
'send_uuid': 'uuid2',
|
|
'thread_id': 'dummy_discussion_id',
|
|
'course_id': 'fake_course_edx',
|
|
'thread_created_at': datetime(2000, 1, 1, 0, 0, 0)
|
|
}
|
|
), (
|
|
{
|
|
'thread_id': 'dummy_discussion_id2',
|
|
'thread_title': 'thread-title2',
|
|
'thread_created_at': date.serialize(datetime(2000, 1, 1, 0, 0, 0)),
|
|
'course_id': 'fake_course_edx2',
|
|
'thread_author_id': 'a_fake_dude2'
|
|
},
|
|
{
|
|
'app_label': 'discussion',
|
|
'name': 'responsenotification',
|
|
'language': 'en',
|
|
'uuid': 'uuid3',
|
|
'send_uuid': 'uuid4',
|
|
'thread_id': 'dummy_discussion_id2',
|
|
'course_id': 'fake_course_edx2',
|
|
'thread_created_at': datetime(2000, 1, 1, 0, 0, 0)
|
|
}
|
|
|
|
))
|
|
@ddt.unpack
|
|
def test_track_notification_sent(self, context, test_props):
|
|
with mock.patch('edx_ace.ace.send').start() as message:
|
|
# Populate mock message (
|
|
# There are some cruft attrs, but they're harmless.
|
|
for key, entry in test_props.items():
|
|
setattr(message, key, entry)
|
|
|
|
test_props['nonInteraction'] = True
|
|
# Also augment context with site object, for setting segment context.
|
|
site = Site.objects.get_current()
|
|
context['site'] = site
|
|
with mock.patch('lms.djangoapps.discussion.tasks.segment.track') as mock_segment_track:
|
|
_track_notification_sent(message, context)
|
|
mock_segment_track.assert_called_once_with(
|
|
user_id=context['thread_author_id'],
|
|
event_name='edx.bi.email.sent',
|
|
properties=test_props,
|
|
)
|