From b5444b2702817bc04f5d0d025779ee50dbdec465 Mon Sep 17 00:00:00 2001 From: "E. Kolpakov" Date: Fri, 27 Mar 2015 13:11:03 +0300 Subject: [PATCH] Documented and added tests for using "contains in" instead of "equals to" predicate when searching in multivalue fields --- .pylintrc | 2 + README.md | 8 + search/tests/mock_search_engine.py | 8 + search/tests/test_mock_search_engine.py | 81 ++ search/tests/test_search_result_processor.py | 338 +++++++ search/tests/test_views.py | 406 ++++++++ search/tests/tests.py | 930 +------------------ search/tests/utils.py | 77 ++ 8 files changed, 957 insertions(+), 893 deletions(-) create mode 100644 search/tests/test_mock_search_engine.py create mode 100644 search/tests/test_search_result_processor.py create mode 100644 search/tests/test_views.py create mode 100644 search/tests/utils.py diff --git a/.pylintrc b/.pylintrc index c52755bd..6cdd1027 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,7 @@ [MESSAGES CONTROL] disable=too-many-lines +# https://bitbucket.org/logilab/pylint/issue/214/the-duplicate-code-r0801-cant-be-disabled +disable=duplicate-code [FORMAT] max-line-length=120 diff --git a/README.md b/README.md index b1599dc0..479dd0e2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ search_result = search_engine.search(field_dictionary=match_field_dict) _Notice the . notation for querying fields that are nested within the indexed object_ +**Important notice:** searching in multivalue fields (i.e. a lists) have a special semantics - if search term is a +scalar value (i.e. string, number, etc.), search uses "contains in" predicate, so all documents containing specified +search value as one of the elements of multivalue field are included in result set. If search term is vector value (i.e. +list, tuple, dictionary, etc.), search will result in undefined behavior, specific to underlying search engine; thus +using iterable as filter field value is discouraged. + #### Search results The `search_result` object returned from a call to `search` is a python dict object that contains the following fields: ``` @@ -168,6 +174,8 @@ already_started = { search_result = search_engine.search(filter_dictionary=already_started) ``` +**Important notice:** same concerns about searching in multivalue fields apply here. + ### Searches using a combination of these criteria All of these criteria can be combined to present results that are desired. Consider a search that wants to return objects that: diff --git a/search/tests/mock_search_engine.py b/search/tests/mock_search_engine.py index ff194081..797ead05 100644 --- a/search/tests/mock_search_engine.py +++ b/search/tests/mock_search_engine.py @@ -2,6 +2,7 @@ import copy from datetime import datetime import json +import collections import os from django.conf import settings @@ -47,6 +48,11 @@ def _find_field(doc, field_name): return field_value +def _is_iterable(item): + """ Checks if an item is iterable (list, tuple, generator), but not string """ + return isinstance(item, collections.Iterable) and not isinstance(item, basestring) + + def _filter_intersection(documents_to_search, dictionary_object, include_blanks=False): """ Filters out documents that do not match all of the field values within the dictionary_object @@ -73,6 +79,8 @@ def value_matches(doc, field_name, field_value): (field_value.lower is None or compare_value >= field_value.lower) and (field_value.upper is None or compare_value <= field_value.upper) ) + elif _is_iterable(compare_value) and not _is_iterable(field_value): + return any((item == field_value for item in compare_value)) else: return compare_value == field_value diff --git a/search/tests/test_mock_search_engine.py b/search/tests/test_mock_search_engine.py new file mode 100644 index 00000000..fb65625a --- /dev/null +++ b/search/tests/test_mock_search_engine.py @@ -0,0 +1,81 @@ +""" Tests for MockSearchEngine specific features """ +from datetime import datetime +from django.test import TestCase +from django.test.utils import override_settings +from search.tests.mock_search_engine import _find_field, _filter_intersection, json_date_to_datetime + + +# Any class that inherits from TestCase will cause too-many-public-methods pylint error +# pylint: disable=too-many-public-methods +@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine") +@override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) +class MockSpecificSearchTests(TestCase): + """ For testing pieces of the Mock Engine that have no equivalent in Elastic """ + + def test_find_field_arguments(self): + """ test that field argument validity is observed """ + field_value = _find_field( + { + "name": "Come and listen to my story" + }, + "name" + ) + self.assertEqual(field_value, "Come and listen to my story") + + field_value = _find_field( + { + "name": { + "first": "Martyn", + "last": "James" + } + }, + "name.first" + ) + self.assertEqual(field_value, "Martyn") + + field_value = _find_field( + { + "name": { + "first": "Monica", + "last": { + "one": "Parker", + "two": "James" + } + } + }, + "name.last.two" + ) + self.assertEqual(field_value, "James") + + with self.assertRaises(ValueError): + field_value = _find_field( + { + "name": "Come and listen to my story" + }, + 123 + ) + + with self.assertRaises(ValueError): + field_value = _find_field(123, "name") + + def test_filter_optimization(self): + """ Make sure that intersection optimizes return when no filter dictionary is provided """ + test_docs = [{"A": {"X": 1, "Y": 2, "Z": 3}}, {"B": {"X": 9, "Y": 8, "Z": 7}}] + self.assertTrue(_filter_intersection(test_docs, None), test_docs) + + def test_datetime_conversion(self): + """ tests json_date_to_datetime with different formats """ + json_date = "2015-01-31" + self.assertTrue(json_date_to_datetime(json_date), datetime(2015, 1, 31)) + + json_datetime = "2015-01-31T07:30:28" + self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28)) + + json_datetime = "2015-01-31T07:30:28.65785" + self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28, 65785)) + + json_datetime = "2015-01-31T07:30:28Z" + self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28)) + + json_datetime = "2015-01-31T07:30:28.65785Z" + self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28, 65785)) diff --git a/search/tests/test_search_result_processor.py b/search/tests/test_search_result_processor.py new file mode 100644 index 00000000..f06a7b4d --- /dev/null +++ b/search/tests/test_search_result_processor.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +""" Tests for result processors """ +from django.test import TestCase +from django.test.utils import override_settings +from search.result_processor import SearchResultProcessor + + +# Any class that inherits from TestCase will cause too-many-public-methods pylint error +# pylint: disable=too-many-public-methods +class SearchResultProcessorTests(TestCase): + """ Tests to check SearchResultProcessor is working as desired """ + + def test_strings_in_dictionary(self): + """ Test finding strings within dictionary item """ + test_dict = { + "a": "This is a string that should show up" + } + + get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) + self.assertEqual(len(get_strings), 1) + self.assertEqual(get_strings[0], test_dict["a"]) + + test_dict.update({ + "b": "This is another string that should show up" + }) + get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) + self.assertEqual(len(get_strings), 2) + self.assertEqual(get_strings[0], test_dict["a"]) + self.assertEqual(get_strings[1], test_dict["b"]) + + test_dict.update({ + "CASCADE": { + "z": "This one should be found too" + } + }) + get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) + self.assertEqual(len(get_strings), 3) + self.assertEqual(get_strings[0], test_dict["a"]) + self.assertEqual(get_strings[1], test_dict["b"]) + self.assertEqual(get_strings[2], test_dict["CASCADE"]["z"]) + + test_dict.update({ + "DEEP": { + "DEEPER": { + "STILL_GOING": { + "MORE": { + "here": "And here, again and again" + } + } + } + } + }) + get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) + self.assertEqual(len(get_strings), 4) + self.assertEqual(get_strings[0], test_dict["a"]) + self.assertEqual(get_strings[1], test_dict["b"]) + self.assertEqual(get_strings[2], test_dict["CASCADE"]["z"]) + self.assertEqual(get_strings[3], test_dict["DEEP"]["DEEPER"]["STILL_GOING"]["MORE"]["here"]) + + def test_find_matches(self): + """ test finding matches """ + words = ["hello"] + strings = [ + "hello there", + "goodbye", + "Sail away to say HELLO", + ] + matches = SearchResultProcessor.find_matches(strings, words, 100) + self.assertEqual(len(matches), 2) + self.assertTrue(strings[0] in matches) + self.assertFalse(strings[1] in matches) + self.assertTrue(strings[2] in matches) + + words = ["hello", "there"] + strings = [ + "hello there", + "goodbye", + "Sail away to say HELLO", + ] + matches = SearchResultProcessor.find_matches(strings, words, 100) + self.assertEqual(len(matches), 2) + self.assertTrue(strings[0] in matches) + self.assertFalse(strings[1] in matches) + self.assertTrue(strings[2] in matches) + + words = ["hello", "there"] + strings = [ + "hello there", + "goodbye there", + "Sail away to say HELLO", + ] + matches = SearchResultProcessor.find_matches(strings, words, 100) + self.assertEqual(len(matches), 3) + self.assertTrue(strings[0] in matches) + self.assertTrue(strings[1] in matches) + self.assertTrue(strings[2] in matches) + + words = ["goodbye there", "goodbye", "there"] + strings = [ + "goodbye", + "goodbye there", + "Sail away to say GOODBYE", + ] + matches = SearchResultProcessor.find_matches(strings, words, 100) + self.assertTrue(strings[0] in matches) + self.assertTrue(strings[1] in matches) + self.assertTrue(strings[2] in matches) + + words = ["none of these are present"] + strings = [ + "goodbye", + "goodbye there", + "Sail away to say GOODBYE", + ] + matches = SearchResultProcessor.find_matches(strings, words, 100) + self.assertEqual(len(matches), 0) + + def test_too_long_find_matches(self): + """ make sure that we keep the expert snippets short enough """ + words = ["edx", "afterward"] + strings = [ + ("Here is a note about edx and it is very long - more than the desirable length of 100 characters" + " - indeed this should show up"), + "This matches too but comes afterward", + ] + matches = SearchResultProcessor.find_matches(strings, words, 100) + self.assertEqual(len(matches), 1) + + def test_excerpt(self): + """ test that we return an excerpt """ + test_result = { + "content": { + "notes": u"Here is a الاستحسان about edx", + "name": "edX search a lot", + } + } + srp = SearchResultProcessor(test_result, u"الاستحسان") + self.assertEqual(srp.excerpt, u"Here is a الاستحسان about edx") + + srp = SearchResultProcessor(test_result, u"edx") + self.assertEqual(srp.excerpt, u"Here is a الاستحسان about edxedX search a lot") + + def test_too_long_excerpt(self): + """ test that we shorten an excerpt that is too long appropriately """ + test_string = ( + u"Here is a note about الاستحسان and it is very long - more than the desirable length of 100" + u" characters - indeed this should show up but it should trim the characters around in" + u" order to show the selected text in bold" + ) + test_result = { + "content": { + "notes": test_string, + } + } + srp = SearchResultProcessor(test_result, u"الاستحسان") + test_string_compare = SearchResultProcessor.decorate_matches(test_string, u"الاستحسان") + excerpt = srp.excerpt + self.assertNotEqual(excerpt, test_string_compare) + self.assertTrue(u"note about الاستحسان and it is" in excerpt) + + test_string = ( + u"Here is a note about stuff and it is very long - more than the desirable length of 100" + u" characters - indeed this should show up but it should trim the الاستحسان characters around in" + u" order to show the selected text in bold" + ) + test_result = { + "content": { + "notes": test_string, + } + } + srp = SearchResultProcessor(test_result, u"الاستحسان") + test_string_compare = SearchResultProcessor.decorate_matches(test_string, u"الاستحسان") + excerpt = srp.excerpt + self.assertNotEqual(excerpt, test_string_compare) + self.assertTrue(u"should trim the الاستحسان characters around" in excerpt) + + def test_excerpt_front(self): + """ test that we process correctly when match is at the front of the excerpt """ + test_result = { + "content": { + "notes": "Dog - match upon first word", + } + } + srp = SearchResultProcessor(test_result, "dog") + self.assertEqual(srp.excerpt, "Dog - match upon first word") + + test_result = { + "content": { + "notes": ( + "Dog - match upon first word " + "The long and winding road " + "That leads to your door " + "Will never disappear " + "I've seen that road before " + "It always leads me here " + "Lead me to you door " + "The wild and windy night " + "That the rain washed away " + "Has left a pool of tears " + "Crying for the day " + "Why leave me standing here " + "Let me know the way " + "Many times I've been alone " + "And many times I've cried " + "Any way you'll never know " + "The many ways I've tried " + "But still they lead me back " + "To the long winding road " + "You left me standing here " + "A long long time ago " + "Don't leave me waiting here " + "Lead me to your door " + "But still they lead me back " + "To the long winding road " + "You left me standing here " + "A long long time ago " + "Don't leave me waiting here " + "Lead me to your door " + "Yeah, yeah, yeah, yeah " + ), + } + } + srp = SearchResultProcessor(test_result, "dog") + self.assertEqual(srp.excerpt[0:34], "Dog - match upon first word") + + def test_excerpt_back(self): + """ test that we process correctly when match is at the end of the excerpt """ + test_result = { + "content": { + "notes": "Match upon last word - Dog", + } + } + srp = SearchResultProcessor(test_result, "dog") + self.assertEqual(srp.excerpt, "Match upon last word - Dog") + + test_result = { + "content": { + "notes": ( + "The long and winding road " + "That leads to your door " + "Will never disappear " + "I've seen that road before " + "It always leads me here " + "Lead me to you door " + "The wild and windy night " + "That the rain washed away " + "Has left a pool of tears " + "Crying for the day " + "Why leave me standing here " + "Let me know the way " + "Many times I've been alone " + "And many times I've cried " + "Any way you'll never know " + "The many ways I've tried " + "But still they lead me back " + "To the long winding road " + "You left me standing here " + "A long long time ago " + "Don't leave me waiting here " + "Lead me to your door " + "But still they lead me back " + "To the long winding road " + "You left me standing here " + "A long long time ago " + "Don't leave me waiting here " + "Lead me to your door " + "Yeah, yeah, yeah, yeah " + "Match upon last word - Dog" + ), + } + } + srp = SearchResultProcessor(test_result, "dog") + self.assertEqual(srp.excerpt[-33:], "Match upon last word - Dog") + + +class TestSearchResultProcessor(SearchResultProcessor): + """ + Override the SearchResultProcessor so that we get the additional (inferred) properties + and can identify results that should be removed due to access restriction + """ + # pylint: disable=no-self-use + @property + def additional_property(self): + """ additional property that should appear within processed results """ + return "Should have an extra value" + + @property + def url(self): + """ + Property to display the url for the given location, useful for allowing navigation + """ + if "course" not in self._results_fields or "id" not in self._results_fields: + raise ValueError("expect this error when not providing a course and/or id") + + return u"/courses/{course_id}/jump_to/{location}".format( + course_id=self._results_fields["course"], + location=self._results_fields["id"], + ) + + def should_remove(self, user): + """ remove items when url is None """ + return "remove_me" in self._results_fields + + +@override_settings(SEARCH_RESULT_PROCESSOR="search.tests.test_search_result_processor.TestSearchResultProcessor") +class TestOverrideSearchResultProcessor(TestCase): + """ test the correct processing of results using the SEARCH_RESULT_PROCESSOR specified class """ + + def test_additional_property(self): + """ make sure the addition properties are returned """ + test_result = { + "course": "testmetestme", + "id": "herestheid" + } + new_result = SearchResultProcessor.process_result(test_result, "fake search pattern", None) + self.assertEqual(new_result, test_result) + self.assertEqual(test_result["url"], "/courses/testmetestme/jump_to/herestheid") + self.assertIsNone(test_result["excerpt"]) + self.assertEqual(test_result["additional_property"], "Should have an extra value") + + def test_removal(self): + """ make sure that the override of should remove let's the application prevent access to a result """ + test_result = { + "course": "remove_course", + "id": "remove_id", + "remove_me": True + } + new_result = SearchResultProcessor.process_result(test_result, "fake search pattern", None) + self.assertIsNone(new_result) + + def test_property_error(self): + """ result should be removed from list if there is an error in the handler properties """ + test_result = { + "not_course": "asdasda", + "not_id": "rthrthretht" + } + new_result = SearchResultProcessor.process_result(test_result, "fake search pattern", None) + self.assertIsNone(new_result) diff --git a/search/tests/test_views.py b/search/tests/test_views.py new file mode 100644 index 00000000..7804fb93 --- /dev/null +++ b/search/tests/test_views.py @@ -0,0 +1,406 @@ +""" High-level view tests""" +from datetime import datetime +from django.core.urlresolvers import resolve +from django.core.urlresolvers import Resolver404 +from django.test import TestCase +from django.test.utils import override_settings +from mock import patch, call +from search.search_engine_base import SearchEngine +from search.tests.mock_search_engine import MockSearchEngine +from search.tests.tests import TEST_INDEX_NAME +from search.tests.utils import post_request, SearcherMixin + + +# Any class that inherits from TestCase will cause too-many-public-methods pylint error +# pylint: disable=too-many-public-methods +@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine") +@override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) +@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME) +class MockSearchUrlTest(TestCase, SearcherMixin): + """ + Make sure that requests to the url get routed to the correct view handler + """ + def _reset_mocked_tracker(self): + """ reset mocked tracker and clear logged emits """ + self.mock_tracker.reset_mock() + + def setUp(self): + MockSearchEngine.destroy() + self._searcher = None + patcher = patch('search.views.track') + self.mock_tracker = patcher.start() + self.addCleanup(patcher.stop) + + def tearDown(self): + MockSearchEngine.destroy() + self._searcher = None + + def assert_no_events_were_emitted(self): + """Ensures no events were emitted since the last event related assertion""" + self.assertFalse(self.mock_tracker.emit.called) # pylint: disable=maybe-no-member + + def assert_search_initiated_event(self, search_term, size, page): + """Ensures an search initiated event was emitted""" + initiated_search_call = self.mock_tracker.emit.mock_calls[0] # pylint: disable=maybe-no-member + expected_result = call('edx.course.search.initiated', { + "search_term": unicode(search_term), + "page_size": size, + "page_number": page, + }) + self.assertEqual(expected_result, initiated_search_call) + + def assert_results_returned_event(self, search_term, size, page, total): + """Ensures an results returned event was emitted""" + returned_results_call = self.mock_tracker.emit.mock_calls[1] # pylint: disable=maybe-no-member + expected_result = call('edx.course.search.results_displayed', { + "search_term": unicode(search_term), + "page_size": size, + "page_number": page, + "results_count": total, + }) + self.assertEqual(expected_result, returned_results_call) + + def assert_initiated_return_events(self, search_term, size, page, total): + """Asserts search initiated and results returned events were emitted""" + self.assertEqual(self.mock_tracker.emit.call_count, 2) # pylint: disable=maybe-no-member + self.assert_search_initiated_event(search_term, size, page) + self.assert_results_returned_event(search_term, size, page, total) + + def test_url_resolution(self): + """ make sure that the url is resolved as expected """ + resolver = resolve('/') + self.assertEqual(resolver.view_name, 'do_search') + + with self.assertRaises(Resolver404): + resolver = resolve('/blah') + + resolver = resolve('/edX/DemoX/Demo_Course') + self.assertEqual(resolver.view_name, 'do_search') + self.assertEqual(resolver.kwargs['course_id'], 'edX/DemoX/Demo_Course') + + def test_search_from_url(self): + """ test searching using the url """ + self.searcher.index( + "test_doc", + { + "id": "FAKE_ID_1", + "content": { + "text": "Little Darling, it's been a long long lonely winter" + }, + "test_date": datetime(2015, 1, 1), + "test_string": "ABC, It's easy as 123" + } + ) + self.searcher.index( + "test_doc", + { + "id": "FAKE_ID_2", + "content": { + "text": "Little Darling, it's been a year since sun been gone" + } + } + ) + self.searcher.index("test_doc", {"id": "FAKE_ID_3", "content": {"text": "Here comes the sun"}}) + + # Test no events called yet after setup + self.assert_no_events_were_emitted() + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "sun"}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 2) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_3" in result_ids and "FAKE_ID_2" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("sun", 20, 0, 2) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Darling"}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 2) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Darling", 20, 0, 2) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "winter"}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" not in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("winter", 20, 0, 1) + self._reset_mocked_tracker() + + self.assertTrue(results["results"][0]["data"]["test_date"], datetime(2015, 1, 1).isoformat()) + self.assertTrue(results["results"][0]["data"]["test_string"], "ABC, It's easy as 123") + + def test_course_search_url(self): + """ test searching using the course url """ + self.searcher.index( + "test_doc", + { + "course": "ABC/DEF/GHI", + "id": "FAKE_ID_1", + "content": { + "text": "Little Darling, it's been a long long lonely winter" + } + } + ) + self.searcher.index( + "test_doc", + { + "course": "ABC/DEF/GHI", + "id": "FAKE_ID_2", + "content": { + "text": "Little Darling, it's been a year since you've been gone" + } + } + ) + self.searcher.index( + "test_doc", + { + "course": "LMN/OPQ/RST", + "id": "FAKE_ID_3", + "content": { + "text": "Little Darling, it's been a long long lonely winter" + } + } + ) + + # Test no events called yet after setup + self.assert_no_events_were_emitted() + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling"}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 20, 0, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Darling"}, "ABC/DEF/GHI") + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 2) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Darling", 20, 0, 2) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "winter"}, "ABC/DEF/GHI") + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" not in result_ids and "FAKE_ID_3" not in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("winter", 20, 0, 1) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "winter"}, "LMN/OPQ/RST") + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" not in result_ids and "FAKE_ID_2" not in result_ids and "FAKE_ID_3" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("winter", 20, 0, 1) + self._reset_mocked_tracker() + + def test_empty_search_string(self): + """ test when search string is provided as empty or null (None) """ + code, results = post_request({"search_string": ""}) + self.assertTrue(code > 499) + self.assertEqual(results["error"], "No search term provided for search") + + code, results = post_request({"no_search_string_provided": ""}) + self.assertTrue(code > 499) + self.assertEqual(results["error"], "No search term provided for search") + + def test_pagination(self): # pylint: disable=too-many-statements + """ test searching using the course url """ + self.searcher.index( + "test_doc", + { + "course": "ABC", + "id": "FAKE_ID_1", + "content": { + "text": "Little Darling Little Darling Little Darling, it's been a long long lonely winter" + } + } + ) + self.searcher.index( + "test_doc", + { + "course": "ABC", + "id": "FAKE_ID_2", + "content": { + "text": "Little Darling Little Darling, it's been a year since you've been gone" + } + } + ) + self.searcher.index( + "test_doc", + { + "course": "XYZ", + "id": "FAKE_ID_3", + "content": { + "text": "Little Darling, it's been a long long lonely winter" + } + } + ) + + # Test no events called yet after setup + self.assert_no_events_were_emitted() + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling"}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 3) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 20, 0, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 1}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 1, 0, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 1, "page_index": 0}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 1, 0, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 1, "page_index": 1}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_2" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 1, 1, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 1, "page_index": 2}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_3" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 1, 2, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 2}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 2) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 2, 0, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 2, "page_index": 0}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 2) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 2, 0, 3) + self._reset_mocked_tracker() + + code, results = post_request({"search_string": "Little Darling", "page_size": 2, "page_index": 1}) + self.assertTrue(code < 300 and code > 199) + self.assertEqual(results["total"], 3) + self.assertEqual(len(results["results"]), 1) + result_ids = [r["data"]["id"] for r in results["results"]] + self.assertTrue("FAKE_ID_3" in result_ids) + + # Test initiate search and return results were called - and clear mocked tracker + self.assert_initiated_return_events("Little Darling", 2, 1, 3) + self._reset_mocked_tracker() + + def test_page_size_too_large(self): + """ test searching with too-large page_size """ + self.searcher.index( + "test_doc", + { + "course": "ABC/DEF/GHI", + "id": "FAKE_ID_1", + "content": { + "text": "Little Darling, it's been a long long lonely winter" + } + } + ) + + code, results = post_request({"search_string": "Little Darling", "page_size": 101}) + self.assertEqual(code, 500) + self.assertTrue("error" in results) + + +@override_settings(SEARCH_ENGINE="search.tests.utils.ErroringSearchEngine") +@override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) +@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME) +class BadSearchTest(TestCase): + """ Make sure that we can error message when there is a problem """ + _searcher = None + + def setUp(self): + MockSearchEngine.destroy() + + def tearDown(self): + MockSearchEngine.destroy() + + def test_search_from_url(self): + """ ensure that we get the error back when the backend fails """ + searcher = SearchEngine.get_search_engine(TEST_INDEX_NAME) + searcher.index( + "test_doc", + { + "id": "FAKE_ID_1", + "content": { + "text": "Little Darling, it's been a long long lonely winter" + } + } + ) + searcher.index( + "test_doc", + { + "id": "FAKE_ID_2", + "content": { + "text": "Little Darling, it's been a year since sun been gone" + } + } + ) + searcher.index("test_doc", {"id": "FAKE_ID_3", "content": {"text": "Here comes the sun"}}) + + code, results = post_request({"search_string": "sun"}) + self.assertTrue(code > 499) + self.assertEqual(results["error"], 'An error occurred when searching for "sun"') diff --git a/search/tests/tests.py b/search/tests/tests.py index 065455c0..ccec9092 100644 --- a/search/tests/tests.py +++ b/search/tests/tests.py @@ -8,62 +8,26 @@ import json import os -from django.core.urlresolvers import resolve, Resolver404 -from django.test import TestCase, Client +from django.test import TestCase from django.test.utils import override_settings from elasticsearch import Elasticsearch, exceptions from search.search_engine_base import SearchEngine from search.elastic import ElasticSearchEngine, RESERVED_CHARACTERS -from search.result_processor import SearchResultProcessor +from search.tests.utils import ErroringElasticImpl, SearcherMixin, TEST_INDEX_NAME from search.utils import ValueRange, DateRange from search.api import perform_search, NoSearchEngine -from .mock_search_engine import MockSearchEngine, _find_field, _filter_intersection, json_date_to_datetime -from mock import patch, call +from .mock_search_engine import MockSearchEngine, json_date_to_datetime -TEST_INDEX_NAME = "test_index" # Any class that inherits from TestCase will cause too-many-public-methods pylint error # pylint: disable=too-many-public-methods - -# We override ElasticSearchEngine class in order to force an index refresh upon index -# otherwise we often get results from the prior state, rendering the tests less useful - - -class ForceRefreshElasticSearchEngine(ElasticSearchEngine): - """ - Override of ElasticSearchEngine that forces the update of the index, - so that tests can relaibly search right afterward - """ - - def index(self, doc_type, body, **kwargs): - kwargs.update({ - "refresh": True - }) - super(ForceRefreshElasticSearchEngine, self).index(doc_type, body, **kwargs) - - def remove(self, doc_type, doc_id, **kwargs): - kwargs.update({ - "refresh": True - }) - super(ForceRefreshElasticSearchEngine, self).remove(doc_type, doc_id, **kwargs) - - @override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine") @override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) @override_settings(MOCK_SEARCH_BACKING_FILE=None) -class MockSearchTests(TestCase): +class MockSearchTests(TestCase, SearcherMixin): """ Test operation of search activities """ - _searcher = None - - @property - def searcher(self): - """ cached instance of search engine """ - if self._searcher is None: - self._searcher = SearchEngine.get_search_engine(TEST_INDEX_NAME) - return self._searcher - @property def _is_elastic(self): """ check search engine implementation, to manage cleanup differently """ @@ -298,6 +262,36 @@ def test_search_tags(self): field_dictionary={"tags.shape": "square", "tags.color": "blue"}, use_field_match=True) self.assertEqual(response["total"], 0) + def test_search_array(self): + """ test nested object array """ + test_object1 = { + "name": "John Lester", + "course_id": "A/B/C", + "array": ["a", "c", "x"] + } + test_object2 = { + "name": "Anthony Rizzo", + "course_id": "C/D/E", + "array": ["a", "b", "c"] + } + self.searcher.index("test_doc", test_object1) + self.searcher.index("test_doc", test_object2) + + response = self.searcher.search(field_dictionary={"array": "x"}) + self.assertEqual(response["total"], 1) + self.assertEqual(response["results"][0]["data"], test_object1) + + response = self.searcher.search(field_dictionary={"array": "a"}) + self.assertEqual(response["total"], 2) + self.assertIn(response["results"][0]["data"], [test_object1, test_object2]) + self.assertIn(response["results"][1]["data"], [test_object1, test_object2]) + + response = self.searcher.search(field_dictionary={"array": "g"}) + self.assertEqual(response["total"], 0) + + response = self.searcher.search(field_dictionary={"array": "c"}) + self.assertEqual(response["total"], 2) + def test_extended_characters(self): """ Make sure that extended character searches work """ test_string = u"قضايـا هامـة" @@ -552,81 +546,7 @@ def test_pagination(self): self.assertTrue("FAKE_ID_3" in result_ids) -@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine") -@override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) -class MockSpecificSearchTests(TestCase): - """ For testing pieces of the Mock Engine that have no equivalent in Elastic """ - - def test_find_field_arguments(self): - """ test that field argument validity is observed """ - field_value = _find_field( - { - "name": "Come and listen to my story" - }, - "name" - ) - self.assertEqual(field_value, "Come and listen to my story") - - field_value = _find_field( - { - "name": { - "first": "Martyn", - "last": "James" - } - }, - "name.first" - ) - self.assertEqual(field_value, "Martyn") - - field_value = _find_field( - { - "name": { - "first": "Monica", - "last": { - "one": "Parker", - "two": "James" - } - } - }, - "name.last.two" - ) - self.assertEqual(field_value, "James") - - with self.assertRaises(ValueError): - field_value = _find_field( - { - "name": "Come and listen to my story" - }, - 123 - ) - - with self.assertRaises(ValueError): - field_value = _find_field(123, "name") - - def test_filter_optimization(self): - """ Make sure that intersection optimizes return when no filter dictionary is provided """ - test_docs = [{"A": {"X": 1, "Y": 2, "Z": 3}}, {"B": {"X": 9, "Y": 8, "Z": 7}}] - self.assertTrue(_filter_intersection(test_docs, None), test_docs) - - def test_datetime_conversion(self): - """ tests json_date_to_datetime with different formats """ - json_date = "2015-01-31" - self.assertTrue(json_date_to_datetime(json_date), datetime(2015, 1, 31)) - - json_datetime = "2015-01-31T07:30:28" - self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28)) - - json_datetime = "2015-01-31T07:30:28.65785" - self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28, 65785)) - - json_datetime = "2015-01-31T07:30:28Z" - self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28)) - - json_datetime = "2015-01-31T07:30:28.65785Z" - self.assertTrue(json_date_to_datetime(json_datetime), datetime(2015, 1, 31, 7, 30, 28, 65785)) - - -@override_settings(SEARCH_ENGINE="search.tests.tests.ForceRefreshElasticSearchEngine") +@override_settings(SEARCH_ENGINE="search.tests.utils.ForceRefreshElasticSearchEngine") class ElasticSearchTests(MockSearchTests): """ Override that runs the same tests for ElasticSearchEngine instead of MockSearchEngine """ @@ -774,745 +694,10 @@ def test_disabled_index(self): self.assertEqual(response["total"], 0) -class SearchResultProcessorTests(TestCase): - """ Tests to check SearchResultProcessor is working as desired """ - - def test_strings_in_dictionary(self): - """ Test finding strings within dictionary item """ - test_dict = { - "a": "This is a string that should show up" - } - - get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) - self.assertEqual(len(get_strings), 1) - self.assertEqual(get_strings[0], test_dict["a"]) - - test_dict.update({ - "b": "This is another string that should show up" - }) - get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) - self.assertEqual(len(get_strings), 2) - self.assertEqual(get_strings[0], test_dict["a"]) - self.assertEqual(get_strings[1], test_dict["b"]) - - test_dict.update({ - "CASCADE": { - "z": "This one should be found too" - } - }) - get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) - self.assertEqual(len(get_strings), 3) - self.assertEqual(get_strings[0], test_dict["a"]) - self.assertEqual(get_strings[1], test_dict["b"]) - self.assertEqual(get_strings[2], test_dict["CASCADE"]["z"]) - - test_dict.update({ - "DEEP": { - "DEEPER": { - "STILL_GOING": { - "MORE": { - "here": "And here, again and again" - } - } - } - } - }) - get_strings = SearchResultProcessor.strings_in_dictionary(test_dict) - self.assertEqual(len(get_strings), 4) - self.assertEqual(get_strings[0], test_dict["a"]) - self.assertEqual(get_strings[1], test_dict["b"]) - self.assertEqual(get_strings[2], test_dict["CASCADE"]["z"]) - self.assertEqual(get_strings[3], test_dict["DEEP"]["DEEPER"]["STILL_GOING"]["MORE"]["here"]) - - def test_find_matches(self): - """ test finding matches """ - words = ["hello"] - strings = [ - "hello there", - "goodbye", - "Sail away to say HELLO", - ] - matches = SearchResultProcessor.find_matches(strings, words, 100) - self.assertEqual(len(matches), 2) - self.assertTrue(strings[0] in matches) - self.assertFalse(strings[1] in matches) - self.assertTrue(strings[2] in matches) - - words = ["hello", "there"] - strings = [ - "hello there", - "goodbye", - "Sail away to say HELLO", - ] - matches = SearchResultProcessor.find_matches(strings, words, 100) - self.assertEqual(len(matches), 2) - self.assertTrue(strings[0] in matches) - self.assertFalse(strings[1] in matches) - self.assertTrue(strings[2] in matches) - - words = ["hello", "there"] - strings = [ - "hello there", - "goodbye there", - "Sail away to say HELLO", - ] - matches = SearchResultProcessor.find_matches(strings, words, 100) - self.assertEqual(len(matches), 3) - self.assertTrue(strings[0] in matches) - self.assertTrue(strings[1] in matches) - self.assertTrue(strings[2] in matches) - - words = ["goodbye there", "goodbye", "there"] - strings = [ - "goodbye", - "goodbye there", - "Sail away to say GOODBYE", - ] - matches = SearchResultProcessor.find_matches(strings, words, 100) - self.assertTrue(strings[0] in matches) - self.assertTrue(strings[1] in matches) - self.assertTrue(strings[2] in matches) - - words = ["none of these are present"] - strings = [ - "goodbye", - "goodbye there", - "Sail away to say GOODBYE", - ] - matches = SearchResultProcessor.find_matches(strings, words, 100) - self.assertEqual(len(matches), 0) - - def test_too_long_find_matches(self): - """ make sure that we keep the expert snippets short enough """ - words = ["edx", "afterward"] - strings = [ - ("Here is a note about edx and it is very long - more than the desirable length of 100 characters" - " - indeed this should show up"), - "This matches too but comes afterward", - ] - matches = SearchResultProcessor.find_matches(strings, words, 100) - self.assertEqual(len(matches), 1) - - def test_excerpt(self): - """ test that we return an excerpt """ - test_result = { - "content": { - "notes": u"Here is a الاستحسان about edx", - "name": "edX search a lot", - } - } - srp = SearchResultProcessor(test_result, u"الاستحسان") - self.assertEqual(srp.excerpt, u"Here is a الاستحسان about edx") - - srp = SearchResultProcessor(test_result, u"edx") - self.assertEqual(srp.excerpt, u"Here is a الاستحسان about edxedX search a lot") - - def test_too_long_excerpt(self): - """ test that we shorten an excerpt that is too long appropriately """ - test_string = ( - u"Here is a note about الاستحسان and it is very long - more than the desirable length of 100" - u" characters - indeed this should show up but it should trim the characters around in" - u" order to show the selected text in bold" - ) - test_result = { - "content": { - "notes": test_string, - } - } - srp = SearchResultProcessor(test_result, u"الاستحسان") - test_string_compare = SearchResultProcessor.decorate_matches(test_string, u"الاستحسان") - excerpt = srp.excerpt - self.assertNotEqual(excerpt, test_string_compare) - self.assertTrue(u"note about الاستحسان and it is" in excerpt) - - test_string = ( - u"Here is a note about stuff and it is very long - more than the desirable length of 100" - u" characters - indeed this should show up but it should trim the الاستحسان characters around in" - u" order to show the selected text in bold" - ) - test_result = { - "content": { - "notes": test_string, - } - } - srp = SearchResultProcessor(test_result, u"الاستحسان") - test_string_compare = SearchResultProcessor.decorate_matches(test_string, u"الاستحسان") - excerpt = srp.excerpt - self.assertNotEqual(excerpt, test_string_compare) - self.assertTrue(u"should trim the الاستحسان characters around" in excerpt) - - def test_excerpt_front(self): - """ test that we process correctly when match is at the front of the excerpt """ - test_result = { - "content": { - "notes": "Dog - match upon first word", - } - } - srp = SearchResultProcessor(test_result, "dog") - self.assertEqual(srp.excerpt, "Dog - match upon first word") - - test_result = { - "content": { - "notes": ( - "Dog - match upon first word " - "The long and winding road " - "That leads to your door " - "Will never disappear " - "I've seen that road before " - "It always leads me here " - "Lead me to you door " - "The wild and windy night " - "That the rain washed away " - "Has left a pool of tears " - "Crying for the day " - "Why leave me standing here " - "Let me know the way " - "Many times I've been alone " - "And many times I've cried " - "Any way you'll never know " - "The many ways I've tried " - "But still they lead me back " - "To the long winding road " - "You left me standing here " - "A long long time ago " - "Don't leave me waiting here " - "Lead me to your door " - "But still they lead me back " - "To the long winding road " - "You left me standing here " - "A long long time ago " - "Don't leave me waiting here " - "Lead me to your door " - "Yeah, yeah, yeah, yeah " - ), - } - } - srp = SearchResultProcessor(test_result, "dog") - self.assertEqual(srp.excerpt[0:34], "Dog - match upon first word") - - def test_excerpt_back(self): - """ test that we process correctly when match is at the end of the excerpt """ - test_result = { - "content": { - "notes": "Match upon last word - Dog", - } - } - srp = SearchResultProcessor(test_result, "dog") - self.assertEqual(srp.excerpt, "Match upon last word - Dog") - - test_result = { - "content": { - "notes": ( - "The long and winding road " - "That leads to your door " - "Will never disappear " - "I've seen that road before " - "It always leads me here " - "Lead me to you door " - "The wild and windy night " - "That the rain washed away " - "Has left a pool of tears " - "Crying for the day " - "Why leave me standing here " - "Let me know the way " - "Many times I've been alone " - "And many times I've cried " - "Any way you'll never know " - "The many ways I've tried " - "But still they lead me back " - "To the long winding road " - "You left me standing here " - "A long long time ago " - "Don't leave me waiting here " - "Lead me to your door " - "But still they lead me back " - "To the long winding road " - "You left me standing here " - "A long long time ago " - "Don't leave me waiting here " - "Lead me to your door " - "Yeah, yeah, yeah, yeah " - "Match upon last word - Dog" - ), - } - } - srp = SearchResultProcessor(test_result, "dog") - self.assertEqual(srp.excerpt[-33:], "Match upon last word - Dog") - - -class TestSearchResultProcessor(SearchResultProcessor): - """ - Override the SearchResultProcessor so that we get the additional (inferred) properties - and can identify results that should be removed due to access restriction - """ - # pylint: disable=no-self-use - @property - def additional_property(self): - """ additional property that should appear within processed results """ - return "Should have an extra value" - - @property - def url(self): - """ - Property to display the url for the given location, useful for allowing navigation - """ - if "course" not in self._results_fields or "id" not in self._results_fields: - raise ValueError("expect this error when not providing a course and/or id") - - return u"/courses/{course_id}/jump_to/{location}".format( - course_id=self._results_fields["course"], - location=self._results_fields["id"], - ) - - def should_remove(self, user): - """ remove items when url is None """ - return "remove_me" in self._results_fields - - -@override_settings(SEARCH_RESULT_PROCESSOR="search.tests.tests.TestSearchResultProcessor") -class TestOverrideSearchResultProcessor(TestCase): - """ test the correct processing of results using the SEARCH_RESULT_PROCESSOR specified class """ - - def test_additional_property(self): - """ make sure the addition properties are returned """ - test_result = { - "course": "testmetestme", - "id": "herestheid" - } - new_result = SearchResultProcessor.process_result(test_result, "fake search pattern", None) - self.assertEqual(new_result, test_result) - self.assertEqual(test_result["url"], "/courses/testmetestme/jump_to/herestheid") - self.assertIsNone(test_result["excerpt"]) - self.assertEqual(test_result["additional_property"], "Should have an extra value") - - def test_removal(self): - """ make sure that the override of should remove let's the application prevent access to a result """ - test_result = { - "course": "remove_course", - "id": "remove_id", - "remove_me": True - } - new_result = SearchResultProcessor.process_result(test_result, "fake search pattern", None) - self.assertIsNone(new_result) - - def test_property_error(self): - """ result should be removed from list if there is an error in the handler properties """ - test_result = { - "not_course": "asdasda", - "not_id": "rthrthretht" - } - new_result = SearchResultProcessor.process_result(test_result, "fake search pattern", None) - self.assertIsNone(new_result) - - -def _post_request(body, course_id=None): - """ Helper method to post the request and process the response """ - address = '/' if course_id is None else '/{}'.format(course_id) - response = Client().post(address, body) - - return getattr(response, "status_code", 500), json.loads(getattr(response, "content", None)) - - -@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine") -@override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) -@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME) -class MockSearchUrlTest(TestCase): - """ - Make sure that requests to the url get routed to the correct view handler - """ - _searcher = None - - def _reset_mocked_tracker(self): - """ reset mocked tracker and clear logged emits """ - self.mock_tracker.reset_mock() - - def setUp(self): - MockSearchEngine.destroy() - self._searcher = None - patcher = patch('search.views.track') - self.mock_tracker = patcher.start() - self.addCleanup(patcher.stop) - - def tearDown(self): - MockSearchEngine.destroy() - self._searcher = None - - @property - def searcher(self): - """ return instance of searcher """ - if self._searcher is None: - self._searcher = SearchEngine.get_search_engine(TEST_INDEX_NAME) - return self._searcher - - def assert_no_events_were_emitted(self): - """Ensures no events were emitted since the last event related assertion""" - self.assertFalse(self.mock_tracker.emit.called) # pylint: disable=maybe-no-member - - def assert_search_initiated_event(self, search_term, size, page): - """Ensures an search initiated event was emitted""" - initiated_search_call = self.mock_tracker.emit.mock_calls[0] # pylint: disable=maybe-no-member - expected_result = call('edx.course.search.initiated', { - "search_term": unicode(search_term), - "page_size": size, - "page_number": page, - }) - self.assertEqual(expected_result, initiated_search_call) - - def assert_results_returned_event(self, search_term, size, page, total): - """Ensures an results returned event was emitted""" - returned_results_call = self.mock_tracker.emit.mock_calls[1] # pylint: disable=maybe-no-member - expected_result = call('edx.course.search.results_displayed', { - "search_term": unicode(search_term), - "page_size": size, - "page_number": page, - "results_count": total, - }) - self.assertEqual(expected_result, returned_results_call) - - def assert_initiated_return_events(self, search_term, size, page, total): - """Asserts search initiated and results returned events were emitted""" - self.assertEqual(self.mock_tracker.emit.call_count, 2) # pylint: disable=maybe-no-member - self.assert_search_initiated_event(search_term, size, page) - self.assert_results_returned_event(search_term, size, page, total) - - def test_url_resolution(self): - """ make sure that the url is resolved as expected """ - resolver = resolve('/') - self.assertEqual(resolver.view_name, 'do_search') - - with self.assertRaises(Resolver404): - resolver = resolve('/blah') - - resolver = resolve('/edX/DemoX/Demo_Course') - self.assertEqual(resolver.view_name, 'do_search') - self.assertEqual(resolver.kwargs['course_id'], 'edX/DemoX/Demo_Course') - - def test_search_from_url(self): - """ test searching using the url """ - self.searcher.index( - "test_doc", - { - "id": "FAKE_ID_1", - "content": { - "text": "Little Darling, it's been a long long lonely winter" - }, - "test_date": datetime(2015, 1, 1), - "test_string": "ABC, It's easy as 123" - } - ) - self.searcher.index( - "test_doc", - { - "id": "FAKE_ID_2", - "content": { - "text": "Little Darling, it's been a year since sun been gone" - } - } - ) - self.searcher.index("test_doc", {"id": "FAKE_ID_3", "content": {"text": "Here comes the sun"}}) - - # Test no events called yet after setup - self.assert_no_events_were_emitted() - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "sun"}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 2) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_3" in result_ids and "FAKE_ID_2" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("sun", 20, 0, 2) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Darling"}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 2) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Darling", 20, 0, 2) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "winter"}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" not in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("winter", 20, 0, 1) - self._reset_mocked_tracker() - - self.assertTrue(results["results"][0]["data"]["test_date"], datetime(2015, 1, 1).isoformat()) - self.assertTrue(results["results"][0]["data"]["test_string"], "ABC, It's easy as 123") - - def test_course_search_url(self): - """ test searching using the course url """ - self.searcher.index( - "test_doc", - { - "course": "ABC/DEF/GHI", - "id": "FAKE_ID_1", - "content": { - "text": "Little Darling, it's been a long long lonely winter" - } - } - ) - self.searcher.index( - "test_doc", - { - "course": "ABC/DEF/GHI", - "id": "FAKE_ID_2", - "content": { - "text": "Little Darling, it's been a year since you've been gone" - } - } - ) - self.searcher.index( - "test_doc", - { - "course": "LMN/OPQ/RST", - "id": "FAKE_ID_3", - "content": { - "text": "Little Darling, it's been a long long lonely winter" - } - } - ) - - # Test no events called yet after setup - self.assert_no_events_were_emitted() - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling"}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 20, 0, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Darling"}, "ABC/DEF/GHI") - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 2) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Darling", 20, 0, 2) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "winter"}, "ABC/DEF/GHI") - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" not in result_ids and "FAKE_ID_3" not in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("winter", 20, 0, 1) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "winter"}, "LMN/OPQ/RST") - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" not in result_ids and "FAKE_ID_2" not in result_ids and "FAKE_ID_3" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("winter", 20, 0, 1) - self._reset_mocked_tracker() - - def test_empty_search_string(self): - """ test when search string is provided as empty or null (None) """ - code, results = _post_request({"search_string": ""}) - self.assertTrue(code > 499) - self.assertEqual(results["error"], "No search term provided for search") - - code, results = _post_request({"no_search_string_provided": ""}) - self.assertTrue(code > 499) - self.assertEqual(results["error"], "No search term provided for search") - - def test_pagination(self): # pylint: disable=too-many-statements - """ test searching using the course url """ - self.searcher.index( - "test_doc", - { - "course": "ABC", - "id": "FAKE_ID_1", - "content": { - "text": "Little Darling Little Darling Little Darling, it's been a long long lonely winter" - } - } - ) - self.searcher.index( - "test_doc", - { - "course": "ABC", - "id": "FAKE_ID_2", - "content": { - "text": "Little Darling Little Darling, it's been a year since you've been gone" - } - } - ) - self.searcher.index( - "test_doc", - { - "course": "XYZ", - "id": "FAKE_ID_3", - "content": { - "text": "Little Darling, it's been a long long lonely winter" - } - } - ) - - # Test no events called yet after setup - self.assert_no_events_were_emitted() - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling"}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 3) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 20, 0, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 1}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 1, 0, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 1, "page_index": 0}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 1, 0, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 1, "page_index": 1}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_2" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 1, 1, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 1, "page_index": 2}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_3" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 1, 2, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 2}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 2) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 2, 0, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 2, "page_index": 0}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 2) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_1" in result_ids and "FAKE_ID_2" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 2, 0, 3) - self._reset_mocked_tracker() - - code, results = _post_request({"search_string": "Little Darling", "page_size": 2, "page_index": 1}) - self.assertTrue(code < 300 and code > 199) - self.assertEqual(results["total"], 3) - self.assertEqual(len(results["results"]), 1) - result_ids = [r["data"]["id"] for r in results["results"]] - self.assertTrue("FAKE_ID_3" in result_ids) - - # Test initiate search and return results were called - and clear mocked tracker - self.assert_initiated_return_events("Little Darling", 2, 1, 3) - self._reset_mocked_tracker() - - def test_page_size_too_large(self): - """ test searching with too-large page_size """ - self.searcher.index( - "test_doc", - { - "course": "ABC/DEF/GHI", - "id": "FAKE_ID_1", - "content": { - "text": "Little Darling, it's been a long long lonely winter" - } - } - ) - - code, results = _post_request({"search_string": "Little Darling", "page_size": 101}) - self.assertEqual(code, 500) - self.assertTrue("error" in results) - - -class ErroringSearchEngine(MockSearchEngine): - """ Override to generate search engine error to test """ - - def search(self, query_string=None, field_dictionary=None, filter_dictionary=None, **kwargs): - raise StandardError("There is a problem here") - - -class ErroringElasticImpl(Elasticsearch): - """ Elasticsearch implementation that throws exceptions""" - - # pylint: disable=unused-argument - def index(self, **kwargs): - """ this operation will fail """ - raise exceptions.ElasticsearchException("This index operation failed") - - # pylint: disable=unused-argument - def delete(self, **kwargs): - """ this operation will definitely fail """ - raise exceptions.ElasticsearchException("This delete operation failed") - - # pylint: disable=unused-argument - def search(self, **kwargs): - """ this will definitely fail """ - raise exceptions.ElasticsearchException("This search operation failed") - - -@override_settings(SEARCH_ENGINE="search.tests.tests.ForceRefreshElasticSearchEngine") +@override_settings(SEARCH_ENGINE="search.tests.utils.ForceRefreshElasticSearchEngine") @override_settings(ELASTIC_SEARCH_IMPL=ErroringElasticImpl) -class ErroringElasticTests(TestCase): +class ErroringElasticTests(TestCase, SearcherMixin): """ testing handling of elastic exceptions when they happen """ - _searcher = None - - @property - def searcher(self): - """ cached instance of search engine """ - if self._searcher is None: - self._searcher = SearchEngine.get_search_engine(TEST_INDEX_NAME) - return self._searcher - def test_index_failure(self): """ the index operation should fail """ with self.assertRaises(exceptions.ElasticsearchException): @@ -1537,44 +722,3 @@ def test_perform_search(self): """ search opertaion should yeild an exception with no search engine """ with self.assertRaises(NoSearchEngine): perform_search("abc test") - - -@override_settings(SEARCH_ENGINE="search.tests.tests.ErroringSearchEngine") -@override_settings(ELASTIC_FIELD_MAPPINGS={"start_date": {"type": "date"}}) -@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME) -class BadSearchTest(TestCase): - """ Make sure that we can error message when there is a problem """ - _searcher = None - - def setUp(self): - MockSearchEngine.destroy() - - def tearDown(self): - MockSearchEngine.destroy() - - def test_search_from_url(self): - """ ensure that we get the error back when the backend fails """ - searcher = SearchEngine.get_search_engine(TEST_INDEX_NAME) - searcher.index( - "test_doc", - { - "id": "FAKE_ID_1", - "content": { - "text": "Little Darling, it's been a long long lonely winter" - } - } - ) - searcher.index( - "test_doc", - { - "id": "FAKE_ID_2", - "content": { - "text": "Little Darling, it's been a year since sun been gone" - } - } - ) - searcher.index("test_doc", {"id": "FAKE_ID_3", "content": {"text": "Here comes the sun"}}) - - code, results = _post_request({"search_string": "sun"}) - self.assertTrue(code > 499) - self.assertEqual(results["error"], 'An error occurred when searching for "sun"') diff --git a/search/tests/utils.py b/search/tests/utils.py new file mode 100644 index 00000000..aebb2101 --- /dev/null +++ b/search/tests/utils.py @@ -0,0 +1,77 @@ +""" Test utilities """ +import json +from django.test import Client +from elasticsearch import Elasticsearch, exceptions +from search.search_engine_base import SearchEngine +from search.tests.mock_search_engine import MockSearchEngine +from search.elastic import ElasticSearchEngine + + +TEST_INDEX_NAME = "test_index" + + +def post_request(body, course_id=None): + """ Helper method to post the request and process the response """ + address = '/' if course_id is None else '/{}'.format(course_id) + response = Client().post(address, body) + + return getattr(response, "status_code", 500), json.loads(getattr(response, "content", None)) + + +# pylint: disable=too-few-public-methods +class SearcherMixin(object): + """ Mixin to provide searcher for the tests """ + _searcher = None + + @property + def searcher(self): + """ cached instance of search engine """ + if self._searcher is None: + self._searcher = SearchEngine.get_search_engine(TEST_INDEX_NAME) + return self._searcher + + +# We override ElasticSearchEngine class in order to force an index refresh upon index +# otherwise we often get results from the prior state, rendering the tests less useful +class ForceRefreshElasticSearchEngine(ElasticSearchEngine): + """ + Override of ElasticSearchEngine that forces the update of the index, + so that tests can relaibly search right afterward + """ + + def index(self, doc_type, body, **kwargs): + kwargs.update({ + "refresh": True + }) + super(ForceRefreshElasticSearchEngine, self).index(doc_type, body, **kwargs) + + def remove(self, doc_type, doc_id, **kwargs): + kwargs.update({ + "refresh": True + }) + super(ForceRefreshElasticSearchEngine, self).remove(doc_type, doc_id, **kwargs) + + +class ErroringSearchEngine(MockSearchEngine): + """ Override to generate search engine error to test """ + + def search(self, query_string=None, field_dictionary=None, filter_dictionary=None, **kwargs): + raise StandardError("There is a problem here") + + +class ErroringElasticImpl(Elasticsearch): + """ Elasticsearch implementation that throws exceptions""" + # pylint: disable=unused-argument + def index(self, **kwargs): + """ this operation will fail """ + raise exceptions.ElasticsearchException("This index operation failed") + + # pylint: disable=unused-argument + def delete(self, **kwargs): + """ this operation will definitely fail """ + raise exceptions.ElasticsearchException("This delete operation failed") + + # pylint: disable=unused-argument + def search(self, **kwargs): + """ this will definitely fail """ + raise exceptions.ElasticsearchException("This search operation failed")