diff --git a/common/djangoapps/terrain/start_stubs.py b/common/djangoapps/terrain/start_stubs.py index aec88a1cc1..7d115d04a7 100644 --- a/common/djangoapps/terrain/start_stubs.py +++ b/common/djangoapps/terrain/start_stubs.py @@ -8,8 +8,6 @@ from terrain.stubs.youtube import StubYouTubeService from terrain.stubs.xqueue import StubXQueueService -USAGE = "USAGE: python -m fakes.start SERVICE_NAME PORT_NUM" - SERVICES = { "youtube": {"port": settings.YOUTUBE_PORT, "class": StubYouTubeService}, "xqueue": {"port": settings.XQUEUE_PORT, "class": StubXQueueService}, diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py index 14dcf8ac2f..0a202c413a 100644 --- a/common/djangoapps/terrain/stubs/http.py +++ b/common/djangoapps/terrain/stubs/http.py @@ -23,14 +23,13 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): """ Redirect messages to keep the test console clean. """ + LOGGER.debug(self._format_msg(format_str, *args)) - msg = "{0} - - [{1}] {2}\n".format( - self.client_address[0], - self.log_date_time_string(), - format_str % args - ) - - LOGGER.debug(msg) + def log_error(self, format_str, *args): + """ + Helper to log a server error. + """ + LOGGER.error(self._format_msg(format_str, *args)) @lazy def request_content(self): @@ -76,22 +75,39 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): def do_PUT(self): """ Allow callers to configure the stub server using the /set_config URL. + The request should have POST data, such that: + + Each POST parameter is the configuration key. + Each POST value is a JSON-encoded string value for the configuration. """ if self.path == "/set_config" or self.path == "/set_config/": - for key, value in self.post_dict.iteritems(): - self.log_message("Set config '{0}' to '{1}'".format(key, value)) + if len(self.post_dict) > 0: + for key, value in self.post_dict.iteritems(): - try: - value = json.loads(value) + # Decode the params as UTF-8 + try: + key = unicode(key, 'utf-8') + value = unicode(value, 'utf-8') + except UnicodeDecodeError: + self.log_message("Could not decode request params as UTF-8") - except ValueError: - self.log_message(u"Could not parse JSON: {0}".format(value)) - self.send_response(400) + self.log_message(u"Set config '{0}' to '{1}'".format(key, value)) - else: - self.server.set_config(unicode(key, 'utf-8'), value) - self.send_response(200) + try: + value = json.loads(value) + + except ValueError: + self.log_message(u"Could not parse JSON: {0}".format(value)) + self.send_response(400) + + else: + self.server.config[key] = value + self.send_response(200) + + # No parameters sent to configure, so return success by default + else: + self.send_response(200) else: self.send_response(404) @@ -119,6 +135,18 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): if content is not None: self.wfile.write(content) + def _format_msg(self, format_str, *args): + """ + Format message for logging. + `format_str` is a string with old-style Python format escaping; + `args` is an array of values to fill into the string. + """ + return u"{0} - - [{1}] {2}\n".format( + self.client_address[0], + self.log_date_time_string(), + format_str % args + ) + class StubHttpService(HTTPServer, object): """ @@ -138,7 +166,7 @@ class StubHttpService(HTTPServer, object): HTTPServer.__init__(self, address, self.HANDLER_CLASS) # Create a dict to store configuration values set by the client - self._config = dict() + self.config = dict() # Start the server in a separate thread server_thread = threading.Thread(target=self.serve_forever) @@ -165,17 +193,3 @@ class StubHttpService(HTTPServer, object): """ _, port = self.server_address return port - - def config(self, key, default=None): - """ - Return the configuration value for `key`. If this - value has not been set, return `default` instead. - """ - return self._config.get(key, default) - - def set_config(self, key, value): - """ - Set the configuration `value` for `key`. - """ - self._config[key] = value - diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py new file mode 100644 index 0000000000..1533607583 --- /dev/null +++ b/common/djangoapps/terrain/stubs/start.py @@ -0,0 +1,72 @@ +""" +Command-line utility to start a stub service. +""" +import sys +import time +import logging +from .xqueue import StubXQueueService +from .youtube import StubYouTubeService + + +USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM" + +SERVICES = { + 'xqueue': StubXQueueService, + 'youtube': StubYouTubeService +} + +# Log to stdout, including debug messages +logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s") + + +def get_args(): + """ + Parse arguments, returning tuple of `(service_name, port_num)`. + Exits with a message if arguments are invalid. + """ + if len(sys.argv) < 3: + print USAGE + sys.exit(1) + + service_name = sys.argv[1] + port_num = sys.argv[2] + + if service_name not in SERVICES: + print "Unrecognized service '{0}'. Valid choices are: {1}".format( + service_name, ", ".join(SERVICES.keys())) + sys.exit(1) + + try: + port_num = int(port_num) + if port_num < 0: + raise ValueError + + except ValueError: + print "Port '{0}' must be a positive integer".format(port_num) + sys.exit(1) + + return service_name, port_num + + +def main(): + """ + Start a server; shut down on keyboard interrupt signal. + """ + service_name, port_num = get_args() + print "Starting stub service '{0}' on port {1}...".format(service_name, port_num) + + server = SERVICES[service_name](port_num=port_num) + + try: + while True: + time.sleep(1) + + except KeyboardInterrupt: + print "Stopping stub service..." + + finally: + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/common/djangoapps/terrain/stubs/tests/test_http.py b/common/djangoapps/terrain/stubs/tests/test_http.py index fb09bad173..ffad4cd88f 100644 --- a/common/djangoapps/terrain/stubs/tests/test_http.py +++ b/common/djangoapps/terrain/stubs/tests/test_http.py @@ -13,6 +13,7 @@ class StubHttpServiceTest(unittest.TestCase): def setUp(self): self.server = StubHttpService() self.addCleanup(self.server.shutdown) + self.url = "http://127.0.0.1:{0}/set_config".format(self.server.port) def test_configure(self): """ @@ -21,33 +22,38 @@ class StubHttpServiceTest(unittest.TestCase): """ params = { 'test_str': 'This is only a test', + 'test_empty': '', 'test_int': 12345, 'test_float': 123.45, + 'test_dict': { 'test_key': 'test_val' }, + 'test_empty_dict': {}, 'test_unicode': u'\u2603 the snowman', - 'test_dict': { 'test_key': 'test_val' } + 'test_none': None, + 'test_boolean': False } for key, val in params.iteritems(): - post_params = {key: json.dumps(val)} - response = requests.put( - "http://127.0.0.1:{0}/set_config".format(self.server.port), - data=post_params - ) + # JSON-encode each parameter + post_params = {key: json.dumps(val)} + response = requests.put(self.url, data=post_params) self.assertEqual(response.status_code, 200) # Check that the expected values were set in the configuration for key, val in params.iteritems(): - self.assertEqual(self.server.config(key), val) - - def test_default_config(self): - self.assertEqual(self.server.config('not_set', default=42), 42) + self.assertEqual(self.server.config.get(key), val) def test_bad_json(self): - response = requests.put( - "http://127.0.0.1:{0}/set_config".format(self.server.port), - data="{,}" - ) + response = requests.put(self.url, data="{,}") + self.assertEqual(response.status_code, 400) + + def test_no_post_data(self): + response = requests.put(self.url, data={}) + self.assertEqual(response.status_code, 200) + + def test_unicode_non_json(self): + # Send unicode without json-encoding it + response = requests.put(self.url, data={'test_unicode': u'\u2603 the snowman'}) self.assertEqual(response.status_code, 400) def test_unknown_path(self): diff --git a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py index 222792ebb3..5ac170b187 100644 --- a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py +++ b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py @@ -5,70 +5,172 @@ Unit tests for stub XQueue implementation. import mock import unittest import json -import urllib +import requests import time -from terrain.stubs.xqueue import StubXQueueService +import copy +from terrain.stubs.xqueue import StubXQueueService, StubXQueueHandler class StubXQueueServiceTest(unittest.TestCase): def setUp(self): self.server = StubXQueueService() - self.url = "http://127.0.0.1:{0}".format(self.server.port) + self.url = "http://127.0.0.1:{0}/xqueue/submit".format(self.server.port) self.addCleanup(self.server.shutdown) # For testing purposes, do not delay the grading response - self.server.set_config('response_delay', 0) + self.server.config['response_delay'] = 0 - @mock.patch('requests.post') + @mock.patch('terrain.stubs.xqueue.post') def test_grade_request(self, post): - # Send a grade request + # Post a submission to the stub XQueue callback_url = 'http://127.0.0.1:8000/test_callback' + expected_header = self._post_submission( + callback_url, 'test_queuekey', 'test_queue', + json.dumps({ + 'student_info': 'test', + 'grader_payload': 'test', + 'student_response': 'test' + }) + ) - grade_header = json.dumps({ - 'lms_callback_url': callback_url, - 'lms_key': 'test_queuekey', - 'queue_name': 'test_queue' - }) + # Check the response we receive + # (Should be the default grading response) + expected_body = json.dumps({'correct': True, 'score': 1, 'msg': '
'}) + self._check_grade_response(post, callback_url, expected_header, expected_body) - grade_body = json.dumps({ - 'student_info': 'test', - 'grader_payload': 'test', - 'student_response': 'test' - }) + @mock.patch('terrain.stubs.xqueue.post') + def test_configure_default_response(self, post): + # Configure the default response for submissions to any queue + response_content = {'test_response': 'test_content'} + self.server.config['default'] = response_content + + # Post a submission to the stub XQueue + callback_url = 'http://127.0.0.1:8000/test_callback' + expected_header = self._post_submission( + callback_url, 'test_queuekey', 'test_queue', + json.dumps({ + 'student_info': 'test', + 'grader_payload': 'test', + 'student_response': 'test' + }) + ) + + # Check the response we receive + # (Should be the default grading response) + self._check_grade_response( + post, callback_url, expected_header, json.dumps(response_content) + ) + + @mock.patch('terrain.stubs.xqueue.post') + def test_configure_specific_response(self, post): + + # Configure the XQueue stub response to any submission to the test queue + response_content = {'test_response': 'test_content'} + self.server.config['This is only a test.'] = response_content + + # Post a submission to the XQueue stub + callback_url = 'http://127.0.0.1:8000/test_callback' + expected_header = self._post_submission( + callback_url, 'test_queuekey', 'test_queue', + json.dumps({'submission': 'This is only a test.'}) + ) + + # Check that we receive the response we configured + self._check_grade_response( + post, callback_url, expected_header, json.dumps(response_content) + ) + + @mock.patch('terrain.stubs.xqueue.post') + def test_multiple_response_matches(self, post): + + # Configure the XQueue stub with two responses that + # match the same submission + self.server.config['test_1'] = {'response': True} + self.server.config['test_2'] = {'response': False} + + with mock.patch('terrain.stubs.http.LOGGER') as logger: + + # Post a submission to the XQueue stub + callback_url = 'http://127.0.0.1:8000/test_callback' + expected_header = self._post_submission( + callback_url, 'test_queuekey', 'test_queue', + json.dumps({'submission': 'test_1 and test_2'}) + ) + + # Wait for the delayed grade response + self._wait_for_mock_called(logger.error, max_time=10) + + # Expect that we do NOT receive a response + # and that an error message is logged + self.assertFalse(post.called) + self.assertTrue(logger.error.called) + + def _post_submission(self, callback_url, lms_key, queue_name, xqueue_body): + """ + Post a submission to the stub XQueue implementation. + `callback_url` is the URL at which we expect to receive a grade response + `lms_key` is the authentication key sent in the header + `queue_name` is the name of the queue in which to send put the submission + `xqueue_body` is the content of the submission + + Returns the header (a string) we send with the submission, which can + be used to validate the response we receive from the stub. + """ + + # Post a submission to the XQueue stub grade_request = { - 'xqueue_header': grade_header, - 'xqueue_body': grade_body - } - - response_handle = urllib.urlopen( - self.url + '/xqueue/submit', - urllib.urlencode(grade_request) - ) - - response_dict = json.loads(response_handle.read()) - - # Expect that the response is success - self.assertEqual(response_dict['return_code'], 0) - - # Expect that the server tries to post back the grading info - xqueue_body = json.dumps( - {'correct': True, 'score': 1, 'msg': ''} - ) - - expected_callback_dict = { - 'xqueue_header': grade_header, + 'xqueue_header': json.dumps({ + 'lms_callback_url': callback_url, + 'lms_key': 'test_queuekey', + 'queue_name': 'test_queue' + }), 'xqueue_body': xqueue_body } + resp = requests.post(self.url, data=grade_request) + + # Expect that the response is success + self.assertEqual(resp.status_code, 200) + + # Return back the header, so we can authenticate the response we receive + return grade_request['xqueue_header'] + + def _check_grade_response(self, post_mock, callback_url, expected_header, expected_body): + """ + Verify that the stub sent a POST request back to us + with the expected data. + + `post_mock` is our mock for `requests.post` + `callback_url` is the URL we expect the stub to POST to + `expected_header` is the header (a string) we expect to receive with the grade. + `expected_body` is the content (a string) we expect to receive with the grade. + + Raises an `AssertionError` if the check fails. + """ # Wait for the server to POST back to the callback URL - # Time out if it takes too long - start_time = time.time() - while time.time() - start_time < 5: - if post.called: - break + # If it takes too long, continue anyway + self._wait_for_mock_called(post_mock, max_time=10) + + # Check the response posted back to us + # This is the default response + expected_callback_dict = { + 'xqueue_header': expected_header, + 'xqueue_body': expected_body, + } # Check that the POST request was made with the correct params - post.assert_called_with(callback_url, data=expected_callback_dict) + post_mock.assert_called_with(callback_url, data=expected_callback_dict) + + def _wait_for_mock_called(self, mock_obj, max_time=10): + """ + Wait for `mock` (a `Mock` object) to be called. + If seconds elapsed exceeds `max_time`, continue without error. + """ + start_time = time.time() + while time.time() - start_time < max_time: + if mock_obj.called: + break + time.sleep(1) diff --git a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py index 3c8ef46908..0e5f27aca8 100644 --- a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py +++ b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py @@ -12,7 +12,7 @@ class StubYouTubeServiceTest(unittest.TestCase): def setUp(self): self.server = StubYouTubeService() self.url = "http://127.0.0.1:{0}/".format(self.server.port) - self.server.set_config('time_to_response', 0.0) + self.server.config['time_to_response'] = 0.0 self.addCleanup(self.server.shutdown) def test_unused_url(self): diff --git a/common/djangoapps/terrain/stubs/xqueue.py b/common/djangoapps/terrain/stubs/xqueue.py index 96e8e78f7b..52ca14ae34 100644 --- a/common/djangoapps/terrain/stubs/xqueue.py +++ b/common/djangoapps/terrain/stubs/xqueue.py @@ -1,10 +1,17 @@ """ Stub implementation of XQueue for acceptance tests. + +Configuration values: + "default" (dict): Default response to be sent to LMS as a grade for a submission + ""All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author
+Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
+