diff --git a/docs/api.rst b/docs/api.rst index 33c8a51..2c88bf7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -60,6 +60,24 @@ and coroutines. :inherited-members: :members: +``microdot_test_client`` module +------------------------------- + +The ``microdot_test_client`` module defines a test client that can be used to +create automated tests for the Microdot server. + +``TestClient`` class +~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: microdot_test_client.TestClient + :members: + +``TestResponse`` class +~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: microdot_test_client.TestResponse + :members: + ``microdot_wsgi`` module ------------------------ diff --git a/docs/extensions.rst b/docs/extensions.rst index d9a1b9b..481f4a4 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -94,6 +94,22 @@ Maintaing Secure User Sessions `hashlib `_, `warnings `_ +Test Client +~~~~~~~~~~~ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython & MicroPython + + * - Required Microdot source files + - | `microdot.py `_ + | `microdot_test_client.py `_ + + * - Required external dependencies + - | None + Deploying on a Production Web Server ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/intro.rst b/docs/intro.rst index 679ab41..dc6d634 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -727,5 +727,5 @@ Another option is to create a response object directly in the route function:: Standard cookies do not offer sufficient privacy and security controls, so never store sensitive information in them unless you are adding additional protection mechanisms such as encryption or cryptographic signing. The - :ref:`session ` extension implements signed cookies that prevent tampering - by malicious actors. + :ref:`session ` extension implements signed + cookies that prevent tampering by malicious actors. diff --git a/src/microdot.py b/src/microdot.py index d518ceb..7e10a8f 100644 --- a/src/microdot.py +++ b/src/microdot.py @@ -485,7 +485,7 @@ class Response(): can_flush = hasattr(stream, 'flush') try: for body in self.body_iter(): - if isinstance(body, str): + if isinstance(body, str): # pragma: no cover body = body.encode() stream.write(body) if can_flush: # pragma: no cover diff --git a/src/microdot_test_client.py b/src/microdot_test_client.py new file mode 100644 index 0000000..bc4e0fb --- /dev/null +++ b/src/microdot_test_client.py @@ -0,0 +1,189 @@ +from io import BytesIO +import json +from microdot import Request + + +class TestResponse: + """A response object issued by the Microdot test client.""" + def __init__(self, res): + #: The numeric status code returned by the server. + self.status_code = res.status_code + #: The text reason associated with the status response, such as + #: ``'OK'`` or ``'NOT FOUND'``. + self.reason = res.reason + #: A dictionary with the response headers. + self.headers = res.headers + #: The body of the response, as a bytes object. + self.body = b'' + for body in res.body_iter(): + if isinstance(body, str): + body = body.encode() + self.body += body + try: + #: The body of the response, decoded to a UTF-8 string. Set to + #: ``None`` if the response cannot be represented as UTF-8 text. + self.text = self.body.decode() + except ValueError: + self.text = None + #: The body of the JSON response, decoded to a dictionary or list. Set + #: ``Note`` if the response does not have a JSON payload. + self.json = None + for name, value in self.headers.items(): # pragma: no branch + if name.lower() == 'content-type': + if value.lower() == 'application/json': + self.json = json.loads(self.text) + break + + +class TestClient: + """A test client for Microdot. + + :param app: The Microdot application instance. + :param cookies: A dictionary of cookies to use when sending requests to the + application. + + The following example shows how to create a test client for an application + and send a test request:: + + from microdot import Microdot + + app = Microdot() + + @app.get('/') + def index(): + return 'Hello, World!' + + def test_hello_world(self): + client = TestClient(app) + res = client.get('/') + assert res.status_code == 200 + assert res.text == 'Hello, World!' + """ + def __init__(self, app, cookies=None): + self.app = app + self.cookies = cookies or {} + + def request(self, method, path, headers=None, body=None): + if headers is None: # pragma: no branch + headers = {} + if body is None: + body = b'' + elif isinstance(body, (dict, list)): + body = json.dumps(body).encode() + if 'Content-Type' not in headers and \ + 'content-type' not in headers: # pragma: no cover + headers['Content-Type'] = 'application/json' + elif isinstance(body, str): + body = body.encode() + if body and 'Content-Length' not in headers and \ + 'content-length' not in headers: + headers['Content-Length'] = str(len(body)) + cookies = '' + for name, value in self.cookies.items(): + if cookies: + cookies += '; ' + cookies += name + '=' + value + if cookies: + if 'Cookie' in headers: + headers['Cookie'] += '; ' + cookies + else: + headers['Cookie'] = cookies + request_bytes = '{method} {path} HTTP/1.0\n'.format( + method=method, path=path) + if 'Host' not in headers: # pragma: no branch + headers['Host'] = 'example.com:1234' + for header, value in headers.items(): + request_bytes += '{header}: {value}\n'.format( + header=header, value=value) + request_bytes = request_bytes.encode() + b'\n' + body + + req = Request.create(self.app, BytesIO(request_bytes), + ('127.0.0.1', 1234)) + res = self.app.dispatch_request(req) + res.complete() + + for name, value in res.headers.items(): + if name.lower() == 'set-cookie': + for cookie in value: + cookie_name, cookie_value = cookie.split('=', 1) + cookie_options = cookie_value.split(';') + delete = False + for option in cookie_options[1:]: + if option.strip().lower().startswith('expires='): + _, e = option.strip().split('=', 1) + # this is a very limited parser for cookie expiry + # that only detects a cookie deletion request when + # the date is 1/1/1970 + if '1 jan 1970' in e.lower(): # pragma: no branch + delete = True + break + if delete: + if cookie_name in self.cookies: # pragma: no branch + del self.cookies[cookie_name] + else: + self.cookies[cookie_name] = cookie_options[0] + return TestResponse(res) + + def get(self, path, headers=None): + """Send a GET request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + + This method returns a + :class:`TestResponse ` object. + """ + return self.request('GET', path, headers=headers) + + def post(self, path, headers=None, body=None): + """Send a POST request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return self.request('POST', path, headers=headers, body=body) + + def put(self, path, headers=None, body=None): + """Send a PUT request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return self.request('PUT', path, headers=headers, body=body) + + def patch(self, path, headers=None, body=None): + """Send a PATCH request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return self.request('PATCH', path, headers=headers, body=body) + + def delete(self, path, headers=None): + """Send a DELETE request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + + This method returns a + :class:`TestResponse ` object. + """ + return self.request('DELETE', path, headers=headers) diff --git a/tests/__init__.py b/tests/__init__.py index e39fe7b..b241e4c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,11 +1,13 @@ -from tests.microdot.test_multidict import TestMultiDict -from tests.microdot.test_request import TestRequest -from tests.microdot.test_response import TestResponse -from tests.microdot.test_url_pattern import TestURLPattern -from tests.microdot.test_microdot import TestMicrodot +from .test_multidict import TestMultiDict +from .test_request import TestRequest +from .test_response import TestResponse +from .test_url_pattern import TestURLPattern +from .test_microdot import TestMicrodot -from tests.microdot_asyncio.test_request_asyncio import TestRequestAsync -from tests.microdot_asyncio.test_response_asyncio import TestResponseAsync -from tests.microdot_asyncio.test_microdot_asyncio import TestMicrodotAsync +from .test_request_asyncio import TestRequestAsync +from .test_response_asyncio import TestResponseAsync +from .test_microdot_asyncio import TestMicrodotAsync -from tests.microdot_utemplate.test_utemplate import TestUTemplate +from .test_utemplate import TestUTemplate + +from .test_session import TestSession diff --git a/tests/microdot/__init__.py b/tests/microdot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/microdot/test_microdot.py b/tests/microdot/test_microdot.py deleted file mode 100644 index 9d0503c..0000000 --- a/tests/microdot/test_microdot.py +++ /dev/null @@ -1,466 +0,0 @@ -import sys -import unittest -from microdot import Microdot, Response -from tests import mock_socket - - -def mock_create_thread(f, *args, **kwargs): - f(*args, **kwargs) - - -class TestMicrodot(unittest.TestCase): - def setUp(self): - # mock socket module - self.original_socket = sys.modules['microdot'].socket - self.original_create_thread = sys.modules['microdot'].create_thread - sys.modules['microdot'].socket = mock_socket - sys.modules['microdot'].create_thread = mock_create_thread - - def tearDown(self): - # restore original socket module - sys.modules['microdot'].socket = self.original_socket - sys.modules['microdot'].create_thread = self.original_create_thread - - def _add_shutdown(self, app): - @app.route('/shutdown') - def shutdown(req): - app.shutdown() - return '' - - mock_socket.add_request('GET', '/shutdown') - - def test_get_request(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) - - def test_post_request(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.route('/', methods=['POST']) - def index_post(req): - return Response('bar') - - mock_socket.clear_requests() - fd = mock_socket.add_request('POST', '/') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nbar')) - - def test_empty_request(self): - app = Microdot() - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 11\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) - - def test_method_decorators(self): - app = Microdot() - - @app.get('/get') - def get(req): - return 'GET' - - @app.post('/post') - def post(req): - return 'POST' - - @app.put('/put') - def put(req): - return 'PUT' - - @app.patch('/patch') - def patch(req): - return 'PATCH' - - @app.delete('/delete') - def delete(req): - return 'DELETE' - - methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] - mock_socket.clear_requests() - fds = [mock_socket.add_request(method, '/' + method.lower()) - for method in methods] - self._add_shutdown(app) - app.run() - for fd, method in zip(fds, methods): - self.assertTrue(fd.response.endswith( - b'\r\n\r\n' + method.encode())) - - def test_tuple_responses(self): - app = Microdot() - - @app.route('/body') - def one(req): - return 'one' - - @app.route('/body-status') - def two(req): - return 'two', 202 - - @app.route('/body-headers') - def three(req): - return '

three

', {'Content-Type': 'text/html'} - - @app.route('/body-status-headers') - def four(req): - return '

four

', 202, {'Content-Type': 'text/html'} - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/body') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\none')) - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/body-status') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 202 N/A\r\n')) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\ntwo')) - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/body-headers') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Type: text/html\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n

three

')) - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/body-status-headers') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 202 N/A\r\n')) - self.assertIn(b'Content-Type: text/html\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n

four

')) - - def test_before_after_request(self): - app = Microdot() - - @app.before_request - def before_request(req): - if req.path == '/bar': - @req.after_request - def after_request(req, res): - res.headers['X-Two'] = '2' - return res - return 'bar', 202 - req.g.message = 'baz' - - @app.after_request - def after_request_one(req, res): - res.headers['X-One'] = '1' - - @app.after_request - def after_request_two(req, res): - res.set_cookie('foo', 'bar') - return res - - @app.route('/bar') - def bar(req): - return 'foo' - - @app.route('/baz') - def baz(req): - return req.g.message - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/bar') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 202 N/A\r\n')) - self.assertIn(b'X-One: 1\r\n', fd.response) - self.assertIn(b'X-Two: 2\r\n', fd.response) - self.assertIn(b'Set-Cookie: foo=bar\r\n', fd.response) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nbar')) - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/baz') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'X-One: 1\r\n', fd.response) - self.assertIn(b'Set-Cookie: foo=bar\r\n', fd.response) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nbaz')) - - def test_400(self): - app = Microdot() - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 11\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) - - def test_400_handler(self): - app = Microdot() - - @app.errorhandler(400) - def handle_400(req): - return '400' - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n400')) - - def test_404(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/foo') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 404 N/A\r\n')) - self.assertIn(b'Content-Length: 9\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nNot found')) - - def test_404_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.errorhandler(404) - def handle_404(req): - return '404' - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/foo') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n404')) - - def test_405(self): - app = Microdot() - - @app.route('/foo') - def index(req): - return 'foo' - - mock_socket.clear_requests() - fd = mock_socket.add_request('POST', '/foo') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 405 N/A\r\n')) - self.assertIn(b'Content-Length: 9\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nNot found')) - - def test_405_handler(self): - app = Microdot() - - @app.route('/foo') - def index(req): - return 'foo' - - @app.errorhandler(405) - def handle_404(req): - return '405' - - mock_socket.clear_requests() - fd = mock_socket.add_request('POST', '/foo') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n405')) - - def test_413(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/foo', body='x' * 17000) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 413 N/A\r\n')) - self.assertIn(b'Content-Length: 17\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nPayload too large')) - - def test_413_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.errorhandler(413) - def handle_413(req): - return '413', 400 - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/foo', body='x' * 17000) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n413')) - - def test_500(self): - app = Microdot() - - @app.route('/') - def index(req): - return 1 / 0 - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 500 N/A\r\n')) - self.assertIn(b'Content-Length: 21\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nInternal server error')) - - def test_500_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 1 / 0 - - @app.errorhandler(500) - def handle_500(req): - return '501', 501 - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 501 N/A\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n501')) - - def test_exception_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 1 / 0 - - @app.errorhandler(ZeroDivisionError) - def handle_div_zero(req, exc): - return '501', 501 - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 501 N/A\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n501')) - - def test_streaming(self): - app = Microdot() - - @app.route('/') - def index(req): - def stream(): - yield 'foo' - yield b'bar' - return stream() - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar')) - - def test_mount(self): - subapp = Microdot() - - @subapp.before_request - def before(req): - req.g.before = 'before' - - @subapp.after_request - def after(req, res): - return res.body + b':after' - - @subapp.errorhandler(404) - def not_found(req): - return '404', 404 - - @subapp.route('/app') - def index(req): - return req.g.before + ':foo' - - app = Microdot() - app.mount(subapp, url_prefix='/sub') - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/app') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 404 N/A\r\n')) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n404')) - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/sub/app') - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Type: text/plain\r\n', fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nbefore:foo:after')) diff --git a/tests/microdot_asyncio/__init__.py b/tests/microdot_asyncio/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/microdot_utemplate/templates/hello_txt.py b/tests/microdot_utemplate/templates/hello_txt.py deleted file mode 100644 index 25e52e0..0000000 --- a/tests/microdot_utemplate/templates/hello_txt.py +++ /dev/null @@ -1,6 +0,0 @@ -# Autogenerated file -def render(name): - yield """Hello, """ - yield str(name) - yield """! -""" diff --git a/tests/microdot_jinja/templates/hello.txt b/tests/templates/hello.jinja.txt similarity index 100% rename from tests/microdot_jinja/templates/hello.txt rename to tests/templates/hello.jinja.txt diff --git a/tests/microdot_utemplate/templates/hello.txt b/tests/templates/hello.utemplate.txt similarity index 100% rename from tests/microdot_utemplate/templates/hello.txt rename to tests/templates/hello.utemplate.txt diff --git a/tests/microdot_jinja/test_jinja.py b/tests/test_jinja.py similarity index 87% rename from tests/microdot_jinja/test_jinja.py rename to tests/test_jinja.py index 6170b23..5bded2d 100644 --- a/tests/microdot_jinja/test_jinja.py +++ b/tests/test_jinja.py @@ -10,7 +10,7 @@ from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync from microdot_jinja import render_template, init_templates from tests.mock_socket import get_request_fd, get_async_request_fd -init_templates('tests/microdot_jinja/templates') +init_templates('tests/templates') def _run(coro): @@ -21,7 +21,7 @@ def _run(coro): 'not supported under MicroPython') class TestUTemplate(unittest.TestCase): def test_render_template(self): - s = render_template('hello.txt', name='foo') + s = render_template('hello.jinja.txt', name='foo') self.assertEqual(s, 'Hello, foo!') def test_render_template_in_app(self): @@ -29,7 +29,7 @@ class TestUTemplate(unittest.TestCase): @app.route('/') def index(req): - return render_template('hello.txt', name='foo') + return render_template('hello.jinja.txt', name='foo') req = Request.create(app, get_request_fd('GET', '/'), 'addr') res = app.dispatch_request(req) @@ -41,7 +41,7 @@ class TestUTemplate(unittest.TestCase): @app.route('/') async def index(req): - return render_template('hello.txt', name='foo') + return render_template('hello.jinja.txt', name='foo') req = _run(RequestAsync.create( app, get_async_request_fd('GET', '/'), 'addr')) diff --git a/tests/test_microdot.py b/tests/test_microdot.py new file mode 100644 index 0000000..a2f6553 --- /dev/null +++ b/tests/test_microdot.py @@ -0,0 +1,529 @@ +import sys +import unittest +from microdot import Microdot, Response +from microdot_test_client import TestClient +from tests import mock_socket + + +def mock_create_thread(f, *args, **kwargs): + f(*args, **kwargs) + + +class TestMicrodot(unittest.TestCase): + def _mock_socket(self): + self.original_socket = sys.modules['microdot'].socket + self.original_create_thread = sys.modules['microdot'].create_thread + sys.modules['microdot'].socket = mock_socket + sys.modules['microdot'].create_thread = mock_create_thread + + def _unmock_socket(self): + sys.modules['microdot'].socket = self.original_socket + sys.modules['microdot'].create_thread = self.original_create_thread + + def _add_shutdown(self, app): + @app.route('/shutdown') + def shutdown(req): + app.shutdown() + return '' + + mock_socket.add_request('GET', '/shutdown') + + def test_get_request(self): + app = Microdot() + + @app.route('/') + def index(req): + return 'foo' + + client = TestClient(app) + res = client.get('/') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'foo') + self.assertEqual(res.body, b'foo') + self.assertEqual(res.json, None) + + def test_post_request(self): + app = Microdot() + + @app.route('/') + def index(req): + return 'foo' + + @app.route('/', methods=['POST']) + def index_post(req): + return Response('bar') + + client = TestClient(app) + res = client.post('/') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.headers['Content-Length'], '3') + self.assertEqual(res.text, 'bar') + + def test_empty_request(self): + self._mock_socket() + + app = Microdot() + + mock_socket.clear_requests() + fd = mock_socket.FakeStream(b'\n') + mock_socket._requests.append(fd) + self._add_shutdown(app) + app.run() + self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) + self.assertIn(b'Content-Length: 11\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain\r\n', fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) + + self._unmock_socket() + + def test_method_decorators(self): + app = Microdot() + + @app.get('/get') + def get(req): + return 'GET' + + @app.post('/post') + def post(req): + return 'POST' + + @app.put('/put') + def put(req): + return 'PUT' + + @app.patch('/patch') + def patch(req): + return 'PATCH' + + @app.delete('/delete') + def delete(req): + return 'DELETE' + + client = TestClient(app) + methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] + for method in methods: + res = getattr(client, method.lower())('/' + method.lower()) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, method) + + def test_headers(self): + app = Microdot() + + @app.route('/') + def index(req): + return req.headers.get('X-Foo') + + client = TestClient(app) + res = client.get('/', headers={'X-Foo': 'bar'}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'bar') + + def test_cookies(self): + app = Microdot() + + @app.route('/') + def index(req): + return req.cookies['one'] + req.cookies['two'] + \ + req.cookies['three'] + + client = TestClient(app, cookies={'one': '1', 'two': '2'}) + res = client.get('/', headers={'Cookie': 'three=3'}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '123') + + def test_binary_payload(self): + app = Microdot() + + @app.post('/') + def index(req): + return req.body + + client = TestClient(app) + res = client.post('/', body=b'foo') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'foo') + + def test_json_payload(self): + app = Microdot() + + @app.post('/dict') + def json_dict(req): + print(req.headers) + return req.json.get('foo') + + @app.post('/list') + def json_list(req): + return req.json[0] + + client = TestClient(app) + + res = client.post('/dict', body={'foo': 'bar'}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'bar') + + res = client.post('/list', body=['foo', 'bar']) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'foo') + + def test_tuple_responses(self): + app = Microdot() + + @app.route('/body') + def one(req): + return 'one' + + @app.route('/body-status') + def two(req): + return 'two', 202 + + @app.route('/body-headers') + def three(req): + return '

three

', {'Content-Type': 'text/html'} + + @app.route('/body-status-headers') + def four(req): + return '

four

', 202, {'Content-Type': 'text/html'} + + client = TestClient(app) + + res = client.get('/body') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'one') + + res = client.get('/body-status') + self.assertEqual(res.status_code, 202) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'two') + + res = client.get('/body-headers') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/html') + self.assertEqual(res.text, '

three

') + + res = client.get('/body-status-headers') + self.assertEqual(res.status_code, 202) + self.assertEqual(res.headers['Content-Type'], 'text/html') + self.assertEqual(res.text, '

four

') + + def test_before_after_request(self): + app = Microdot() + + @app.before_request + def before_request(req): + if req.path == '/bar': + @req.after_request + def after_request(req, res): + res.headers['X-Two'] = '2' + return res + return 'bar', 202 + req.g.message = 'baz' + + @app.after_request + def after_request_one(req, res): + res.headers['X-One'] = '1' + + @app.after_request + def after_request_two(req, res): + res.set_cookie('foo', 'bar') + return res + + @app.route('/bar') + def bar(req): + return 'foo' + + @app.route('/baz') + def baz(req): + return req.g.message + + client = TestClient(app) + + res = client.get('/bar') + self.assertEqual(res.status_code, 202) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.headers['Set-Cookie'], ['foo=bar']) + self.assertEqual(res.headers['X-One'], '1') + self.assertEqual(res.headers['X-Two'], '2') + self.assertEqual(res.text, 'bar') + self.assertEqual(client.cookies['foo'], 'bar') + + res = client.get('/baz') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.headers['Set-Cookie'], ['foo=bar']) + self.assertEqual(res.headers['X-One'], '1') + self.assertFalse('X-Two' in res.headers) + self.assertEqual(res.headers['Content-Length'], '3') + self.assertEqual(res.text, 'baz') + + def test_400(self): + self._mock_socket() + + app = Microdot() + + mock_socket.clear_requests() + fd = mock_socket.FakeStream(b'\n') + mock_socket._requests.append(fd) + self._add_shutdown(app) + app.run() + self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) + self.assertIn(b'Content-Length: 11\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain\r\n', fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) + + self._unmock_socket() + + def test_400_handler(self): + self._mock_socket() + + app = Microdot() + + @app.errorhandler(400) + def handle_400(req): + return '400' + + mock_socket.clear_requests() + fd = mock_socket.FakeStream(b'\n') + mock_socket._requests.append(fd) + self._add_shutdown(app) + app.run() + self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) + self.assertIn(b'Content-Length: 3\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain\r\n', fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\n400')) + + self._unmock_socket() + + def test_404(self): + app = Microdot() + + @app.route('/') + def index(req): + return 'foo' + + client = TestClient(app) + res = client.post('/foo') + self.assertEqual(res.status_code, 404) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'Not found') + + def test_404_handler(self): + app = Microdot() + + @app.route('/') + def index(req): + return 'foo' + + @app.errorhandler(404) + def handle_404(req): + return '404' + + client = TestClient(app) + res = client.post('/foo') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '404') + + def test_405(self): + app = Microdot() + + @app.route('/foo') + def index(req): + return 'foo' + + client = TestClient(app) + res = client.post('/foo') + self.assertEqual(res.status_code, 405) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'Not found') + + def test_405_handler(self): + app = Microdot() + + @app.route('/foo') + def index(req): + return 'foo' + + @app.errorhandler(405) + def handle_405(req): + return '405', 405 + + client = TestClient(app) + res = client.patch('/foo') + self.assertEqual(res.status_code, 405) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '405') + + def test_413(self): + app = Microdot() + + @app.post('/') + def index(req): + return 'foo' + + client = TestClient(app) + res = client.post('/foo', body='x' * 17000) + self.assertEqual(res.status_code, 413) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'Payload too large') + + def test_413_handler(self): + app = Microdot() + + @app.route('/') + def index(req): + return 'foo' + + @app.errorhandler(413) + def handle_413(req): + return '413', 400 + + client = TestClient(app) + res = client.post('/foo', body='x' * 17000) + self.assertEqual(res.status_code, 400) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '413') + + def test_500(self): + app = Microdot() + + @app.route('/') + def index(req): + return 1 / 0 + + client = TestClient(app) + res = client.get('/') + self.assertEqual(res.status_code, 500) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'Internal server error') + + def test_500_handler(self): + app = Microdot() + + @app.route('/') + def index(req): + return 1 / 0 + + @app.errorhandler(500) + def handle_500(req): + return '501', 501 + + client = TestClient(app) + res = client.get('/') + self.assertEqual(res.status_code, 501) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '501') + + def test_exception_handler(self): + app = Microdot() + + @app.route('/') + def index(req): + return 1 / 0 + + @app.errorhandler(ZeroDivisionError) + def handle_div_zero(req, exc): + return '501', 501 + + client = TestClient(app) + res = client.get('/') + self.assertEqual(res.status_code, 501) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '501') + + def test_json_response(self): + app = Microdot() + + @app.route('/dict') + def json_dict(req): + return {'foo': 'bar'} + + @app.route('/list') + def json_list(req): + return ['foo', 'bar'] + + client = TestClient(app) + + res = client.get('/dict') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'application/json') + self.assertEqual(res.json, {'foo': 'bar'}) + + res = client.get('/list') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'application/json') + self.assertEqual(res.json, ['foo', 'bar']) + + def test_binary_response(self): + app = Microdot() + + @app.route('/bin') + def index(req): + return b'\xff\xfe', {'Content-Type': 'application/octet-stream'} + + client = TestClient(app) + res = client.get('/bin') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], + 'application/octet-stream') + self.assertEqual(res.text, None) + self.assertEqual(res.json, None) + self.assertEqual(res.body, b'\xff\xfe') + + def test_streaming(self): + app = Microdot() + + @app.route('/') + def index(req): + def stream(): + yield 'foo' + yield b'bar' + return stream() + + client = TestClient(app) + res = client.get('/') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'foobar') + + def test_mount(self): + subapp = Microdot() + + @subapp.before_request + def before(req): + req.g.before = 'before' + + @subapp.after_request + def after(req, res): + return res.body + b':after' + + @subapp.errorhandler(404) + def not_found(req): + return '404', 404 + + @subapp.route('/app') + def index(req): + return req.g.before + ':foo' + + app = Microdot() + app.mount(subapp, url_prefix='/sub') + + client = TestClient(app) + + res = client.get('/app') + self.assertEqual(res.status_code, 404) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, '404') + + res = client.get('/sub/app') + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], 'text/plain') + self.assertEqual(res.text, 'before:foo:after') diff --git a/tests/microdot_asgi/test_microdot_asgi.py b/tests/test_microdot_asgi.py similarity index 100% rename from tests/microdot_asgi/test_microdot_asgi.py rename to tests/test_microdot_asgi.py diff --git a/tests/microdot_asyncio/test_microdot_asyncio.py b/tests/test_microdot_asyncio.py similarity index 100% rename from tests/microdot_asyncio/test_microdot_asyncio.py rename to tests/test_microdot_asyncio.py diff --git a/tests/microdot_wsgi/test_microdot_wsgi.py b/tests/test_microdot_wsgi.py similarity index 100% rename from tests/microdot_wsgi/test_microdot_wsgi.py rename to tests/test_microdot_wsgi.py diff --git a/tests/microdot/test_multidict.py b/tests/test_multidict.py similarity index 100% rename from tests/microdot/test_multidict.py rename to tests/test_multidict.py diff --git a/tests/microdot/test_request.py b/tests/test_request.py similarity index 100% rename from tests/microdot/test_request.py rename to tests/test_request.py diff --git a/tests/microdot_asyncio/test_request_asyncio.py b/tests/test_request_asyncio.py similarity index 100% rename from tests/microdot_asyncio/test_request_asyncio.py rename to tests/test_request_asyncio.py diff --git a/tests/microdot/test_response.py b/tests/test_response.py similarity index 100% rename from tests/microdot/test_response.py rename to tests/test_response.py diff --git a/tests/microdot_asyncio/test_response_asyncio.py b/tests/test_response_asyncio.py similarity index 100% rename from tests/microdot_asyncio/test_response_asyncio.py rename to tests/test_response_asyncio.py diff --git a/tests/microdot/test_url_pattern.py b/tests/test_url_pattern.py similarity index 100% rename from tests/microdot/test_url_pattern.py rename to tests/test_url_pattern.py diff --git a/tests/microdot_utemplate/test_utemplate.py b/tests/test_utemplate.py similarity index 86% rename from tests/microdot_utemplate/test_utemplate.py rename to tests/test_utemplate.py index 862253f..f03928f 100644 --- a/tests/microdot_utemplate/test_utemplate.py +++ b/tests/test_utemplate.py @@ -9,7 +9,7 @@ from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync from microdot_utemplate import render_template, init_templates from tests.mock_socket import get_request_fd, get_async_request_fd -init_templates('tests/microdot_utemplate/templates') +init_templates('tests/templates') def _run(coro): @@ -18,7 +18,7 @@ def _run(coro): class TestUTemplate(unittest.TestCase): def test_render_template(self): - s = list(render_template('hello.txt', name='foo')) + s = list(render_template('hello.utemplate.txt', name='foo')) self.assertEqual(s, ['Hello, ', 'foo', '!\n']) def test_render_template_in_app(self): @@ -26,7 +26,7 @@ class TestUTemplate(unittest.TestCase): @app.route('/') def index(req): - return render_template('hello.txt', name='foo') + return render_template('hello.utemplate.txt', name='foo') req = Request.create(app, get_request_fd('GET', '/'), 'addr') res = app.dispatch_request(req) @@ -38,7 +38,7 @@ class TestUTemplate(unittest.TestCase): @app.route('/') async def index(req): - return render_template('hello.txt', name='foo') + return render_template('hello.utemplate.txt', name='foo') req = _run(RequestAsync.create( app, get_async_request_fd('GET', '/'), 'addr'))