From 47268242b071895dd269d97540e45dce646f675c Mon Sep 17 00:00:00 2001 From: Nick Sarbicki Date: Mon, 5 Feb 2018 10:22:24 +0000 Subject: [PATCH] Fixed #29082 -- Allowed the test client to encode JSON request data. --- AUTHORS | 1 + django/test/client.py | 17 +++++++++++++++-- docs/releases/2.1.txt | 4 ++++ docs/topics/testing/tools.txt | 29 +++++++++++++++++++++++++---- tests/test_client/tests.py | 26 ++++++++++++++++++++++++++ tests/test_client/urls.py | 1 + tests/test_client/views.py | 14 ++++++++++++++ 7 files changed, 86 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index f5eba42531..58df5eacbf 100644 --- a/AUTHORS +++ b/AUTHORS @@ -605,6 +605,7 @@ answer newbie questions, and generally made Django that much better: Nick Pope Nick Presta Nick Sandford + Nick Sarbicki Niclas Olofsson Nicola Larosa Nicolas Lara diff --git a/django/test/client.py b/django/test/client.py index 9fce782dd4..71ccf9672c 100644 --- a/django/test/client.py +++ b/django/test/client.py @@ -13,6 +13,7 @@ from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit from django.conf import settings from django.core.handlers.base import BaseHandler from django.core.handlers.wsgi import WSGIRequest +from django.core.serializers.json import DjangoJSONEncoder from django.core.signals import ( got_request_exception, request_finished, request_started, ) @@ -261,7 +262,8 @@ class RequestFactory: Once you have a request object you can pass it to any view function, just as if that view had been hooked up using a URLconf. """ - def __init__(self, **defaults): + def __init__(self, *, json_encoder=DjangoJSONEncoder, **defaults): + self.json_encoder = json_encoder self.defaults = defaults self.cookies = SimpleCookie() self.errors = BytesIO() @@ -310,6 +312,14 @@ class RequestFactory: charset = settings.DEFAULT_CHARSET return force_bytes(data, encoding=charset) + def _encode_json(self, data, content_type): + """ + Return encoded JSON if data is a dict and content_type is + application/json. + """ + should_encode = JSON_CONTENT_TYPE_RE.match(content_type) and isinstance(data, dict) + return json.dumps(data, cls=self.json_encoder) if should_encode else data + def _get_path(self, parsed): path = parsed.path # If there are parameters, add them @@ -332,7 +342,7 @@ class RequestFactory: def post(self, path, data=None, content_type=MULTIPART_CONTENT, secure=False, **extra): """Construct a POST request.""" - data = {} if data is None else data + data = self._encode_json({} if data is None else data, content_type) post_data = self._encode_data(data, content_type) return self.generic('POST', path, post_data, content_type, @@ -359,18 +369,21 @@ class RequestFactory: def put(self, path, data='', content_type='application/octet-stream', secure=False, **extra): """Construct a PUT request.""" + data = self._encode_json(data, content_type) return self.generic('PUT', path, data, content_type, secure=secure, **extra) def patch(self, path, data='', content_type='application/octet-stream', secure=False, **extra): """Construct a PATCH request.""" + data = self._encode_json(data, content_type) return self.generic('PATCH', path, data, content_type, secure=secure, **extra) def delete(self, path, data='', content_type='application/octet-stream', secure=False, **extra): """Construct a DELETE request.""" + data = self._encode_json(data, content_type) return self.generic('DELETE', path, data, content_type, secure=secure, **extra) diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 54666e3d44..d62371f555 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -208,6 +208,10 @@ Tests * Added test :class:`~django.test.Client` support for 307 and 308 redirects. +* The test :class:`~django.test.Client` now serializes a request data + dictionary as JSON if ``content_type='application/json'``. You can customize + the JSON encoder with test client's ``json_encoder`` parameter. + URLs ~~~~ diff --git a/docs/topics/testing/tools.txt b/docs/topics/testing/tools.txt index d3be9e2124..3adacffb35 100644 --- a/docs/topics/testing/tools.txt +++ b/docs/topics/testing/tools.txt @@ -109,7 +109,7 @@ Making requests Use the ``django.test.Client`` class to make requests. -.. class:: Client(enforce_csrf_checks=False, **defaults) +.. class:: Client(enforce_csrf_checks=False, json_encoder=DjangoJSONEncoder, **defaults) It requires no arguments at time of construction. However, you can use keywords arguments to specify some default headers. For example, this will @@ -125,6 +125,13 @@ Use the ``django.test.Client`` class to make requests. The ``enforce_csrf_checks`` argument can be used to test CSRF protection (see above). + The ``json_encoder`` argument allows setting a custom JSON encoder for + the JSON serialization that's described in :meth:`post`. + + .. versionchanged:: 2.1 + + The ``json_encoder`` argument was added. + Once you have a ``Client`` instance, you can call any of the following methods: @@ -206,9 +213,23 @@ Use the ``django.test.Client`` class to make requests. name=fred&passwd=secret - If you provide ``content_type`` (e.g. :mimetype:`text/xml` for an XML - payload), the contents of ``data`` will be sent as-is in the POST - request, using ``content_type`` in the HTTP ``Content-Type`` header. + If you provide ``content_type`` as :mimetype:`application/json`, a + ``data`` dictionary is serialized using :func:`json.dumps` with + :class:`~django.core.serializers.json.DjangoJSONEncoder`. You can + change the encoder by providing a ``json_encoder`` argument to + :class:`Client`. This serialization also happens for :meth:`put`, + :meth:`patch`, and :meth:`delete` requests. + + .. versionchanged:: 2.1 + + The JSON serialization described above was added. In older versions, + you can call :func:`json.dumps` on ``data`` before passing it to + ``post()`` to achieve the same thing. + + If you provide any other ``content_type`` (e.g. :mimetype:`text/xml` + for an XML payload), the contents of ``data`` are sent as-is in the + POST request, using ``content_type`` in the HTTP ``Content-Type`` + header. If you don't provide a value for ``content_type``, the values in ``data`` will be transmitted with a content type of diff --git a/tests/test_client/tests.py b/tests/test_client/tests.py index 2643a279b1..393678ca03 100644 --- a/tests/test_client/tests.py +++ b/tests/test_client/tests.py @@ -21,6 +21,7 @@ rather than the HTML rendered to the end-user. """ import itertools import tempfile +from unittest import mock from django.contrib.auth.models import User from django.core import mail @@ -86,6 +87,31 @@ class ClientTest(TestCase): self.assertEqual(response.templates[0].name, 'POST Template') self.assertContains(response, 'Data received') + def test_json_serialization(self): + """The test client serializes JSON data.""" + methods = ('post', 'put', 'patch', 'delete') + for method in methods: + with self.subTest(method=method): + client_method = getattr(self.client, method) + method_name = method.upper() + response = client_method('/json_view/', {'value': 37}, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['data'], 37) + self.assertContains(response, 'Viewing %s page.' % method_name) + + def test_json_encoder_argument(self): + """The test Client accepts a json_encoder.""" + mock_encoder = mock.MagicMock() + mock_encoding = mock.MagicMock() + mock_encoder.return_value = mock_encoding + mock_encoding.encode.return_value = '{"value": 37}' + + client = self.client_class(json_encoder=mock_encoder) + # Vendored tree JSON content types are accepted. + client.post('/json_view/', {'value': 37}, content_type='application/vnd.api+json') + self.assertTrue(mock_encoder.called) + self.assertTrue(mock_encoding.encode.called) + def test_trace(self): """TRACE a view""" response = self.client.trace('/trace_view/') diff --git a/tests/test_client/urls.py b/tests/test_client/urls.py index 749426f2c4..9d1d8f49e1 100644 --- a/tests/test_client/urls.py +++ b/tests/test_client/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ url(r'^form_view/$', views.form_view), url(r'^form_view_with_template/$', views.form_view_with_template), url(r'^formset_view/$', views.formset_view), + url(r'^json_view/$', views.json_view), url(r'^login_protected_view/$', views.login_protected_view), url(r'^login_protected_method_view/$', views.login_protected_method_view), url(r'^login_protected_view_custom_redirect/$', views.login_protected_view_changed_redirect), diff --git a/tests/test_client/views.py b/tests/test_client/views.py index 80daa9bcad..9ffb1bd6a3 100644 --- a/tests/test_client/views.py +++ b/tests/test_client/views.py @@ -1,3 +1,4 @@ +import json from urllib.parse import urlencode from xml.dom.minidom import parseString @@ -73,7 +74,20 @@ def post_view(request): else: t = Template('Viewing GET page.', name='Empty GET Template') c = Context() + return HttpResponse(t.render(c)) + +def json_view(request): + """ + A view that expects a request with the header 'application/json' and JSON + data with a key named 'value'. + """ + if request.META.get('CONTENT_TYPE') != 'application/json': + return HttpResponse() + + t = Template('Viewing {} page. With data {{ data }}.'.format(request.method)) + data = json.loads(request.body.decode('utf-8')) + c = Context({'data': data['value']}) return HttpResponse(t.render(c))