Files
edx-platform/lms/djangoapps/edxnotes/tests.py
Stu Young 0f1c391134 incr-275 (#20584)
* run python modernize

* run isort

* quality fixed

* quality
2019-05-16 13:44:02 -04:00

1290 lines
53 KiB
Python

"""
Tests for the EdxNotes app.
"""
from __future__ import absolute_import
import json
from contextlib import contextmanager
from datetime import datetime
from unittest import skipUnless
import ddt
import jwt
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 courseware.model_data import FieldDataCache
from courseware.module_render import get_module_for_descriptor
from 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_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(),
}
]
}
)
self.assertItemsEqual(
{
"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 = "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({})
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(),
}
]
})
self.assertItemsEqual(
{
"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 = "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})
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)
self.assertItemsEqual(
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()
},
]
self.assertItemsEqual(
[{
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)
self.assertItemsEqual(
[{
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(),
}]
self.assertItemsEqual(
[], 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(),
},
]
self.assertItemsEqual(
[
{
'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."""
request = RequestFactory().request()
request.user = user
tabs = get_course_tab_list(request, 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 <a href="test_url">Section 1</a>'
)
@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), 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.assertEqual(response.status_code, 500)
self.assertIn("error", response.content)
@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.assertEqual(response.status_code, 500)
self.assertIn("error", response.content)
@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 = ''
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