mirror of
https://github.com/django/daphne.git
synced 2024-11-22 07:56:34 +03:00
Add HTTP response test suite
This commit is contained in:
parent
e0e60e4117
commit
1ca1c67032
|
@ -152,7 +152,7 @@ class Server(object):
|
||||||
"""
|
"""
|
||||||
Coroutine that jumps the reply message from asyncio to Twisted
|
Coroutine that jumps the reply message from asyncio to Twisted
|
||||||
"""
|
"""
|
||||||
reactor.callLater(0, protocol.handle_reply, message)
|
protocol.handle_reply(message)
|
||||||
|
|
||||||
### Utility
|
### Utility
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import msgpack
|
|
||||||
import os
|
import os
|
||||||
|
import pickle
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,12 +19,15 @@ class TestApplication:
|
||||||
async def __call__(self, send, receive):
|
async def __call__(self, send, receive):
|
||||||
# Load setup info
|
# Load setup info
|
||||||
setup = self.load_setup()
|
setup = self.load_setup()
|
||||||
|
# Receive input and send output
|
||||||
try:
|
try:
|
||||||
for _ in range(setup["receive_messages"]):
|
for _ in range(setup["receive_messages"]):
|
||||||
self.messages.append(await receive())
|
self.messages.append(await receive())
|
||||||
for message in setup["response_messages"]:
|
for message in setup["response_messages"]:
|
||||||
await send(message)
|
await send(message)
|
||||||
finally:
|
except Exception as e:
|
||||||
|
self.save_exception(e)
|
||||||
|
else:
|
||||||
self.save_result()
|
self.save_result()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -33,13 +36,13 @@ class TestApplication:
|
||||||
Stores setup information.
|
Stores setup information.
|
||||||
"""
|
"""
|
||||||
with open(cls.setup_storage, "wb") as fh:
|
with open(cls.setup_storage, "wb") as fh:
|
||||||
fh.write(msgpack.packb(
|
pickle.dump(
|
||||||
{
|
{
|
||||||
"response_messages": response_messages,
|
"response_messages": response_messages,
|
||||||
"receive_messages": receive_messages,
|
"receive_messages": receive_messages,
|
||||||
},
|
},
|
||||||
use_bin_type=True,
|
fh,
|
||||||
))
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_setup(cls):
|
def load_setup(cls):
|
||||||
|
@ -47,7 +50,7 @@ class TestApplication:
|
||||||
Returns setup details.
|
Returns setup details.
|
||||||
"""
|
"""
|
||||||
with open(cls.setup_storage, "rb") as fh:
|
with open(cls.setup_storage, "rb") as fh:
|
||||||
return msgpack.unpackb(fh.read(), encoding="utf-8")
|
return pickle.load(fh)
|
||||||
|
|
||||||
def save_result(self):
|
def save_result(self):
|
||||||
"""
|
"""
|
||||||
|
@ -55,13 +58,26 @@ class TestApplication:
|
||||||
We could use pickle here, but that seems wrong, still, somehow.
|
We could use pickle here, but that seems wrong, still, somehow.
|
||||||
"""
|
"""
|
||||||
with open(self.result_storage, "wb") as fh:
|
with open(self.result_storage, "wb") as fh:
|
||||||
fh.write(msgpack.packb(
|
pickle.dump(
|
||||||
{
|
{
|
||||||
"scope": self.scope,
|
"scope": self.scope,
|
||||||
"messages": self.messages,
|
"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
|
@classmethod
|
||||||
def load_result(cls):
|
def load_result(cls):
|
||||||
|
@ -69,7 +85,7 @@ class TestApplication:
|
||||||
Returns result details.
|
Returns result details.
|
||||||
"""
|
"""
|
||||||
with open(cls.result_storage, "rb") as fh:
|
with open(cls.result_storage, "rb") as fh:
|
||||||
return msgpack.unpackb(fh.read(), encoding="utf-8")
|
return pickle.load(fh)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_storage(cls):
|
def clear_storage(cls):
|
||||||
|
|
|
@ -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-à/")
|
|
|
@ -82,7 +82,19 @@ class DaphneTestCase(unittest.TestCase):
|
||||||
conn.endheaders(message_body=body)
|
conn.endheaders(message_body=body)
|
||||||
else:
|
else:
|
||||||
conn.endheaders()
|
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:
|
finally:
|
||||||
# Shut down daphne
|
# Shut down daphne
|
||||||
process.terminate()
|
process.terminate()
|
||||||
|
@ -107,6 +119,20 @@ class DaphneTestCase(unittest.TestCase):
|
||||||
)
|
)
|
||||||
return inner_result["scope"], inner_result["messages"]
|
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):
|
def tearDown(self):
|
||||||
"""
|
"""
|
||||||
Ensures any storage files are cleared.
|
Ensures any storage files are cleared.
|
||||||
|
@ -155,36 +181,6 @@ class DaphneTestCase(unittest.TestCase):
|
||||||
self.assertIsInstance(port, int)
|
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):
|
# class ASGIWebSocketTestCase(ASGITestCaseBase):
|
||||||
# """
|
# """
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import collections
|
import collections
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
from hypothesis import given, assume, settings, HealthCheck
|
from hypothesis import given, assume, settings
|
||||||
|
|
||||||
import http_strategies
|
import http_strategies
|
||||||
from http_base import DaphneTestCase
|
from http_base import DaphneTestCase
|
||||||
|
|
101
tests/test_http_response.py
Normal file
101
tests/test_http_response.py
Normal file
|
@ -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),
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user