Files
edx-platform/lms/djangoapps/discussion/tests/test_tasks_v2.py
Taimoor Ahmed 86d9b08b5d feat: remove last cs_comments service references (#37503)
This commit removes all remaining references to cs_comments_service
except the ForumsConfig model. The only purpose of keeping the model
and table around is so that the webapp processes don't start throwing
errors during deployment because they're running the old code for a
few minutes after the database migration has run. We can drop
ForumsConfig and add the drop-table migration after Ulmo is cut.

Also bumps the openedx-forum version to 0.3.7

---------

Co-authored-by: Taimoor  Ahmed <taimoor.ahmed@A006-01711.local>
2025-10-23 10:48:39 -04:00

485 lines
19 KiB
Python

"""
Tests the execution of forum notification tasks.
"""
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
from lms.djangoapps.discussion.django_comment_client.tests.mixins import (
MockForumApiMixin,
)
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
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 (
_is_first_comment,
_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.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
NOW = datetime.utcnow()
ONE_HOUR_AGO = NOW - timedelta(hours=1)
TWO_HOURS_AGO = NOW - timedelta(hours=2)
def make_subscribed_threads_callback(subscribed_thread_ids, per_page=1):
"""
Creates a callback function for simulating user data.
"""
def callback(*args, **kwargs): # lint-amnesty, pylint: disable=unused-argument
subscribed_thread_collection = [
{"id": thread_id} for thread_id in subscribed_thread_ids
]
page = kwargs.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 data
return callback
@ddt.ddt
class TaskTestCase(
ModuleStoreTestCase, MockForumApiMixin
): # lint-amnesty, pylint: disable=missing-class-docstring
@classmethod
@mock.patch.dict(
"django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}
)
def setUpClass(cls):
super().setUpClass()
super().setUpClassAndForumMock()
cls.discussion_id = "dummy_discussion_id"
cls.question_id = "dummy_question_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.user.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)
cls.create_threads_and_comments()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
super().disposeForumMocks()
def _set_forum_mocks(
self,
subscribed_thread_ids=None,
thread_data=None,
comment_data=None,
per_page=1,
):
"""mock threads and comments"""
if subscribed_thread_ids:
self.set_mock_side_effect(
"get_user_subscriptions",
make_subscribed_threads_callback(subscribed_thread_ids, per_page),
)
if thread_data:
self.set_mock_return_value("get_thread", thread_data)
if comment_data:
self.set_mock_return_value("get_parent_comment", comment_data)
@classmethod
def create_threads_and_comments(
cls,
): # lint-amnesty, pylint: disable=missing-function-docstring
# Regular discussion threads and comments.
cls.discussion_thread = {
"id": cls.discussion_id,
"course_id": str(cls.course.id),
"created_at": date.serialize(TWO_HOURS_AGO),
"title": "discussion-thread-title",
"user_id": cls.thread_author.id,
"username": cls.thread_author.username,
"commentable_id": "discussion-thread-commentable-id",
"thread_type": "discussion",
}
cls.discussion_comment = {
"id": "discussion-comment",
"body": "discussion-comment-body",
"created_at": date.serialize(ONE_HOUR_AGO),
"thread_id": cls.discussion_thread["id"],
"parent_id": None,
"user_id": cls.comment_author.id,
"username": cls.comment_author.username,
"course_id": str(cls.course.id),
}
cls.discussion_comment2 = {
"id": "discussion-comment2",
"body": "discussion-comment2-body",
"created_at": date.serialize(NOW),
"thread_id": cls.discussion_thread["id"],
"parent_id": None,
"user_id": cls.comment_author.id,
"username": cls.comment_author.username,
"course_id": str(cls.course.id),
}
cls.discussion_subcomment = {
"id": "discussion-subcomment",
"body": "discussion-subcomment-body",
"created_at": date.serialize(NOW),
"thread_id": cls.discussion_thread["id"],
"parent_id": cls.discussion_comment["id"],
"user_id": cls.comment_author.id,
"username": cls.comment_author.username,
"course_id": str(cls.course.id),
}
cls.discussion_thread["children"] = [
cls.discussion_comment,
cls.discussion_comment2,
]
cls.discussion_comment["child_count"] = 1
cls.discussion_thread2 = {
"id": cls.discussion_id,
"course_id": str(cls.course.id),
"created_at": date.serialize(TWO_HOURS_AGO),
"title": "discussion-thread-2-title",
"user_id": cls.thread_author.id,
"username": cls.thread_author.username,
"commentable_id": "discussion-thread-commentable-id-2",
"thread_type": "discussion",
}
# Qeustion threads and comments.
cls.question_thread = {
"id": cls.question_id,
"course_id": str(cls.course.id),
"created_at": date.serialize(TWO_HOURS_AGO),
"title": "question-thread-title",
"user_id": cls.thread_author.id,
"username": cls.thread_author.username,
"commentable_id": "question-thread-commentable-id-3",
"thread_type": "question",
}
cls.question_comment = {
"id": "question-comment",
"body": "question-comment-body",
"created_at": date.serialize(ONE_HOUR_AGO),
"thread_id": cls.question_thread["id"],
"parent_id": None,
"user_id": cls.comment_author.id,
"username": cls.comment_author.username,
"course_id": str(cls.course.id),
}
cls.question_comment2 = {
"id": "question-comment2",
"body": "question-comment2-body",
"created_at": date.serialize(NOW),
"thread_id": cls.question_thread["id"],
"parent_id": None,
"user_id": cls.comment_author.id,
"username": cls.comment_author.username,
"course_id": str(cls.course.id),
}
cls.question_subcomment = {
"id": "question-subcomment",
"body": "question-subcomment-body",
"created_at": date.serialize(NOW),
"thread_id": cls.question_thread["id"],
"parent_id": cls.question_comment["id"],
"user_id": cls.comment_author.id,
"username": cls.comment_author.username,
"course_id": str(cls.course.id),
}
cls.question_thread["endorsed_responses"] = [cls.question_comment]
cls.question_thread["non_endorsed_responses"] = [cls.question_comment2]
cls.question_comment["child_count"] = 1
cls.question_thread2 = {
"id": cls.question_id,
"course_id": str(cls.course.id),
"created_at": date.serialize(TWO_HOURS_AGO),
"title": "question-thread-2-title",
"user_id": cls.thread_author.id,
"username": cls.thread_author.username,
"commentable_id": "question-thread-commentable-id-2",
"thread_type": "question",
}
def setUp(self):
super().setUp()
self.ace_send_patcher = mock.patch("edx_ace.ace.send")
self.mock_ace_send = self.ace_send_patcher.start()
self.mock_message_patcher = mock.patch(
"lms.djangoapps.discussion.tasks.ResponseNotification"
)
self.mock_message = self.mock_message_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()
self.set_mock_return_value("get_course_id_by_thread", str(self.course.id))
self.set_mock_return_value("get_course_id_by_comment", str(self.course.id))
def tearDown(self):
super().tearDown()
self.ace_send_patcher.stop()
self.mock_message_patcher.stop()
self.permalink_patcher.stop()
@ddt.data(True)
def test_send_discussion_email_notification(self, user_subscribed):
self.mock_message_patcher.stop()
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,
self.question_id,
]
else:
subscribed_thread_ids = []
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()
examples = [
(self.discussion_thread, self.discussion_comment),
(self.question_thread, self.question_comment),
]
for thread, comment in examples:
self.mock_ace_send.reset_mock()
self._set_forum_mocks(
subscribed_thread_ids=subscribed_thread_ids,
comment_data=comment,
thread_data=thread,
)
user = mock.Mock()
comment = cc.Comment.find(id=comment["id"]).retrieve()
with mock.patch(
"lms.djangoapps.discussion.rest_api.tasks.send_response_notifications.apply_async"
):
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": comment["body"],
"comment_body_text": comment.body_text,
"comment_created_at": ONE_HOUR_AGO,
"comment_id": comment["id"],
"comment_parent_id": comment["parent_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": thread["id"],
"thread_title": thread["title"],
"thread_username": self.thread_author.username,
"thread_commentable_id": thread["commentable_id"],
"post_link": f"https://{site.domain}{self.mock_permalink.return_value}",
"site": site,
"site_id": site.id,
"push_notification_extra_context": {
"notification_type": "forum_response",
"topic_id": thread["commentable_id"],
"course_id": comment["course_id"],
"parent_id": str(comment["parent_id"]),
"thread_id": thread["id"],
"comment_id": comment["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, comment)
else:
assert not self.mock_ace_send.called
def _assert_rendered_email(
self, message, comment
): # 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 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._set_forum_mocks(
subscribed_thread_ids=[self.discussion_id, self.question_id],
comment_data=comment_dict,
thread_data=thread,
)
user = mock.Mock()
comment = cc.Comment.find(id=comment_dict["id"]).retrieve()
with mock.patch(
"lms.djangoapps.discussion.rest_api.tasks.send_response_notifications.apply_async"
):
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"],
}
)
should_email_send = _is_first_comment(comment_dict["id"], thread["id"])
assert not should_email_send
assert not self.mock_ace_send.called
def test_subcomment_should_not_send_email(self):
self.run_should_not_send_email_test(
self.discussion_thread, self.discussion_subcomment
)
self.run_should_not_send_email_test(
self.question_subcomment, self.question_subcomment
)
def test_second_comment_should_not_send_email(self):
self.run_should_not_send_email_test(
self.discussion_thread, self.discussion_comment2
)
self.run_should_not_send_email_test(
self.question_thread, self.question_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.discussion_thread2, self.discussion_comment
)
self.run_should_not_send_email_test(
self.question_thread2, self.question_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,
)