""" Tests for the EdxNotes app. """ import json from contextlib import contextmanager from datetime import datetime from unittest import skipUnless from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse import ddt import jwt import pytest 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 oauth2_provider.models import Application from common.djangoapps.edxmako.shortcuts import render_to_string from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, SuperuserFactory, UserFactory from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor from lms.djangoapps.courseware.tabs import get_course_tab_list 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 xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order from xmodule.tabs import CourseTab # lint-amnesty, pylint: disable=wrong-import-order from xmodule.tests.helpers import StubUserService # lint-amnesty, pylint: disable=wrong-import-order from . import helpers from .decorators import edxnotes from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable from .plugins import EdxNotesTab 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: """ 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.scope_ids = MagicMock(usage_id=course.id.make_usage_key('test_problem', 'test_usage_id')) user = user or UserFactory() user_service = StubUserService(user) self.runtime = MagicMock(service=lambda _a, _b: user_service, is_author_mode=False) self.block = MagicMock() self.block.runtime.modulestore.get_course.return_value = course def get_html(self): """ Imitate get_html in block. """ 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().setUp() ApplicationFactory(name="edx-notes") self.course = CourseFactory(edxnotes=True, default_store=ModuleStoreEnum.Type.split) self.user = UserFactory() self.client.login(username=self.user.username, password=UserFactory._DEFAULT_PASSWORD) # lint-amnesty, pylint: disable=protected-access self.problem = TestProblem(self.course, self.user) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True}) @patch("lms.djangoapps.edxnotes.helpers.get_public_endpoint", autospec=True) @patch("lms.djangoapps.edxnotes.helpers.get_token_url", autospec=True) @patch("lms.djangoapps.edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("lms.djangoapps.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": problem.scope_ids.usage_id, "courseId": course.id, "token": "token", "tokenUrl": "/tokenUrl", "endpoint": "/endpoint", "debug": settings.DEBUG, "eventStringLimit": settings.TRACK_MAX_EVENT / 6, }, } assert 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 assert '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. """ assert '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.runtime.is_author_mode = True assert 'original_get_html' == self.problem.get_html() def test_edxnotes_learning_core_runtime(self): """ Tests that get_html is not wrapped when problem is rendered by the learning core runtime. """ del self.problem.block.runtime.modulestore assert '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) assert '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().setUp() with self.store.default_store(ModuleStoreEnum.Type.split): ApplicationFactory(name="edx-notes") self.course = CourseFactory.create() self.chapter = BlockFactory.create(category="chapter", parent_location=self.course.location) self.chapter_2 = BlockFactory.create(category="chapter", parent_location=self.course.location) self.sequential = BlockFactory.create(category="sequential", parent_location=self.chapter.location) self.vertical = BlockFactory.create(category="vertical", parent_location=self.sequential.location) self.html_block_1 = BlockFactory.create(category="html", parent_location=self.vertical.location) self.html_block_2 = BlockFactory.create(category="html", parent_location=self.vertical.location) self.vertical_with_container = BlockFactory.create( category='vertical', parent_location=self.sequential.location ) self.child_container = BlockFactory.create( category='split_test', parent_location=self.vertical_with_container.location) self.child_vertical = BlockFactory.create( category='vertical', parent_location=self.child_container.location) self.child_html_block = BlockFactory.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_block = self.store.get_item(self.child_html_block.location) self.user = UserFactory() self.client.login(username=self.user.username, password=UserFactory._DEFAULT_PASSWORD) # lint-amnesty, pylint: disable=protected-access 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/"): assert 'http://example.com/' == get_endpoint_function() # url doesn't have "/" at the end with patch_edxnotes_api_settings("http://example.com"): assert 'http://example.com/' == get_endpoint_function() # url with path that starts with "/" with patch_edxnotes_api_settings("http://example.com"): assert 'http://example.com/some_path/' == get_endpoint_function('/some_path') # url with path without "/" with patch_edxnotes_api_settings("http://example.com"): assert 'http://example.com/some_path/' == get_endpoint_function('some_path/') # url is not configured with patch_edxnotes_api_settings(None): pytest.raises(ImproperlyConfigured, get_endpoint_function) @patch("lms.djangoapps.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": [ { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }, { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_2.location), "updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), } ] } ).encode('utf-8') assert len( { "count": 2, "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 1, "results": [ { "quote": "quote text", "text": "text", "chapter": { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)] }, "section": { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [ str(self.vertical.location), str(self.vertical_with_container.location) ] }, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, "usage_id": str(self.html_block_2.location), "updated": "Nov 19, 2014 at 08:06 UTC", }, { "quote": "quote text", "text": "text", "chapter": { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)] }, "section": { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [ str(self.vertical.location), str(self.vertical_with_container.location)] }, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, "usage_id": str(self.html_block_1.location), "updated": "Nov 19, 2014 at 08:05 UTC", }, ] }) == len(helpers.get_notes(self.request, self.course)) @patch("lms.djangoapps.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("lms.djangoapps.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("lms.djangoapps.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": [ { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }, { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_2.location), "updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), } ] }).encode('utf-8') assert len( { "count": 2, "current_page": 1, "start": 0, "next": None, "previous": None, "num_pages": 1, "results": [ { "quote": "quote text", "text": "text", "chapter": { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)] }, "section": { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [ str(self.vertical.location), str(self.vertical_with_container.location)] }, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, "usage_id": str(self.html_block_2.location), "updated": "Nov 19, 2014 at 08:06 UTC", }, { "quote": "quote text", "text": "text", "chapter": { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)] }, "section": { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [ str(self.vertical.location), str(self.vertical_with_container.location)] }, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, "usage_id": str(self.html_block_1.location), "updated": "Nov 19, 2014 at 08:05 UTC", }, ] }) == len(helpers.get_notes(self.request, self.course)) @patch("lms.djangoapps.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("lms.djangoapps.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("lms.djangoapps.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') assert len(NOTES_VIEW_EMPTY_RESPONSE) == len(helpers.get_notes(self.request, self.course)) @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("lms.djangoapps.edxnotes.helpers.anonymous_id_for_user", autospec=True) @patch("lms.djangoapps.edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("lms.djangoapps.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 block is not found. """ initial_collection = [ { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat() }, { "quote": "quote text", "text": "text", "usage_id": str(self.course.id.make_usage_key("html", "test_item")), "updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat() }, ] assert len( [{ "quote": "quote text", "text": "text", "chapter": { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)] }, "section": { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [str(self.vertical.location), str(self.vertical_with_container.location)] }, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000), }]) == len(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 blocks. """ initial_collection = [ { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }, { "quote": "quote text", "text": "text", "usage_id": str(self.html_block_2.location), "updated": datetime(2014, 11, 19, 8, 6, 16, 00000).isoformat(), }, ] self.html_block_2.visible_to_staff_only = True self.store.update_item(self.html_block_2, self.user.id) assert len( [{ "quote": "quote text", "text": "text", "chapter": { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)] }, "section": { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [str(self.vertical.location), str(self.vertical_with_container.location)] }, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000), }]) == len(helpers.preprocess_collection(self.user, self.course, initial_collection)) @patch("lms.djangoapps.edxnotes.helpers.has_access", autospec=True) @patch("lms.djangoapps.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 = [{ "quote": "quote text", "text": "text", "usage_id": str(self.html_block_1.location), "updated": datetime(2014, 11, 19, 8, 5, 16, 00000).isoformat(), }] assert not 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 = [ { "quote": "quote text1", "text": "text1", "usage_id": str(self.html_block_1.location), "updated": datetime(2016, 1, 26, 8, 5, 16, 00000).isoformat(), }, { "quote": "quote text2", "text": "text2", "usage_id": str(self.html_block_2.location), "updated": datetime(2016, 1, 26, 9, 6, 17, 00000).isoformat(), }, ] assert len( [ { 'section': {}, 'chapter': {}, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, 'text': 'text1', 'quote': 'quote text1', 'usage_id': str(self.html_block_1.location), 'updated': datetime(2016, 1, 26, 8, 5, 16) }, { 'section': {}, 'chapter': {}, "unit": { "url": self._get_unit_url(self.course, self.chapter, self.sequential), "display_name": self.vertical.display_name_with_default, "location": str(self.vertical.location), }, 'text': 'text2', 'quote': 'quote text2', 'usage_id': str(self.html_block_2.location), 'updated': datetime(2016, 1, 26, 9, 6, 17) } ]) == len(helpers.preprocess_collection(self.user, self.course, initial_collection)) def test_get_block_context_sequential(self): """ Tests `get_block_context` method for the sequential. """ self.assertDictEqual( { "display_name": self.sequential.display_name_with_default, "location": str(self.sequential.location), "children": [str(self.vertical.location), str(self.vertical_with_container.location)], }, helpers.get_block_context(self.course, self.sequential) ) def test_get_block_context_html_component(self): """ Tests `get_block_context` method for the components. """ self.assertDictEqual( { "display_name": self.html_block_1.display_name_with_default, "location": str(self.html_block_1.location), }, helpers.get_block_context(self.course, self.html_block_1) ) def test_get_block_context_chapter(self): """ Tests `get_block_context` method for the chapters. """ self.assertDictEqual( { "display_name": self.chapter.display_name_with_default, "index": 0, "location": str(self.chapter.location), "children": [str(self.sequential.location)], }, helpers.get_block_context(self.course, self.chapter) ) self.assertDictEqual( { "display_name": self.chapter_2.display_name_with_default, "index": 1, "location": str(self.chapter_2.location), "children": [], }, helpers.get_block_context(self.course, self.chapter_2) ) @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("lms.djangoapps.edxnotes.helpers.anonymous_id_for_user", autospec=True) @patch("lms.djangoapps.edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("lms.djangoapps.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": str(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("lms.djangoapps.edxnotes.helpers.anonymous_id_for_user", autospec=True) @patch("lms.djangoapps.edxnotes.helpers.get_edxnotes_id_token", autospec=True) @patch("lms.djangoapps.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": str(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_block = MagicMock() mock_course_block.position = 3 mock_course_block.get_children.return_value = [] assert helpers.get_course_position(mock_course_block) is None 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_block = 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_block.get_children.return_value = [mock_chapter] assert helpers.get_course_position(mock_course_block) == { 'display_name': 'Test Chapter Display Name', 'url': f'/courses/{self.course.id}/courseware/chapter_url_name/', } def test_get_course_position_no_section(self): """ Returns `None` if no section found. """ mock_course_block = MagicMock(id=self.course.id, position=None) mock_course_block.get_children.return_value = [MagicMock()] assert helpers.get_course_position(mock_course_block) is None 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_block = MagicMock(id=self.course.id, position=None) mock_chapter = MagicMock() mock_chapter.url_name = 'chapter_url_name' mock_course_block.get_children.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_children.return_value = [mock_section] mock_section.get_children.return_value = [MagicMock()] assert helpers.get_course_position(mock_course_block) == { 'display_name': 'Test Section Display Name', 'url': f'/courses/{self.course.id}/courseware/chapter_url_name/section_url_name/', } def test_get_index(self): """ Tests `get_index` method returns unit url. """ children = self.sequential.children assert 0 == helpers.get_index(str(self.vertical.location), children) assert 1 == helpers.get_index(str(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=[str(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: assert expected == constructed else: # constructed url should startswith notes view url instead of api view url assert constructed.startswith(notes_url) # constructed url should not contain extra params assert 'user' not in 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(): assert param in allowed_params assert f'{param}={value[0]}' in 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().setUp() self.course = CourseFactory(edxnotes=True) self.user = UserFactory() CourseEnrollmentFactory(user=self.user, course_id=self.course.id) # lint-amnesty, pylint: disable=no-member self.client.login(username=self.user.username, password=UserFactory._DEFAULT_PASSWORD) # lint-amnesty, pylint: disable=protected-access self.notes_page_url = reverse("edxnotes", args=[str(self.course.id)]) # lint-amnesty, pylint: disable=no-member self.notes_url = reverse("notes", args=[str(self.course.id)]) # lint-amnesty, pylint: disable=no-member self.get_token_url = reverse("get_token", args=[str(self.course.id)]) # lint-amnesty, pylint: disable=no-member self.visibility_url = reverse("edxnotes_visibility", args=[str(self.course.id)]) # lint-amnesty, pylint: disable=no-member def _get_course_block(self): """ Returns the course block. """ field_data_cache = FieldDataCache([self.course], self.course.id, self.user) # lint-amnesty, pylint: disable=no-member return get_block_for_descriptor( self.user, MagicMock(), self.course, field_data_cache, self.course.id, course=self.course # lint-amnesty, pylint: disable=no-member ) 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 assert not has_notes_tab(self.user, self.course) enable_edxnotes_for_the_course(self.course, self.user.id) # disable course.edxnotes self.course.edxnotes = False assert not has_notes_tab(self.user, self.course) # reenable course.edxnotes self.course.edxnotes = True assert has_notes_tab(self.user, self.course) # pylint: disable=unused-argument @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("lms.djangoapps.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("lms.djangoapps.edxnotes.views.get_notes", return_value={'results': []}) @patch("lms.djangoapps.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) assert response.status_code == 404 @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("lms.djangoapps.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"}) assert json.loads(response.content.decode('utf-8')) == NOTES_VIEW_EMPTY_RESPONSE assert 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"}) assert response.status_code == 404 @patch.dict("django.conf.settings.FEATURES", {"ENABLE_EDXNOTES": True}) @patch("lms.djangoapps.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("lms.djangoapps.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) assert response.status_code == 200 client = Application.objects.get(name='edx-notes') jwt.decode( response.content, client.client_secret, audience=client.client_id, algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']] ) @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) assert 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", ) assert response.status_code == 200 course_block = self._get_course_block() assert not course_block.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) assert 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", ) assert 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", ) assert response.status_code == 400 class EdxNotesRetireAPITest(ModuleStoreTestCase): """ Tests for EdxNotes retirement API. """ def setUp(self): ApplicationFactory(name="edx-notes") super().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("lms.djangoapps.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 ) assert 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 ) assert 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 ) assert 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 ) assert response.status_code == 405 @patch("lms.djangoapps.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 ) assert 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().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