diff --git a/daphne/server.py b/daphne/server.py index 0892a7c..6bd6ede 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -152,7 +152,7 @@ class Server(object): """ Coroutine that jumps the reply message from asyncio to Twisted """ - reactor.callLater(0, protocol.handle_reply, message) + protocol.handle_reply(message) ### Utility diff --git a/daphne/test_utils.py b/daphne/test_utils.py index 3829d81..ebfabbd 100644 --- a/daphne/test_utils.py +++ b/daphne/test_utils.py @@ -1,5 +1,5 @@ -import msgpack import os +import pickle import tempfile @@ -19,12 +19,15 @@ class TestApplication: async def __call__(self, send, receive): # Load setup info setup = self.load_setup() + # Receive input and send output try: for _ in range(setup["receive_messages"]): self.messages.append(await receive()) for message in setup["response_messages"]: await send(message) - finally: + except Exception as e: + self.save_exception(e) + else: self.save_result() @classmethod @@ -33,13 +36,13 @@ class TestApplication: Stores setup information. """ with open(cls.setup_storage, "wb") as fh: - fh.write(msgpack.packb( + pickle.dump( { "response_messages": response_messages, "receive_messages": receive_messages, }, - use_bin_type=True, - )) + fh, + ) @classmethod def load_setup(cls): @@ -47,7 +50,7 @@ class TestApplication: Returns setup details. """ with open(cls.setup_storage, "rb") as fh: - return msgpack.unpackb(fh.read(), encoding="utf-8") + return pickle.load(fh) def save_result(self): """ @@ -55,13 +58,26 @@ class TestApplication: We could use pickle here, but that seems wrong, still, somehow. """ with open(self.result_storage, "wb") as fh: - fh.write(msgpack.packb( + pickle.dump( { "scope": self.scope, "messages": self.messages, }, - use_bin_type=True, - )) + fh, + ) + + def save_exception(self, exception): + """ + Saves details of what happened to the result storage. + We could use pickle here, but that seems wrong, still, somehow. + """ + with open(self.result_storage, "wb") as fh: + pickle.dump( + { + "exception": exception, + }, + fh, + ) @classmethod def load_result(cls): @@ -69,7 +85,7 @@ class TestApplication: Returns result details. """ with open(cls.result_storage, "rb") as fh: - return msgpack.unpackb(fh.read(), encoding="utf-8") + return pickle.load(fh) @classmethod def clear_storage(cls): diff --git a/daphne/tests/test_http_response.py b/daphne/tests/test_http_response.py deleted file mode 100644 index 4735825..0000000 --- a/daphne/tests/test_http_response.py +++ /dev/null @@ -1,128 +0,0 @@ -# coding: utf8 -""" -Tests for the HTTP response section of the ASGI spec -""" -from __future__ import unicode_literals - -from unittest import TestCase - -from asgiref.inmemory import ChannelLayer -from hypothesis import given, settings -from twisted.test import proto_helpers - -from daphne.http_protocol import HTTPFactory -from . import factories, http_strategies, testcases - - -class TestHTTPResponseSpec(testcases.ASGIHTTPTestCase): - - def test_minimal_response(self): - """ - Smallest viable example. Mostly verifies that our response building works. - """ - message = {"status": 200} - response = factories.response_for_message(message) - self.assert_valid_http_response_message(message, response) - self.assertIn(b"200 OK", response) - # Assert that the response is the last of the chunks. - # N.b. at the time of writing, Daphne did not support multiple response chunks, - # but still sends with Transfer-Encoding: chunked if no Content-Length header - # is specified (and maybe even if specified). - self.assertTrue(response.endswith(b"0\r\n\r\n")) - - def test_status_code_required(self): - """ - Asserts that passing in the 'status' key is required. - - Previous versions of Daphne did not enforce this, so this test is here - to make sure it stays required. - """ - with self.assertRaises(ValueError): - factories.response_for_message({}) - - def test_status_code_is_transmitted(self): - """ - Tests that a custom status code is present in the response. - - We can't really use hypothesis to test all sorts of status codes, because a lot - of them have meaning that is respected by Twisted. E.g. setting 204 (No Content) - as a status code results in Twisted discarding the body. - """ - message = {"status": 201} # 'Created' - response = factories.response_for_message(message) - self.assert_valid_http_response_message(message, response) - self.assertIn(b"201 Created", response) - - @given(body=http_strategies.http_body()) - def test_body_is_transmitted(self, body): - message = {"status": 200, "content": body.encode("ascii")} - response = factories.response_for_message(message) - self.assert_valid_http_response_message(message, response) - - @given(headers=http_strategies.headers()) - def test_headers(self, headers): - # The ASGI spec requires us to lowercase our header names - message = {"status": 200, "headers": [(name.lower(), value) for name, value in headers]} - response = factories.response_for_message(message) - # The assert_ method does the heavy lifting of checking that headers are - # as expected. - self.assert_valid_http_response_message(message, response) - - @given( - headers=http_strategies.headers(), - body=http_strategies.http_body(), - ) - @settings(perform_health_check=False) - def test_kitchen_sink(self, headers, body): - """ - This tests tries to let Hypothesis find combinations of variables that result - in breaking our assumptions. But responses are less exciting than responses, - so there's not a lot going on here. - """ - message = { - "status": 202, # 'Accepted' - "headers": [(name.lower(), value) for name, value in headers], - "content": body.encode("ascii") - } - response = factories.response_for_message(message) - self.assert_valid_http_response_message(message, response) - - -class TestHTTPResponse(TestCase): - """ - Tests that the HTTP protocol class correctly generates and parses messages. - """ - - def setUp(self): - self.channel_layer = ChannelLayer() - self.factory = HTTPFactory(self.channel_layer, send_channel="test!") - self.proto = self.factory.buildProtocol(("127.0.0.1", 0)) - self.tr = proto_helpers.StringTransport() - self.proto.makeConnection(self.tr) - - def test_http_disconnect_sets_path_key(self): - """ - Tests http disconnect has the path key set, see https://channels.readthedocs.io/en/latest/asgi.html#disconnect - """ - # Send a simple request to the protocol - self.proto.dataReceived( - b"GET /te%20st-%C3%A0/?foo=bar HTTP/1.1\r\n" + - b"Host: anywhere.com\r\n" + - b"\r\n" - ) - # Get the request message - _, message = self.channel_layer.receive(["http.request"]) - - # Send back an example response - self.factory.dispatch_reply( - message["reply_channel"], - { - "status": 200, - "status_text": b"OK", - "content": b"DISCO", - } - ) - - # Get the disconnection notification - _, disconnect_message = self.channel_layer.receive(["http.disconnect"]) - self.assertEqual(disconnect_message["path"], "/te st-à/") diff --git a/tests/http_base.py b/tests/http_base.py index da62baa..95990d5 100644 --- a/tests/http_base.py +++ b/tests/http_base.py @@ -82,7 +82,19 @@ class DaphneTestCase(unittest.TestCase): conn.endheaders(message_body=body) else: conn.endheaders() - response = conn.getresponse() + try: + response = conn.getresponse() + except socket.timeout: + # See if they left an exception for us to load + try: + exception_result = TestApplication.load_result() + except OSError: + raise RuntimeError("Daphne timed out handling request, no result file") + else: + if "exception" in exception_result: + raise exception_result["exception"] + else: + raise RuntimeError("Daphne timed out handling request, no exception found: %r" % exception_result) finally: # Shut down daphne process.terminate() @@ -107,6 +119,20 @@ class DaphneTestCase(unittest.TestCase): ) return inner_result["scope"], inner_result["messages"] + def run_daphne_response(self, response_messages): + """ + Convenience method for just testing response handling. + Returns (scope, messages) + """ + _, response = self.run_daphne( + method="GET", + path="/", + params={}, + body=b"", + responses=response_messages, + ) + return response + def tearDown(self): """ Ensures any storage files are cleared. @@ -155,36 +181,6 @@ class DaphneTestCase(unittest.TestCase): self.assertIsInstance(port, int) -# class ASGIHTTPTestCase(ASGITestCaseBase): -# """ -# Test case with helpers for verifying HTTP channel messages -# """ - - -# def assert_valid_http_response_message(self, message, response): -# self.assertTrue(message) -# self.assertTrue(response.startswith(b"HTTP")) - -# status_code_bytes = str(message["status"]).encode("ascii") -# self.assertIn(status_code_bytes, response) - -# if "content" in message: -# self.assertIn(message["content"], response) - -# # Check that headers are in the given order. -# # N.b. HTTP spec only enforces that the order of header values is kept, but -# # the ASGI spec requires that order of all headers is kept. This code -# # checks conformance with the stricter ASGI spec. -# if "headers" in message: -# for name, value in message["headers"]: -# expected_header = factories.header_line(name, value) -# # Daphne or Twisted turn our lower cased header names ('foo-bar') into title -# # case ('Foo-Bar'). So technically we want to to match that the header name is -# # present while ignoring casing, and want to ensure the value is present without -# # altered casing. The approach below does this well enough. -# self.assertIn(expected_header.lower(), response.lower()) -# self.assertIn(value.encode("ascii"), response) - # class ASGIWebSocketTestCase(ASGITestCaseBase): # """ diff --git a/tests/test_http_request.py b/tests/test_http_request.py index 5482bbb..0b79a87 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -3,7 +3,7 @@ import collections from urllib import parse -from hypothesis import given, assume, settings, HealthCheck +from hypothesis import given, assume, settings import http_strategies from http_base import DaphneTestCase diff --git a/tests/test_http_response.py b/tests/test_http_response.py new file mode 100644 index 0000000..88ee0f5 --- /dev/null +++ b/tests/test_http_response.py @@ -0,0 +1,101 @@ +# coding: utf8 + +from hypothesis import given, assume, settings + +import http_strategies +from http_base import DaphneTestCase + + +class TestHTTPResponseSpec(DaphneTestCase): + """ + Tests which try to pour the HTTP request section of the ASGI spec into code. + The heavy lifting is done by the assert_valid_http_request_message function, + the tests mostly serve to wire up hypothesis so that it exercise it's power to find + edge cases. + """ + + def normalize_headers(self, headers): + """ + Lowercases and sorts headers, and strips transfer-encoding ones. + """ + return sorted([ + (name.lower(), value) + for name, value in headers + if name.lower() != "transfer-encoding" + ]) + + def test_minimal_response(self): + """ + Smallest viable example. Mostly verifies that our response building works. + """ + response = self.run_daphne_response([ + { + "type": "http.response", + "status": 200, + "content": b"hello world", + }, + ]) + self.assertEqual(response.status, 200) + self.assertEqual(response.read(), b"hello world") + + def test_status_code_required(self): + """ + Asserts that passing in the 'status' key is required. + + Previous versions of Daphne did not enforce this, so this test is here + to make sure it stays required. + """ + with self.assertRaises(ValueError): + self.run_daphne_response([ + { + "type": "http.response", + "content": b"hello world", + }, + ]) + + def test_custom_status_code(self): + """ + Tries a non-default status code. + """ + response = self.run_daphne_response([ + { + "type": "http.response", + "status": 201, + "content": b"i made a thing!", + }, + ]) + self.assertEqual(response.status, 201) + self.assertEqual(response.read(), b"i made a thing!") + + @given(body=http_strategies.http_body()) + @settings(max_examples=5, deadline=2000) + def test_body(self, body): + """ + Tries body variants. + """ + response = self.run_daphne_response([ + { + "type": "http.response", + "status": 200, + "content": body, + }, + ]) + self.assertEqual(response.status, 200) + self.assertEqual(response.read(), body) + + @given(headers=http_strategies.headers()) + @settings(max_examples=5, deadline=2000) + def test_headers(self, headers): + # The ASGI spec requires us to lowercase our header names + response = self.run_daphne_response([ + { + "type": "http.response", + "status": 200, + "headers": self.normalize_headers(headers), + }, + ]) + # Check headers in a sensible way. Ignore transfer-encoding. + self.assertEqual( + self.normalize_headers(response.getheaders()), + self.normalize_headers(headers), + )