diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py index 5b7656909bf..6b4fc5b7b03 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -2152,3 +2152,1024 @@ def test_status_by(self, post_status): post_status: True, } self.mock_get_user_active_threads.assert_called_once_with(**params) + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetListTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for CommentViewSet list""" + + def setUp(self): + super().setUp() + self.author = UserFactory.create() + self.url = reverse("comment-list") + self.thread_id = "test_thread" + self.storage = get_profile_image_storage() + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_delete_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread" + ).start() + self.addCleanup(mock.patch.stopall) + + def create_source_comment(self, overrides=None): + """ + Create a sample source cs_comment + """ + comment = make_minimal_cs_comment( + { + "id": "test_comment", + "thread_id": self.thread_id, + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + "body": "Test body", + "votes": {"up_count": 4}, + } + ) + + comment.update(overrides or {}) + return comment + + def make_minimal_cs_thread(self, overrides=None): + """ + Create a thread with the given overrides, plus the course_id if not + already in overrides. + """ + overrides = overrides.copy() if overrides else {} + overrides.setdefault("course_id", str(self.course.id)) + return make_minimal_cs_thread(overrides) + + def expected_response_comment(self, overrides=None): + """ + create expected response data + """ + response_data = { + "id": "test_comment", + "thread_id": self.thread_id, + "parent_id": None, + "author": self.author.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "dummy", + "rendered_body": "
dummy
", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": ["abuse_flagged", "voted"], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response_data.update(overrides or {}) + return response_data + + def test_thread_id_missing(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "thread_id": {"developer_message": "This field is required."} + } + }, + ) + + # def test_404(self): + # self.register_get_thread_error_response(self.thread_id, 404) + # response = self.client.get(self.url, {"thread_id": self.thread_id}) + # self.assert_response_correct( + # response, 404, {"developer_message": "Thread not found."} + # ) + + def test_basic(self): + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + source_comments = [ + self.create_source_comment( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + expected_comments = [ + self.expected_response_comment( + overrides={ + "voted": True, + "vote_count": 4, + "raw_body": "Test body", + "can_delete": False, + "rendered_body": "Test body
", + "created_at": "2015-05-11T00:00:00Z", + "updated_at": "2015-05-11T11:11:11Z", + } + ) + ] + self.register_get_thread_response( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "children": source_comments, + "resp_total": 100, + } + ) + response = self.client.get(self.url, {"thread_id": self.thread_id}) + next_link = ( + "http://testserver/api/discussion/v1/comments/?page=2&thread_id={}".format( + self.thread_id + ) + ) + self.assert_response_correct( + response, + 200, + make_paginated_api_response( + results=expected_comments, + count=100, + num_pages=10, + next_link=next_link, + previous_link=None, + ), + ) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id="test_thread", params=params, course_id=str(self.course.id) + ) + + def test_pagination(self): + """ + Test that pagination parameters are correctly plumbed through to the + comments service and that a 404 is correctly returned if a page past the + end is requested + """ + self.register_get_user_response(self.user) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "resp_total": 10, + } + ) + ) + response = self.client.get( + self.url, {"thread_id": self.thread_id, "page": "18", "page_size": "4"} + ) + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 68, + "resp_limit": 4, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id="test_thread", params=params, course_id=str(self.course.id) + ) + + def test_question_content_with_merge_question_type_responses(self): + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "children": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": True, + } + ), + make_minimal_cs_comment( + { + "id": "non_endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": False, + } + ), + ], + "resp_total": 2, + } + ) + self.register_get_thread_response(thread) + response = self.client.get( + self.url, {"thread_id": thread["id"], "merge_question_type_responses": True} + ) + parsed_content = json.loads(response.content.decode("utf-8")) + assert parsed_content["results"][0]["id"] == "endorsed_comment" + assert parsed_content["results"][1]["id"] == "non_endorsed_comment" + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": True, + } + self.mock_get_thread.assert_called_once_with( + thread_id=thread["id"], params=params, course_id=str(self.course.id) + ) + + @ddt.data( + (True, "endorsed_comment"), + ("true", "endorsed_comment"), + ("1", "endorsed_comment"), + (False, "non_endorsed_comment"), + ("false", "non_endorsed_comment"), + ("0", "non_endorsed_comment"), + ) + @ddt.unpack + def test_question_content(self, endorsed, comment_id): + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + } + ) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "non_endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + } + ) + ], + "non_endorsed_resp_total": 1, + } + ) + self.register_get_thread_response(thread) + response = self.client.get( + self.url, + { + "thread_id": thread["id"], + "endorsed": endorsed, + }, + ) + parsed_content = json.loads(response.content.decode("utf-8")) + assert parsed_content["results"][0]["id"] == comment_id + + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id=thread["id"], params=params, course_id=str(self.course.id) + ) + + def test_question_invalid_endorsed(self): + response = self.client.get( + self.url, {"thread_id": self.thread_id, "endorsed": "invalid-boolean"} + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "endorsed": {"developer_message": "Invalid Boolean Value."} + } + }, + ) + + def test_question_missing_endorsed(self): + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment({"id": "endorsed_comment"}) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment({"id": "non_endorsed_comment"}) + ], + "non_endorsed_resp_total": 1, + } + ) + self.register_get_thread_response(thread) + response = self.client.get(self.url, {"thread_id": thread["id"]}) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "endorsed": { + "developer_message": "This field is required for question threads." + } + } + }, + ) + + @ddt.data(("discussion", False), ("question", True)) + @ddt.unpack + def test_child_comments_count(self, thread_type, merge_question_type_responses): + self.register_get_user_response(self.user) + response_1 = make_minimal_cs_comment( + { + "id": "test_response_1", + "thread_id": self.thread_id, + "user_id": str(self.author.id), + "username": self.author.username, + "child_count": 2, + } + ) + response_2 = make_minimal_cs_comment( + { + "id": "test_response_2", + "thread_id": self.thread_id, + "user_id": str(self.author.id), + "username": self.author.username, + "child_count": 3, + } + ) + thread = self.make_minimal_cs_thread( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": thread_type, + "children": [response_1, response_2], + "resp_total": 2, + "comments_count": 8, + "unread_comments_count": 0, + } + ) + self.register_get_thread_response(thread) + response = self.client.get( + self.url, + { + "thread_id": self.thread_id, + "merge_question_type_responses": merge_question_type_responses, + }, + ) + expected_comments = [ + self.expected_response_comment( + overrides={ + "id": "test_response_1", + "child_count": 2, + "can_delete": False, + } + ), + self.expected_response_comment( + overrides={ + "id": "test_response_2", + "child_count": 3, + "can_delete": False, + } + ), + ] + self.assert_response_correct( + response, + 200, + { + "results": expected_comments, + "pagination": { + "count": 2, + "next": None, + "num_pages": 1, + "previous": None, + }, + }, + ) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": False, + "merge_question_type_responses": merge_question_type_responses, + } + self.mock_get_thread.assert_called_once_with( + thread_id=thread["id"], params=params, course_id=str(self.course.id) + ) + + def test_profile_image_requested_field(self): + """ + Tests all comments retrieved have user profile image details if called in requested_fields + """ + source_comments = [self.create_source_comment()] + self.register_get_thread_response( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "children": source_comments, + "resp_total": 100, + } + ) + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + self.create_profile_image(self.user, get_profile_image_storage()) + + response = self.client.get( + self.url, {"thread_id": self.thread_id, "requested_fields": "profile_image"} + ) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + for response_comment in response_comments: + expected_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + response_users = response_comment["users"] + assert expected_profile_data == response_users[response_comment["author"]] + + def test_profile_image_requested_field_endorsed_comments(self): + """ + Tests all comments have user profile image details for both author and endorser + if called in requested_fields for endorsed threads + """ + endorser_user = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + endorser_user.profile.year_of_birth = 1970 + endorser_user.profile.save() + + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": True, + "endorsement": { + "user_id": endorser_user.id, + "time": "2016-05-10T08:51:28Z", + }, + } + ) + ], + "non_endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "non_endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + } + ) + ], + "non_endorsed_resp_total": 1, + } + ) + self.register_get_thread_response(thread) + self.create_profile_image(self.user, get_profile_image_storage()) + self.create_profile_image(endorser_user, get_profile_image_storage()) + + response = self.client.get( + self.url, + { + "thread_id": thread["id"], + "endorsed": True, + "requested_fields": "profile_image", + }, + ) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + for response_comment in response_comments: + expected_author_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + expected_endorser_profile_data = self.get_expected_user_profile( + response_comment["endorsed_by"] + ) + response_users = response_comment["users"] + assert ( + expected_author_profile_data + == response_users[response_comment["author"]] + ) + assert ( + expected_endorser_profile_data + == response_users[response_comment["endorsed_by"]] + ) + + def test_profile_image_request_for_null_endorsed_by(self): + """ + Tests if 'endorsed' is True but 'endorsed_by' is null, the api does not crash. + This is the case for some old/stale data in prod/stage environments. + """ + self.register_get_user_response(self.user) + thread = self.make_minimal_cs_thread( + { + "thread_type": "question", + "endorsed_responses": [ + make_minimal_cs_comment( + { + "id": "endorsed_comment", + "user_id": self.user.id, + "username": self.user.username, + "endorsed": True, + } + ) + ], + "non_endorsed_resp_total": 0, + } + ) + self.register_get_thread_response(thread) + self.create_profile_image(self.user, get_profile_image_storage()) + + response = self.client.get( + self.url, + { + "thread_id": thread["id"], + "endorsed": True, + "requested_fields": "profile_image", + }, + ) + assert response.status_code == 200 + response_comments = json.loads(response.content.decode("utf-8"))["results"] + for response_comment in response_comments: + expected_author_profile_data = self.get_expected_user_profile( + response_comment["author"] + ) + response_users = response_comment["users"] + assert ( + expected_author_profile_data + == response_users[response_comment["author"]] + ) + assert response_comment["endorsed_by"] not in response_users + + def test_reverse_order_sort(self): + """ + Tests if reverse_order param is passed to cs comments service + """ + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + source_comments = [ + self.create_source_comment( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + self.register_get_thread_response( + { + "id": self.thread_id, + "course_id": str(self.course.id), + "thread_type": "discussion", + "children": source_comments, + "resp_total": 100, + } + ) + self.client.get(self.url, {"thread_id": self.thread_id, "reverse_order": True}) + params = { + "recursive": False, + "with_responses": True, + "user_id": str(self.user.id), + "mark_as_read": False, + "resp_skip": 0, + "resp_limit": 10, + "reverse_order": "True", + "merge_question_type_responses": False, + } + self.mock_get_thread.assert_called_once_with( + thread_id=self.thread_id, params=params, course_id=str(self.course.id) + ) + + +@httpretty.activate +@disable_signal(api, "comment_deleted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ThreadViewSet delete""" + + def setUp(self): + super().setUp() + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + self.comment_id = "test_comment" + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_delete_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_thread" + ).start() + self.mock_delete_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.delete_comment" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + } + ) + self.register_get_thread_response(cs_thread) + cs_comment = make_minimal_cs_comment( + { + "id": self.comment_id, + "course_id": cs_thread["course_id"], + "thread_id": cs_thread["id"], + "username": self.user.username, + "user_id": str(self.user.id), + } + ) + self.register_get_comment_response(cs_comment) + self.register_delete_comment_response(self.comment_id) + response = self.client.delete(self.url) + assert response.status_code == 204 + assert response.content == b"" + self.mock_delete_comment.assert_called_once_with( + comment_id=self.comment_id, course_id=cs_thread["course_id"] + ) + + def test_delete_nonexistent_comment(self): + try: + self.register_get_comment_error_response(self.comment_id, 404) + except Exception as e: + assert e == "404 Not Found" + + +@httpretty.activate +@disable_signal(api, "comment_created") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch( + "lms.djangoapps.discussion.signals.handlers.send_response_notifications", + new=mock.Mock(), +) +class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for CommentViewSet create""" + + def setUp(self): + super().setUp() + self.url = reverse("comment-list") + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.mock_update_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment" + ).start() + self.mock_create_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment" + ).start() + self.mock_create_child_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_child_comment" + ).start() + self.addCleanup(mock.patch.stopall) + + def test_basic(self): + self.register_get_user_response(self.user) + self.register_thread() + self.register_comment() + request_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + expected_response_data = { + "id": "test_comment", + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Original body", + "rendered_body": "Original body
", + "abuse_flagged": False, + "voted": False, + "vote_count": 0, + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "thread_id": "test_thread", + "parent_id": None, + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "child_count": 0, + "children": [], + "abuse_flagged_any_user": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response = self.client.post( + self.url, json.dumps(request_data), content_type="application/json" + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + self.mock_create_parent_comment.assert_called_once_with( + "test_thread", "Test body", "2", "course-v1:x+y+z", False, False + ) + + def test_error(self): + response = self.client.post( + self.url, json.dumps({}), content_type="application/json" + ) + expected_response_data = { + "field_errors": { + "thread_id": {"developer_message": "This field is required."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + def test_closed_thread(self): + self.register_get_user_response(self.user) + self.register_thread({"closed": True}) + self.register_comment() + request_data = {"thread_id": "test_thread", "raw_body": "Test body"} + response = self.client.post( + self.url, json.dumps(request_data), content_type="application/json" + ) + assert response.status_code == 403 + + +@ddt.ddt +@disable_signal(api, "comment_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for CommentViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + mock.patch( + "lms.djangoapps.discussion.toggles.ENABLE_FORUM_V2.is_enabled", + return_value=True, + ).start() + self.mock_get_user = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api.get_user" + ).start() + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=str(self.course.id), + ).start() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=str(self.course.id), + ).start() + self.mock_update_comment_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api.update_comment_flag", + return_value=str(self.course.id), + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread" + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread" + ).start() + self.mock_get_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_parent_comment" + ).start() + self.mock_update_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_comment" + ).start() + self.mock_create_parent_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_parent_comment" + ).start() + self.mock_create_child_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_child_comment" + ).start() + self.mock_update_thread_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag" + ).start() + self.mock_update_thread_flag_in_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api.update_thread_flag" + ).start() + self.addCleanup(mock.patch.stopall) + self.register_get_user_response(self.user) + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + + def expected_response_data(self, overrides=None): + """ + create expected response data from comment update endpoint + """ + response_data = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Original body", + "rendered_body": "Original body
", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": [], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response_data.update(overrides or {}) + return response_data + + def test_basic(self): + self.register_thread() + self.register_comment( + {"created_at": "Test Created Date", "updated_at": "Test Updated Date"} + ) + request_data = {"raw_body": "Edited body"} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "raw_body": "Original body", + "rendered_body": "Original body
", + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + } + ) + self.mock_update_comment.assert_called_once_with( + comment_id="test_comment", + body="Edited body", + course_id=str(self.course.id), + user_id=str(self.user.id), + anonymous=False, + anonymous_to_peers=False, + endorsed=False, + editing_user_id=str(self.user.id), + course_key=str(self.course.id), + ) + + def test_error(self): + self.register_thread() + self.register_comment() + request_data = {"raw_body": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "raw_body": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + self.register_flag_response("comment", "test_comment") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "abuse_flagged": value, + "abuse_flagged_any_user": None, + "editable_fields": ["abuse_flagged"], + } + ) + if value: + self.mock_update_comment_flag.assert_called_once_with( + "test_comment", + "flag", + str(self.user.id), + str(self.course.id), + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 diff --git a/lms/djangoapps/discussion/rest_api/tests/utils_v2.py b/lms/djangoapps/discussion/rest_api/tests/utils_v2.py index 3f2a4997bf3..3f63f77b282 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils_v2.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils_v2.py @@ -105,7 +105,7 @@ def register_put_thread_response(self, thread_data): def register_get_thread_error_response(self, thread_id, status_code): """Register a mock error response for GET on the CS thread endpoint.""" - self.mock_delete_thread.return_value = status_code + self.mock_get_thread.return_value = {"error": status_code} def register_get_thread_response(self, thread): """Register a mock response for the get_thread method.""" @@ -143,7 +143,7 @@ def register_post_comment_response(self, comment_data, thread_id, parent_id=None response_data["parent_id"] = parent_id response_data["id"] = comment_data["id"] for key, val_list in comment_data.items(): - val = val_list[0] if isinstance(val_list, list) else val_list + val = val_list[0] if (isinstance(val_list, list) and val_list) else val_list if key in ["anonymous", "anonymous_to_peers", "endorsed"]: response_data[key] = val == "True" elif key == "edit_reason_code": @@ -300,6 +300,7 @@ def register_flag_response(self, content_type, content_id): """Register a mock response for PUT on the CS flag endpoints""" self.mock_update_thread_flag.return_value = {} self.mock_update_thread_flag_in_comment.return_value = {} + self.mock_update_comment_flag.return_value = {} def register_read_response(self, user, content_type, content_id): """ @@ -325,13 +326,7 @@ def register_delete_comment_response(self, comment_id): """ Register a mock response for DELETE on the CS comment instance endpoint """ - assert httpretty.is_enabled(), "httpretty must be enabled to mock calls." - httpretty.register_uri( - httpretty.DELETE, - f"http://localhost:4567/api/v1/comments/{comment_id}", - body=json.dumps({}), # body is unused - status=200, - ) + self.mock_delete_comment.return_value = {} def register_user_active_threads(self, user_id, response): """