From bea463d95001ca2fd3d4aca8d75b257b243f3da9 Mon Sep 17 00:00:00 2001 From: jsa Date: Sat, 22 Feb 2014 19:16:17 -0500 Subject: [PATCH] Add acceptance tests for forums comment deletion. JIRA: FOR-472 --- common/djangoapps/student/views.py | 9 ++ common/djangoapps/terrain/stubs/comments.py | 59 +++++------- common/test/acceptance/fixtures/__init__.py | 3 + common/test/acceptance/fixtures/discussion.py | 89 +++++++++++++++++++ .../pages/lms/discussion_single_thread.py | 27 +++++- .../test/acceptance/tests/test_discussion.py | 58 +++++++++++- 6 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 common/test/acceptance/fixtures/discussion.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 7bf6922f65..7ec6e7f499 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -57,6 +57,8 @@ from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access +from django_comment_common.models import Role + from external_auth.models import ExternalAuthMap import external_auth.views @@ -1211,6 +1213,7 @@ def auto_auth(request): * `full_name` for the user profile (the user's full name; defaults to the username) * `staff`: Set to "true" to make the user global staff. * `course_id`: Enroll the student in the course with `course_id` + * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` If username, email, or password are not provided, use randomly generated credentials. @@ -1226,6 +1229,7 @@ def auto_auth(request): full_name = request.GET.get('full_name', username) is_staff = request.GET.get('staff', None) course_id = request.GET.get('course_id', None) + role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()] # Get or create the user object post_data = { @@ -1268,6 +1272,11 @@ def auto_auth(request): if course_id is not None: CourseEnrollment.enroll(user, course_id) + # Apply the roles + for role_name in role_names: + role = Role.objects.get(name=role_name, course_id=course_id) + user.roles.add(role) + # Log in as the user user = authenticate(username=username, password=password) login(request, user) diff --git a/common/djangoapps/terrain/stubs/comments.py b/common/djangoapps/terrain/stubs/comments.py index 0d6a538dbe..28f3729b6e 100644 --- a/common/djangoapps/terrain/stubs/comments.py +++ b/common/djangoapps/terrain/stubs/comments.py @@ -2,7 +2,6 @@ Stub implementation of cs_comments_service for acceptance tests """ -from datetime import datetime import re import urlparse from .http import StubHttpRequestHandler, StubHttpService @@ -14,6 +13,7 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): "/api/v1/users/(?P\\d+)$": self.do_user, "/api/v1/threads$": self.do_threads, "/api/v1/threads/(?P\\w+)$": self.do_thread, + "/api/v1/comments/(?P\\w+)$": self.do_comment, } path = urlparse.urlparse(self.path).path for pattern in pattern_handlers: @@ -25,8 +25,13 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): self.send_response(404, content="404 Not Found") def do_PUT(self): + if self.path.startswith('/set_config'): + return StubHttpRequestHandler.do_PUT(self) self.send_response(204, "") + def do_DELETE(self): + self.send_json_response({}) + def do_user(self, user_id): self.send_json_response({ "id": user_id, @@ -36,44 +41,28 @@ class StubCommentsServiceHandler(StubHttpRequestHandler): }) def do_thread(self, thread_id): - match = re.search("(?P\\d+)_responses", thread_id) - resp_total = int(match.group("num")) if match else 0 - thread = { - "id": thread_id, - "commentable_id": "dummy", - "type": "thread", - "title": "Thread title", - "body": "Thread body", - "created_at": datetime.utcnow().isoformat(), - "unread_comments_count": 0, - "comments_count": resp_total, - "votes": {"up_count": 0}, - "abuse_flaggers": [], - "closed": "closed" in thread_id, - } - params = urlparse.parse_qs(urlparse.urlparse(self.path).query) - if "recursive" in params and params["recursive"][0] == "True": - thread["resp_total"] = resp_total - thread["children"] = [] - resp_skip = int(params.get("resp_skip", ["0"])[0]) - resp_limit = int(params.get("resp_limit", ["10000"])[0]) - num_responses = min(resp_limit, resp_total - resp_skip) - self.log_message("Generating {} children; resp_limit={} resp_total={} resp_skip={}".format(num_responses, resp_limit, resp_total, resp_skip)) - for i in range(num_responses): - response_id = str(resp_skip + i) - thread["children"].append({ - "id": str(response_id), - "type": "comment", - "body": response_id, - "created_at": datetime.utcnow().isoformat(), - "votes": {"up_count": 0}, - "abuse_flaggers": [], - }) - self.send_json_response(thread) + if thread_id in self.server.config.get('threads', {}): + thread = self.server.config['threads'][thread_id].copy() + params = urlparse.parse_qs(urlparse.urlparse(self.path).query) + if "recursive" in params and params["recursive"][0] == "True": + thread.setdefault('children', []) + resp_total = thread.setdefault('resp_total', len(thread['children'])) + resp_skip = int(params.get("resp_skip", ["0"])[0]) + resp_limit = int(params.get("resp_limit", ["10000"])[0]) + thread['children'] = thread['children'][resp_skip:(resp_skip + resp_limit)] + self.send_json_response(thread) + else: + self.send_response(404, content="404 Not Found") def do_threads(self): self.send_json_response({"collection": [], "page": 1, "num_pages": 1}) + def do_comment(self, comment_id): + # django_comment_client calls GET comment before doing a DELETE, so that's what this is here to support. + if comment_id in self.server.config.get('comments', {}): + comment = self.server.config['comments'][comment_id] + self.send_json_response(comment) + class StubCommentsService(StubHttpService): HANDLER_CLASS = StubCommentsServiceHandler diff --git a/common/test/acceptance/fixtures/__init__.py b/common/test/acceptance/fixtures/__init__.py index 6ad23459bb..bd01006219 100644 --- a/common/test/acceptance/fixtures/__init__.py +++ b/common/test/acceptance/fixtures/__init__.py @@ -8,3 +8,6 @@ XQUEUE_STUB_URL = os.environ.get('xqueue_url', 'http://localhost:8040') # Get the URL of the Ora stub used in the test ORA_STUB_URL = os.environ.get('ora_url', 'http://localhost:8041') + +# Get the URL of the comments service stub used in the test +COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567') diff --git a/common/test/acceptance/fixtures/discussion.py b/common/test/acceptance/fixtures/discussion.py new file mode 100644 index 0000000000..e9104f199c --- /dev/null +++ b/common/test/acceptance/fixtures/discussion.py @@ -0,0 +1,89 @@ +""" +Tools for creating discussion content fixture data. +""" + +from datetime import datetime +import json + +import factory +import requests + +from . import COMMENTS_STUB_URL + + +class ContentFactory(factory.Factory): + FACTORY_FOR = dict + id = None + user_id = "dummy-user-id" + username = "dummy-username" + course_id = "dummy-course-id" + commentable_id = "dummy-commentable-id" + anonymous = False + anonymous_to_peers = False + at_position_list = [] + abuse_flaggers = [] + created_at = datetime.utcnow().isoformat() + updated_at = datetime.utcnow().isoformat() + endorsed = False + closed = False + votes = {"up_count": 0} + + +class Thread(ContentFactory): + comments_count = 0 + unread_comments_count = 0 + title = "dummy thread title" + body = "dummy thread body" + type = "thread" + group_id = None + pinned = False + read = False + + +class Comment(ContentFactory): + thread_id = None + depth = 0 + type = "comment" + body = "dummy comment body" + + +class Response(Comment): + depth = 1 + body = "dummy response body" + + +class SingleThreadViewFixture(object): + + def __init__(self, thread): + self.thread = thread + + def addResponse(self, response, comments=[]): + response['children'] = comments + self.thread.setdefault('children', []).append(response) + self.thread['comments_count'] += len(comments) + 1 + + def _get_comment_map(self): + """ + Generate a dict mapping each response/comment in the thread + by its `id`. + """ + def _visit(obj): + res = [] + for child in obj.get('children', []): + res.append((child['id'], child)) + if 'children' in child: + res += _visit(child) + return res + return dict(_visit(self.thread)) + + def push(self): + """ + Push the data to the stub comments service. + """ + requests.put( + '{}/set_config'.format(COMMENTS_STUB_URL), + data={ + "threads": json.dumps({self.thread['id']: self.thread}), + "comments": json.dumps(self._get_comment_map()) + } + ) diff --git a/common/test/acceptance/pages/lms/discussion_single_thread.py b/common/test/acceptance/pages/lms/discussion_single_thread.py index 325fa52e30..dda134b883 100644 --- a/common/test/acceptance/pages/lms/discussion_single_thread.py +++ b/common/test/acceptance/pages/lms/discussion_single_thread.py @@ -53,10 +53,7 @@ class DiscussionSingleThreadPage(CoursePage): def has_add_response_button(self): """Returns true if the add response button is visible, false otherwise""" - return ( - self.is_css_present(".add-response-btn") and - self.css_map(".add-response-btn", lambda el: el.visible)[0] - ) + return self._is_element_visible(".add-response-btn") def click_add_response_button(self): """ @@ -68,3 +65,25 @@ class DiscussionSingleThreadPage(CoursePage): lambda: self.is_css_present("#wmd-input-reply-body-{thread_id}:focus".format(thread_id=self.thread_id)), "Response field received focus" )) + + def _is_element_visible(self, selector): + return ( + self.is_css_present(selector) and + self.css_map(selector, lambda el: el.visible)[0] + ) + + def is_comment_visible(self, comment_id): + """Returns true if the comment is viewable onscreen""" + return self._is_element_visible("#comment_{}".format(comment_id)) + + def is_comment_deletable(self, comment_id): + """Returns true if the delete comment button is present, false otherwise""" + return self._is_element_visible("#comment_{} div.action-delete".format(comment_id)) + + def delete_comment(self, comment_id): + with self.handle_alert(): + self.css_click("#comment_{} div.action-delete".format(comment_id)) + fulfill(EmptyPromise( + lambda: not self.is_comment_visible(comment_id), + "Deleted comment was removed" + )) diff --git a/common/test/acceptance/tests/test_discussion.py b/common/test/acceptance/tests/test_discussion.py index 2e282df588..e9ccc2e265 100644 --- a/common/test/acceptance/tests/test_discussion.py +++ b/common/test/acceptance/tests/test_discussion.py @@ -6,6 +6,7 @@ from .helpers import UniqueCourseTest from ..pages.studio.auto_auth import AutoAuthPage from ..pages.lms.discussion_single_thread import DiscussionSingleThreadPage from ..fixtures.course import CourseFixture +from ..fixtures.discussion import SingleThreadViewFixture, Thread, Response, Comment class DiscussionSingleThreadTest(UniqueCourseTest): @@ -19,9 +20,16 @@ class DiscussionSingleThreadTest(UniqueCourseTest): # Create a course to register for CourseFixture(**self.course_info).install() - AutoAuthPage(self.browser, course_id=self.course_id).visit() + self.user_id = AutoAuthPage(self.browser, course_id=self.course_id).visit().get_user_id() + + def setup_thread(self, thread, num_responses): + view = SingleThreadViewFixture(thread=thread) + for i in range(num_responses): + view.addResponse(Response(id=str(i), body=str(i))) + view.push() def test_no_responses(self): + self.setup_thread(Thread(id="0_responses"), 0) page = DiscussionSingleThreadPage(self.browser, self.course_id, "0_responses") page.visit() self.assertEqual(page.get_response_total_text(), "0 responses") @@ -31,6 +39,7 @@ class DiscussionSingleThreadTest(UniqueCourseTest): self.assertIsNone(page.get_load_responses_button_text()) def test_few_responses(self): + self.setup_thread(Thread(id="5_responses"), 5) page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses") page.visit() self.assertEqual(page.get_response_total_text(), "5 responses") @@ -39,6 +48,7 @@ class DiscussionSingleThreadTest(UniqueCourseTest): self.assertIsNone(page.get_load_responses_button_text()) def test_two_response_pages(self): + self.setup_thread(Thread(id="50_responses"), 50) page = DiscussionSingleThreadPage(self.browser, self.course_id, "50_responses") page.visit() self.assertEqual(page.get_response_total_text(), "50 responses") @@ -52,6 +62,7 @@ class DiscussionSingleThreadTest(UniqueCourseTest): self.assertEqual(page.get_load_responses_button_text(), None) def test_three_response_pages(self): + self.setup_thread(Thread(id="150_responses"), 150) page = DiscussionSingleThreadPage(self.browser, self.course_id, "150_responses") page.visit() self.assertEqual(page.get_response_total_text(), "150 responses") @@ -70,12 +81,57 @@ class DiscussionSingleThreadTest(UniqueCourseTest): self.assertEqual(page.get_load_responses_button_text(), None) def test_add_response_button(self): + self.setup_thread(Thread(id="5_responses"), 5) page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses") page.visit() self.assertTrue(page.has_add_response_button()) page.click_add_response_button() def test_add_response_button_closed_thread(self): + self.setup_thread(Thread(id="5_responses_closed", closed=True), 5) page = DiscussionSingleThreadPage(self.browser, self.course_id, "5_responses_closed") page.visit() self.assertFalse(page.has_add_response_button()) + + +class DiscussionCommentDeletionTest(UniqueCourseTest): + """ + Tests for deleting comments displayed beneath responses in the single thread view. + """ + + def setUp(self): + super(DiscussionCommentDeletionTest, self).setUp() + + # Create a course to register for + CourseFixture(**self.course_info).install() + + def setup_user(self, roles=[]): + roles_str = ','.join(roles) + self.user_id = AutoAuthPage(self.browser, course_id=self.course_id, roles=roles_str).visit().get_user_id() + + def setup_view(self): + view = SingleThreadViewFixture(Thread(id="comment_deletion_test_thread")) + view.addResponse( + Response(id="response1"), + [Comment(id="comment_other_author", user_id="other"), Comment(id="comment_self_author", user_id=self.user_id)]) + view.push() + + def test_comment_deletion_as_student(self): + self.setup_user() + self.setup_view() + page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread") + page.visit() + self.assertTrue(page.is_comment_deletable("comment_self_author")) + self.assertTrue(page.is_comment_visible("comment_other_author")) + self.assertFalse(page.is_comment_deletable("comment_other_author")) + page.delete_comment("comment_self_author") + + def test_comment_deletion_as_moderator(self): + self.setup_user(roles=['Moderator']) + self.setup_view() + page = DiscussionSingleThreadPage(self.browser, self.course_id, "comment_deletion_test_thread") + page.visit() + self.assertTrue(page.is_comment_deletable("comment_self_author")) + self.assertTrue(page.is_comment_deletable("comment_other_author")) + page.delete_comment("comment_self_author") + page.delete_comment("comment_other_author")