""" Tests for the EdxNotes app. """ import json from contextlib import contextmanager from datetime import datetime from unittest import skipUnless import ddt import jwt import six from six import text_type from six.moves.urllib.parse import urlparse, parse_qs # pylint: disable=import-error from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse from mock import MagicMock, patch from oauth2_provider.models import Application from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.module_render import get_module_for_descriptor from lms.djangoapps.courseware.tabs import get_course_tab_list from edxmako.shortcuts import render_to_string from edxnotes import helpers from edxnotes.decorators import edxnotes from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable from edxnotes.plugins import EdxNotesTab from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus from student.tests.factories import CourseEnrollmentFactory, SuperuserFactory, UserFactory from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.tabs import CourseTab FEATURES = settings.FEATURES.copy() NOTES_API_EMPTY_RESPONSE = { "total": 0, "rows": [], "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 0, } NOTES_VIEW_EMPTY_RESPONSE = { "count": 0, "results": [], "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 0, } def enable_edxnotes_for_the_course(course, user_id): """ Enable EdxNotes for the course. """ course.tabs.append(CourseTab.load("edxnotes")) modulestore().update_item(course, user_id) @edxnotes class TestProblem(object): """ Test class (fake problem) decorated by edxnotes decorator. The purpose of this class is to imitate any problem. """ def __init__(self, course, user=None): self.system = MagicMock(is_author_mode=False) self.scope_ids = MagicMock(usage_id="test_usage_id") user = user or UserFactory() self.runtime = MagicMock(course_id=course.id, get_real_user=lambda __: user) self.descriptor = MagicMock() self.descriptor.runtime.modulestore.get_course.return_value = course def get_html(self): """ Imitate get_html in module. """ return "original_get_html" @skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") class EdxNotesDecoratorTest(ModuleStoreTestCase): """ Tests for edxnotes decorator. """ def setUp(self): super(EdxNotesDecoratorTest, self).setUp() ApplicationFactory(name="edx-notes") # Using old mongo because of locator comparison issues (see longer # note below in EdxNotesHelpersTest setUp. self.course = CourseFactory(edxnotes=True, default_store=ModuleStoreEnum.Type.mongo) self.user = UserFactory() self.client.login(username=self.user.username, password=UserFactory._DEFAULT_PASSWORD) self.problem = TestProblem(self.course, self.user) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True}) @patch("edxnotes.helpers.get_public_endpoint", autospec=True) @patch("edxnotes.helpers.get_token_url", autospec=True) @patch("edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("edxnotes.helpers.generate_uid", autospec=True) def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_token_url, mock_get_endpoint): """ Tests if get_html is wrapped when feature flag is on and edxnotes are enabled for the course. """ course = CourseFactory(edxnotes=True) enrollment = CourseEnrollmentFactory(course_id=course.id) user = enrollment.user problem = TestProblem(course, user) mock_generate_uid.return_value = "uid" mock_get_id_token.return_value = "token" mock_get_token_url.return_value = "/tokenUrl" mock_get_endpoint.return_value = "/endpoint" enable_edxnotes_for_the_course(course, user.id) expected_context = { "content": "original_get_html", "uid": "uid", "edxnotes_visibility": "true", "params": { "usageId": "test_usage_id", "courseId": course.id, "token": "token", "tokenUrl": "/tokenUrl", "endpoint": "/endpoint", "debug": settings.DEBUG, "eventStringLimit": settings.TRACK_MAX_EVENT / 6, }, } self.assertEqual( problem.get_html(), render_to_string("edxnotes_wrapper.html", expected_context), ) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_edxnotes_disabled_if_edxnotes_flag_is_false(self): """ Tests that get_html is wrapped when feature flag is on, but edxnotes are disabled for the course. """ self.course.edxnotes = False self.assertEqual("original_get_html", self.problem.get_html()) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_edxnotes_disabled(self): """ Tests that get_html is not wrapped when feature flag is off. """ self.assertEqual("original_get_html", self.problem.get_html()) def test_edxnotes_studio(self): """ Tests that get_html is not wrapped when problem is rendered in Studio. """ self.problem.system.is_author_mode = True self.assertEqual("original_get_html", self.problem.get_html()) def test_edxnotes_blockstore_runtime(self): """ Tests that get_html is not wrapped when problem is rendered by Blockstore runtime. """ del self.problem.descriptor.runtime.modulestore self.assertEqual("original_get_html", self.problem.get_html()) def test_edxnotes_harvard_notes_enabled(self): """ Tests that get_html is not wrapped when Harvard Annotation Tool is enabled. """ self.course.advanced_modules = ["videoannotation", "imageannotation", "textannotation"] enable_edxnotes_for_the_course(self.course, self.user.id) self.assertEqual("original_get_html", self.problem.get_html()) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_anonymous_user(self): user = AnonymousUser() problem = TestProblem(self.course, user) enable_edxnotes_for_the_course(self.course, None) assert problem.get_html() == "original_get_html" @skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") @ddt.ddt class EdxNotesHelpersTest(ModuleStoreTestCase): """ Tests for EdxNotes helpers. """ def setUp(self): """ Setup a dummy course content. """ super(EdxNotesHelpersTest, self).setUp() # There are many tests that are comparing locators as returned from helper methods. When using # the split modulestore, some of those locators have version and branch information, but the # comparison values do not. This needs further investigation in order to enable these tests # with the split modulestore. with self.store.default_store(ModuleStoreEnum.Type.mongo): ApplicationFactory(name="edx-notes") self.course = CourseFactory.create() self.chapter = ItemFactory.create(category="chapter", parent_location=self.course.location) self.chapter_2 = ItemFactory.create(category="chapter", parent_location=self.course.location) self.sequential = ItemFactory.create(category="sequential", parent_location=self.chapter.location) self.vertical = ItemFactory.create(category="vertical", parent_location=self.sequential.location) self.html_module_1 = ItemFactory.create(category="html", parent_location=self.vertical.location) self.html_module_2 = ItemFactory.create(category="html", parent_location=self.vertical.location) self.vertical_with_container = ItemFactory.create( category='vertical', parent_location=self.sequential.location ) self.child_container = ItemFactory.create( category='split_test', parent_location=self.vertical_with_container.location) self.child_vertical = ItemFactory.create(category='vertical', parent_location=self.child_container.location) self.child_html_module = ItemFactory.create(category="html", parent_location=self.child_vertical.location) # Read again so that children lists are accurate self.course = self.store.get_item(self.course.location) self.chapter = self.store.get_item(self.chapter.location) self.chapter_2 = self.store.get_item(self.chapter_2.location) self.sequential = self.store.get_item(self.sequential.location) self.vertical = self.store.get_item(self.vertical.location) self.vertical_with_container = self.store.get_item(self.vertical_with_container.location) self.child_container = self.store.get_item(self.child_container.location) self.child_vertical = self.store.get_item(self.child_vertical.location) self.child_html_module = self.store.get_item(self.child_html_module.location) self.user = UserFactory() self.client.login(username=self.user.username, password=UserFactory._DEFAULT_PASSWORD) self.request = RequestFactory().request() self.request.user = self.user def _get_unit_url(self, course, chapter, section, position=1): """ Returns `jump_to_id` url for the `vertical`. """ return reverse('courseware_position', kwargs={ 'course_id': course.id, 'chapter': chapter.url_name, 'section': section.url_name, 'position': position, }) def test_edxnotes_harvard_notes_enabled(self): """ Tests that edxnotes are disabled when Harvard Annotation Tool is enabled. """ self.course.advanced_modules = ['imageannotation', 'textannotation', 'videoannotation'] assert not helpers.is_feature_enabled(self.course, self.user) @ddt.data(True, False) def test_is_feature_enabled(self, enabled): """ Tests that is_feature_enabled shows correct behavior. """ course = CourseFactory(edxnotes=enabled) enrollment = CourseEnrollmentFactory(course_id=course.id) assert helpers.is_feature_enabled(course, enrollment.user) == enabled @ddt.data( helpers.get_public_endpoint, helpers.get_internal_endpoint, ) def test_get_endpoints(self, get_endpoint_function): """ Test that the get_public_endpoint and get_internal_endpoint functions return appropriate values. """ @contextmanager def patch_edxnotes_api_settings(url): """ Convenience function for patching both EDXNOTES_PUBLIC_API and EDXNOTES_INTERNAL_API. """ with override_settings(EDXNOTES_PUBLIC_API=url): with override_settings(EDXNOTES_INTERNAL_API=url): yield # url ends with "/" with patch_edxnotes_api_settings("http://example.com/"): self.assertEqual("http://example.com/", get_endpoint_function()) # url doesn't have "/" at the end with patch_edxnotes_api_settings("http://example.com"): self.assertEqual("http://example.com/", get_endpoint_function()) # url with path that starts with "/" with patch_edxnotes_api_settings("http://example.com"): self.assertEqual("http://example.com/some_path/", get_endpoint_function("/some_path")) # url with path without "/" with patch_edxnotes_api_settings("http://example.com"): self.assertEqual("http://example.com/some_path/", get_endpoint_function("some_path/")) # url is not configured with patch_edxnotes_api_settings(None): self.assertRaises(ImproperlyConfigured, get_endpoint_function) @patch("edxnotes.helpers.requests.get", autospec=True) def test_get_notes_correct_data(self, mock_get): """ Tests the result if correct data is received. """ mock_get.return_value.content = json.dumps( { "total": 2, "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 1, "rows": [ { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }, { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_2.location), u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), } ] } ).encode('utf-8') six.assertCountEqual( self, { "count": 2, "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 1, "results": [ { u"quote": u"quote text", u"text": u"text", u"chapter": { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)] }, u"section": { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [ text_type(self.vertical.location), text_type(self.vertical_with_container.location) ] }, u"unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u"usage_id": text_type(self.html_module_2.location), u"updated": "Nov 19, 2014 at 08:06 UTC", }, { u"quote": u"quote text", u"text": u"text", u"chapter": { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)] }, u"section": { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [ text_type(self.vertical.location), text_type(self.vertical_with_container.location)] }, u"unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u"usage_id": text_type(self.html_module_1.location), u"updated": "Nov 19, 2014 at 08:05 UTC", }, ] }, helpers.get_notes(self.request, self.course) ) @patch("edxnotes.helpers.requests.get", autospec=True) def test_get_notes_json_error(self, mock_get): """ Tests the result if incorrect json is received. """ mock_get.return_value.content = b"Error" self.assertRaises(EdxNotesParseError, helpers.get_notes, self.request, self.course) @patch("edxnotes.helpers.requests.get", autospec=True) def test_get_notes_empty_collection(self, mock_get): """ Tests the result if an empty response is received. """ mock_get.return_value.content = json.dumps({}).encode('utf-8') self.assertRaises(EdxNotesParseError, helpers.get_notes, self.request, self.course) @patch("edxnotes.helpers.requests.get", autospec=True) def test_search_correct_data(self, mock_get): """ Tests the result if correct data is received. """ mock_get.return_value.content = json.dumps({ "total": 2, "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 1, "rows": [ { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }, { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_2.location), u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), } ] }).encode('utf-8') six.assertCountEqual( self, { "count": 2, "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 1, "results": [ { u"quote": u"quote text", u"text": u"text", u"chapter": { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)] }, u"section": { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [ text_type(self.vertical.location), text_type(self.vertical_with_container.location)] }, u"unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u"usage_id": text_type(self.html_module_2.location), u"updated": "Nov 19, 2014 at 08:06 UTC", }, { u"quote": u"quote text", u"text": u"text", u"chapter": { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)] }, u"section": { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [ text_type(self.vertical.location), text_type(self.vertical_with_container.location)] }, u"unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u"usage_id": text_type(self.html_module_1.location), u"updated": "Nov 19, 2014 at 08:05 UTC", }, ] }, helpers.get_notes(self.request, self.course) ) @patch("edxnotes.helpers.requests.get", autospec=True) def test_search_json_error(self, mock_get): """ Tests the result if incorrect json is received. """ mock_get.return_value.content = b"Error" self.assertRaises(EdxNotesParseError, helpers.get_notes, self.request, self.course) @patch("edxnotes.helpers.requests.get", autospec=True) def test_search_wrong_data_format(self, mock_get): """ Tests the result if incorrect data structure is received. """ mock_get.return_value.content = json.dumps({"1": 2}).encode('utf-8') self.assertRaises(EdxNotesParseError, helpers.get_notes, self.request, self.course) @patch("edxnotes.helpers.requests.get", autospec=True) def test_search_empty_collection(self, mock_get): """ Tests no results. """ mock_get.return_value.content = json.dumps(NOTES_API_EMPTY_RESPONSE).encode('utf-8') six.assertCountEqual( self, NOTES_VIEW_EMPTY_RESPONSE, helpers.get_notes(self.request, self.course) ) @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("edxnotes.helpers.anonymous_id_for_user", autospec=True) @patch("edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("edxnotes.helpers.requests.post") def test_delete_all_notes_for_user(self, mock_post, mock_get_id_token, mock_anonymous_id_for_user): """ Test GDPR data deletion for Notes user_id """ mock_anonymous_id_for_user.return_value = "anonymous_id" mock_get_id_token.return_value = "test_token" helpers.delete_all_notes_for_user(self.user) mock_post.assert_called_with( url='http://example.com/retire_annotations/', headers={ 'x-annotator-auth-token': 'test_token' }, data={ 'user': 'anonymous_id' }, timeout=(settings.EDXNOTES_CONNECT_TIMEOUT, settings.EDXNOTES_READ_TIMEOUT) ) def test_preprocess_collection_no_item(self): """ Tests the result if appropriate module is not found. """ initial_collection = [ { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat() }, { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.course.id.make_usage_key("html", "test_item")), u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat() }, ] six.assertCountEqual( self, [{ u"quote": u"quote text", u"text": u"text", u"chapter": { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)] }, u"section": { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [text_type(self.vertical.location), text_type(self.vertical_with_container.location)] }, u"unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000), }], helpers.preprocess_collection(self.user, self.course, initial_collection) ) def test_preprocess_collection_has_access(self): """ Tests the result if the user does not have access to some of the modules. """ initial_collection = [ { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }, { u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_2.location), u"updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), }, ] self.html_module_2.visible_to_staff_only = True self.store.update_item(self.html_module_2, self.user.id) six.assertCountEqual( self, [{ u"quote": u"quote text", u"text": u"text", u"chapter": { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)] }, u"section": { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [text_type(self.vertical.location), text_type(self.vertical_with_container.location)] }, u"unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000), }], helpers.preprocess_collection(self.user, self.course, initial_collection) ) @patch("edxnotes.helpers.has_access", autospec=True) @patch("edxnotes.helpers.modulestore", autospec=True) def test_preprocess_collection_no_unit(self, mock_modulestore, mock_has_access): """ Tests the result if the unit does not exist. """ store = MagicMock() store.get_item().get_parent.return_value = None mock_modulestore.return_value = store mock_has_access.return_value = True initial_collection = [{ u"quote": u"quote text", u"text": u"text", u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }] six.assertCountEqual( self, [], helpers.preprocess_collection(self.user, self.course, initial_collection) ) @override_settings(NOTES_DISABLED_TABS=['course_structure', 'tags']) def test_preprocess_collection_with_disabled_tabs(self, ): """ Tests that preprocess collection returns correct data if `course_structure` and `tags` are disabled. """ initial_collection = [ { u"quote": u"quote text1", u"text": u"text1", u"usage_id": text_type(self.html_module_1.location), u"updated": datetime(2016, 1, 26, 8, 5, 16, 00000).isoformat(), }, { u"quote": u"quote text2", u"text": u"text2", u"usage_id": text_type(self.html_module_2.location), u"updated": datetime(2016, 1, 26, 9, 6, 17, 00000).isoformat(), }, ] six.assertCountEqual( self, [ { 'section': {}, 'chapter': {}, "unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u'text': u'text1', u'quote': u'quote text1', u'usage_id': text_type(self.html_module_1.location), u'updated': datetime(2016, 1, 26, 8, 5, 16) }, { 'section': {}, 'chapter': {}, "unit": { u"url": self._get_unit_url(self.course, self.chapter, self.sequential), u"display_name": self.vertical.display_name_with_default, u"location": text_type(self.vertical.location), }, u'text': u'text2', u'quote': u'quote text2', u'usage_id': text_type(self.html_module_2.location), u'updated': datetime(2016, 1, 26, 9, 6, 17) } ], helpers.preprocess_collection(self.user, self.course, initial_collection) ) def test_get_module_context_sequential(self): """ Tests `get_module_context` method for the sequential. """ self.assertDictEqual( { u"display_name": self.sequential.display_name_with_default, u"location": text_type(self.sequential.location), u"children": [text_type(self.vertical.location), text_type(self.vertical_with_container.location)], }, helpers.get_module_context(self.course, self.sequential) ) def test_get_module_context_html_component(self): """ Tests `get_module_context` method for the components. """ self.assertDictEqual( { u"display_name": self.html_module_1.display_name_with_default, u"location": text_type(self.html_module_1.location), }, helpers.get_module_context(self.course, self.html_module_1) ) def test_get_module_context_chapter(self): """ Tests `get_module_context` method for the chapters. """ self.assertDictEqual( { u"display_name": self.chapter.display_name_with_default, u"index": 0, u"location": text_type(self.chapter.location), u"children": [text_type(self.sequential.location)], }, helpers.get_module_context(self.course, self.chapter) ) self.assertDictEqual( { u"display_name": self.chapter_2.display_name_with_default, u"index": 1, u"location": text_type(self.chapter_2.location), u"children": [], }, helpers.get_module_context(self.course, self.chapter_2) ) @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("edxnotes.helpers.anonymous_id_for_user", autospec=True) @patch("edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("edxnotes.helpers.requests.get", autospec=True) def test_send_request_with_text_param(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user): """ Tests that requests are send with correct information. """ mock_get_id_token.return_value = "test_token" mock_anonymous_id_for_user.return_value = "anonymous_id" helpers.send_request( self.user, self.course.id, path="test", text="text", page=helpers.DEFAULT_PAGE, page_size=helpers.DEFAULT_PAGE_SIZE ) mock_get.assert_called_with( "http://example.com/test/", headers={ "x-annotator-auth-token": "test_token" }, params={ "user": "anonymous_id", "course_id": text_type(self.course.id), "text": "text", "highlight": True, 'page': 1, 'page_size': 25, }, timeout=(settings.EDXNOTES_CONNECT_TIMEOUT, settings.EDXNOTES_READ_TIMEOUT) ) @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("edxnotes.helpers.anonymous_id_for_user", autospec=True) @patch("edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("edxnotes.helpers.requests.get", autospec=True) def test_send_request_without_text_param(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user): """ Tests that requests are send with correct information. """ mock_get_id_token.return_value = "test_token" mock_anonymous_id_for_user.return_value = "anonymous_id" helpers.send_request( self.user, self.course.id, path="test", page=1, page_size=25 ) mock_get.assert_called_with( "http://example.com/test/", headers={ "x-annotator-auth-token": "test_token" }, params={ "user": "anonymous_id", "course_id": text_type(self.course.id), 'page': helpers.DEFAULT_PAGE, 'page_size': helpers.DEFAULT_PAGE_SIZE, }, timeout=(settings.EDXNOTES_CONNECT_TIMEOUT, settings.EDXNOTES_READ_TIMEOUT) ) def test_get_course_position_no_chapter(self): """ Returns `None` if no chapter found. """ mock_course_module = MagicMock() mock_course_module.position = 3 mock_course_module.get_display_items.return_value = [] self.assertIsNone(helpers.get_course_position(mock_course_module)) def test_get_course_position_to_chapter(self): """ Returns a position that leads to COURSE/CHAPTER if this isn't the users's first time. """ mock_course_module = MagicMock(id=self.course.id, position=3) mock_chapter = MagicMock() mock_chapter.url_name = 'chapter_url_name' mock_chapter.display_name_with_default = 'Test Chapter Display Name' mock_course_module.get_display_items.return_value = [mock_chapter] self.assertEqual(helpers.get_course_position(mock_course_module), { 'display_name': 'Test Chapter Display Name', 'url': '/courses/{}/courseware/chapter_url_name/'.format(self.course.id), }) def test_get_course_position_no_section(self): """ Returns `None` if no section found. """ mock_course_module = MagicMock(id=self.course.id, position=None) mock_course_module.get_display_items.return_value = [MagicMock()] self.assertIsNone(helpers.get_course_position(mock_course_module)) def test_get_course_position_to_section(self): """ Returns a position that leads to COURSE/CHAPTER/SECTION if this is the user's first time. """ mock_course_module = MagicMock(id=self.course.id, position=None) mock_chapter = MagicMock() mock_chapter.url_name = 'chapter_url_name' mock_course_module.get_display_items.return_value = [mock_chapter] mock_section = MagicMock() mock_section.url_name = 'section_url_name' mock_section.display_name_with_default = 'Test Section Display Name' mock_chapter.get_display_items.return_value = [mock_section] mock_section.get_display_items.return_value = [MagicMock()] self.assertEqual(helpers.get_course_position(mock_course_module), { 'display_name': 'Test Section Display Name', 'url': '/courses/{}/courseware/chapter_url_name/section_url_name/'.format(self.course.id), }) def test_get_index(self): """ Tests `get_index` method returns unit url. """ children = self.sequential.children self.assertEqual(0, helpers.get_index(text_type(self.vertical.location), children)) self.assertEqual(1, helpers.get_index(text_type(self.vertical_with_container.location), children)) @ddt.unpack @ddt.data( {'previous_api_url': None, 'next_api_url': None}, {'previous_api_url': None, 'next_api_url': 'edxnotes/?course_id=abc&page=2&page_size=10&user=123'}, {'previous_api_url': 'edxnotes.org/?course_id=abc&page=2&page_size=10&user=123', 'next_api_url': None}, { 'previous_api_url': 'edxnotes.org/?course_id=abc&page_size=10&user=123', 'next_api_url': 'edxnotes.org/?course_id=abc&page=3&page_size=10&user=123' }, { 'previous_api_url': 'edxnotes.org/?course_id=abc&page=2&page_size=10&text=wow&user=123', 'next_api_url': 'edxnotes.org/?course_id=abc&page=4&page_size=10&text=wow&user=123' }, ) def test_construct_url(self, previous_api_url, next_api_url): """ Verify that `construct_url` works correctly. """ # make absolute url if self.request.is_secure(): host = 'https://' + self.request.get_host() else: host = 'http://' + self.request.get_host() notes_url = host + reverse("notes", args=[text_type(self.course.id)]) def verify_url(constructed, expected): """ Verify that constructed url is correct. """ # if api url is None then constructed url should also be None if expected is None: self.assertEqual(expected, constructed) else: # constructed url should startswith notes view url instead of api view url self.assertTrue(constructed.startswith(notes_url)) # constructed url should not contain extra params self.assertNotIn('user', constructed) # constructed url should only has these params if present in api url allowed_params = ('page', 'page_size', 'text') # extract query params from constructed url parsed = urlparse(constructed) params = parse_qs(parsed.query) # verify that constructed url has only correct params and params have correct values for param, value in params.items(): self.assertIn(param, allowed_params) self.assertIn('{}={}'.format(param, value[0]), expected) next_url, previous_url = helpers.construct_pagination_urls( self.request, self.course.id, next_api_url, previous_api_url ) verify_url(next_url, next_api_url) verify_url(previous_url, previous_api_url) @skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") @ddt.ddt class EdxNotesViewsTest(ModuleStoreTestCase): """ Tests for EdxNotes views. """ def setUp(self): ApplicationFactory(name="edx-notes") super(EdxNotesViewsTest, self).setUp() self.course = CourseFactory(edxnotes=True) self.user = UserFactory() CourseEnrollmentFactory(user=self.user, course_id=self.course.id) self.client.login(username=self.user.username, password=UserFactory._DEFAULT_PASSWORD) self.notes_page_url = reverse("edxnotes", args=[text_type(self.course.id)]) self.notes_url = reverse("notes", args=[text_type(self.course.id)]) self.get_token_url = reverse("get_token", args=[text_type(self.course.id)]) self.visibility_url = reverse("edxnotes_visibility", args=[text_type(self.course.id)]) def _get_course_module(self): """ Returns the course module. """ field_data_cache = FieldDataCache([self.course], self.course.id, self.user) return get_module_for_descriptor( self.user, MagicMock(), self.course, field_data_cache, self.course.id, course=self.course ) def test_edxnotes_tab(self): """ Tests that edxnotes tab is shown only when the feature is enabled. """ def has_notes_tab(user, course): """Returns true if the "Notes" tab is shown.""" tabs = get_course_tab_list(user, course) return len([tab for tab in tabs if tab.type == 'edxnotes']) == 1 self.assertFalse(has_notes_tab(self.user, self.course)) enable_edxnotes_for_the_course(self.course, self.user.id) # disable course.edxnotes self.course.edxnotes = False self.assertFalse(has_notes_tab(self.user, self.course)) # reenable course.edxnotes self.course.edxnotes = True self.assertTrue(has_notes_tab(self.user, self.course)) # pylint: disable=unused-argument @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("edxnotes.views.get_notes", return_value={'results': []}) def test_edxnotes_view_is_enabled(self, mock_get_notes): """ Tests that appropriate view is received if EdxNotes feature is enabled. """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_page_url) self.assertContains(response, 'Highlights and notes you've made in course content') # pylint: disable=unused-argument @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("edxnotes.views.get_notes", return_value={'results': []}) @patch("edxnotes.views.get_course_position", return_value={'display_name': 'Section 1', 'url': 'test_url'}) def test_edxnotes_html_tags_should_not_be_escaped(self, mock_get_notes, mock_position): """ Tests that explicit html tags rendered correctly. """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_page_url) self.assertContains( response, 'Get started by making a note in something you just read, like Section 1' ) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_edxnotes_view_is_disabled(self): """ Tests that 404 status code is received if EdxNotes feature is disabled. """ response = self.client.get(self.notes_page_url) self.assertEqual(response.status_code, 404) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("edxnotes.views.get_notes", autospec=True) def test_search_notes_successfully_respond(self, mock_search): """ Tests that search notes successfully respond if EdxNotes feature is enabled. """ mock_search.return_value = NOTES_VIEW_EMPTY_RESPONSE enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_url, {"text": "test"}) self.assertEqual(json.loads(response.content.decode('utf-8')), NOTES_VIEW_EMPTY_RESPONSE) self.assertEqual(response.status_code, 200) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_search_notes_is_disabled(self): """ Tests that 404 status code is received if EdxNotes feature is disabled. """ response = self.client.get(self.notes_url, {"text": "test"}) self.assertEqual(response.status_code, 404) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("edxnotes.views.get_notes", autospec=True) def test_search_500_service_unavailable(self, mock_search): """ Tests that 500 status code is received if EdxNotes service is unavailable. """ mock_search.side_effect = EdxNotesServiceUnavailable enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_url, {"text": "test"}) self.assertContains(response, "error", status_code=500) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("edxnotes.views.get_notes", autospec=True) def test_search_notes_exception(self, mock_search): """ Tests that 500 status code is received if invalid data was received from EdXNotes service. """ mock_search.side_effect = EdxNotesParseError enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.get(self.notes_url, {"text": "test"}) self.assertContains(response, "error", status_code=500) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_get_id_token(self): """ Test generation of ID Token. """ response = self.client.get(self.get_token_url) self.assertEqual(response.status_code, 200) client = Application.objects.get(name='edx-notes') jwt.decode(response.content, client.client_secret, audience=client.client_id) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_get_id_token_anonymous(self): """ Test that generation of ID Token does not work for anonymous user. """ self.client.logout() response = self.client.get(self.get_token_url) self.assertEqual(response.status_code, 302) def test_edxnotes_visibility(self): """ Can update edxnotes_visibility value successfully. """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.post( self.visibility_url, data=json.dumps({"visibility": False}), content_type="application/json", ) self.assertEqual(response.status_code, 200) course_module = self._get_course_module() self.assertFalse(course_module.edxnotes_visibility) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": False}) def test_edxnotes_visibility_if_feature_is_disabled(self): """ Tests that 404 response is received if EdxNotes feature is disabled. """ response = self.client.post(self.visibility_url) self.assertEqual(response.status_code, 404) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_edxnotes_visibility_invalid_json(self): """ Tests that 400 response is received if invalid JSON is sent. """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.post( self.visibility_url, data="string", content_type="application/json", ) self.assertEqual(response.status_code, 400) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) def test_edxnotes_visibility_key_error(self): """ Tests that 400 response is received if invalid data structure is sent. """ enable_edxnotes_for_the_course(self.course, self.user.id) response = self.client.post( self.visibility_url, data=json.dumps({'test_key': 1}), content_type="application/json", ) self.assertEqual(response.status_code, 400) class EdxNotesRetireAPITest(ModuleStoreTestCase): """ Tests for EdxNotes retirement API. """ def setUp(self): ApplicationFactory(name="edx-notes") super(EdxNotesRetireAPITest, self).setUp() # setup relevant states RetirementState.objects.create(state_name='PENDING', state_execution_order=1) self.retire_notes_state = RetirementState.objects.create(state_name='RETIRING_NOTES', state_execution_order=11) self.something_complete_state = RetirementState.objects.create( state_name='SOMETHING_COMPLETE', state_execution_order=22, ) # setup retired user with retirement status self.retired_user = UserFactory() self.retirement = UserRetirementStatus.create_retirement(self.retired_user) self.retirement.current_state = self.retire_notes_state self.retirement.save() # setup another normal user which should not be allowed to retire any notes self.normal_user = UserFactory() # setup superuser for making API calls self.superuser = SuperuserFactory() self.retire_user_url = reverse("edxnotes_retire_user") def _build_jwt_headers(self, user): """ Helper function for creating headers for the JWT authentication. """ token = create_jwt_for_user(user) headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} return headers @patch("edxnotes.helpers.requests.post", autospec=True) def test_retire_user_success(self, mock_post): """ Tests that 204 response is received on success. """ mock_post.return_value.content = b'' mock_post.return_value.status_code = 204 headers = self._build_jwt_headers(self.superuser) response = self.client.post( self.retire_user_url, data=json.dumps({'username': self.retired_user.username}), content_type='application/json', **headers ) self.assertEqual(response.status_code, 204) def test_retire_user_normal_user_not_allowed(self): """ Tests that 403 response is received when the requester is not allowed to call the retirement endpoint. """ headers = self._build_jwt_headers(self.normal_user) response = self.client.post( self.retire_user_url, data=json.dumps({'username': self.retired_user.username}), content_type='application/json', **headers ) self.assertEqual(response.status_code, 403) def test_retire_user_status_not_found(self): """ Tests that 404 response is received if the retirement user status is not found. """ headers = self._build_jwt_headers(self.superuser) response = self.client.post( self.retire_user_url, data=json.dumps({'username': 'username_does_not_exist'}), content_type='application/json', **headers ) self.assertEqual(response.status_code, 404) def test_retire_user_wrong_state(self): """ Tests that 405 response is received if the retirement user status is currently in a state which cannot be acted on. """ # Set state to the _COMPLETE version of an arbitrary "SOMETHING" state. self.retirement.current_state = self.something_complete_state self.retirement.save() headers = self._build_jwt_headers(self.superuser) response = self.client.post( self.retire_user_url, data=json.dumps({'username': self.retired_user.username}), content_type='application/json', **headers ) self.assertEqual(response.status_code, 405) @patch("edxnotes.helpers.delete_all_notes_for_user", autospec=True) def test_retire_user_downstream_unavailable(self, mock_delete_all_notes_for_user): """ Tests that 500 response is received if the downstream (i.e. the EdxNotes IDA) is unavailable. """ mock_delete_all_notes_for_user.side_effect = EdxNotesServiceUnavailable headers = self._build_jwt_headers(self.superuser) response = self.client.post( self.retire_user_url, data=json.dumps({'username': self.retired_user.username}), content_type='application/json', **headers ) self.assertEqual(response.status_code, 500) @skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.") @ddt.ddt class EdxNotesPluginTest(ModuleStoreTestCase): """ EdxNotesTab tests. """ def setUp(self): super(EdxNotesPluginTest, self).setUp() self.course = CourseFactory.create(edxnotes=True) self.user = UserFactory() CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) def test_edxnotes_tab_with_unenrolled_user(self): user = UserFactory() assert not EdxNotesTab.is_enabled(self.course, user=user) @ddt.data(True, False) def test_edxnotes_tab_with_feature_flag(self, enabled): """ Verify EdxNotesTab visibility when ENABLE_EDXNOTES feature flag is enabled/disabled. """ FEATURES['ENABLE_EDXNOTES'] = enabled with override_settings(FEATURES=FEATURES): assert EdxNotesTab.is_enabled(self.course, self.user) == enabled