Fixed #4476 -- Added a ``follow`` option to the test client request methods. This implements browser-like behavior for the test client, following redirect chains when a 30X response is received. Thanks to Marc Fargas and Keith Bussell for their work on this.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@9911 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Russell Keith-Magee 2009-02-27 13:14:59 +00:00
parent e20f09c2d0
commit e735fe7160
7 changed files with 298 additions and 76 deletions

View File

@ -1,5 +1,5 @@
import urllib import urllib
from urlparse import urlparse, urlunparse from urlparse import urlparse, urlunparse, urlsplit
import sys import sys
import os import os
try: try:
@ -12,7 +12,7 @@ from django.contrib.auth import authenticate, login
from django.core.handlers.base import BaseHandler from django.core.handlers.base import BaseHandler
from django.core.handlers.wsgi import WSGIRequest from django.core.handlers.wsgi import WSGIRequest
from django.core.signals import got_request_exception from django.core.signals import got_request_exception
from django.http import SimpleCookie, HttpRequest from django.http import SimpleCookie, HttpRequest, QueryDict
from django.template import TemplateDoesNotExist from django.template import TemplateDoesNotExist
from django.test import signals from django.test import signals
from django.utils.functional import curry from django.utils.functional import curry
@ -261,7 +261,7 @@ class Client(object):
return response return response
def get(self, path, data={}, **extra): def get(self, path, data={}, follow=False, **extra):
""" """
Requests a response from the server using GET. Requests a response from the server using GET.
""" """
@ -275,9 +275,13 @@ class Client(object):
} }
r.update(extra) r.update(extra)
return self.request(**r) response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def post(self, path, data={}, content_type=MULTIPART_CONTENT, **extra): def post(self, path, data={}, content_type=MULTIPART_CONTENT,
follow=False, **extra):
""" """
Requests a response from the server using POST. Requests a response from the server using POST.
""" """
@ -297,9 +301,12 @@ class Client(object):
} }
r.update(extra) r.update(extra)
return self.request(**r) response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def head(self, path, data={}, **extra): def head(self, path, data={}, follow=False, **extra):
""" """
Request a response from the server using HEAD. Request a response from the server using HEAD.
""" """
@ -313,9 +320,12 @@ class Client(object):
} }
r.update(extra) r.update(extra)
return self.request(**r) response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def options(self, path, data={}, **extra): def options(self, path, data={}, follow=False, **extra):
""" """
Request a response from the server using OPTIONS. Request a response from the server using OPTIONS.
""" """
@ -328,9 +338,13 @@ class Client(object):
} }
r.update(extra) r.update(extra)
return self.request(**r) response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def put(self, path, data={}, content_type=MULTIPART_CONTENT, **extra): def put(self, path, data={}, content_type=MULTIPART_CONTENT,
follow=False, **extra):
""" """
Send a resource to the server using PUT. Send a resource to the server using PUT.
""" """
@ -350,9 +364,12 @@ class Client(object):
} }
r.update(extra) r.update(extra)
return self.request(**r) response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def delete(self, path, data={}, **extra): def delete(self, path, data={}, follow=False, **extra):
""" """
Send a DELETE request to the server. Send a DELETE request to the server.
""" """
@ -365,7 +382,10 @@ class Client(object):
} }
r.update(extra) r.update(extra)
return self.request(**r) response = self.request(**r)
if follow:
response = self._handle_redirects(response)
return response
def login(self, **credentials): def login(self, **credentials):
""" """
@ -416,3 +436,27 @@ class Client(object):
session = __import__(settings.SESSION_ENGINE, {}, {}, ['']).SessionStore() session = __import__(settings.SESSION_ENGINE, {}, {}, ['']).SessionStore()
session.delete(session_key=self.cookies[settings.SESSION_COOKIE_NAME].value) session.delete(session_key=self.cookies[settings.SESSION_COOKIE_NAME].value)
self.cookies = SimpleCookie() self.cookies = SimpleCookie()
def _handle_redirects(self, response):
"Follows any redirects by requesting responses from the server using GET."
response.redirect_chain = []
while response.status_code in (301, 302, 303, 307):
url = response['Location']
scheme, netloc, path, query, fragment = urlsplit(url)
redirect_chain = response.redirect_chain
redirect_chain.append((url, response.status_code))
# The test client doesn't handle external links,
# but since the situation is simulated in test_client,
# we fake things here by ignoring the netloc portion of the
# redirected URL.
response = self.get(path, QueryDict(query), follow=False)
response.redirect_chain = redirect_chain
# Prevent loops
if response.redirect_chain[-1] in response.redirect_chain[0:-1]:
break
return response

View File

@ -276,25 +276,48 @@ class TransactionTestCase(unittest.TestCase):
Note that assertRedirects won't work for external links since it uses Note that assertRedirects won't work for external links since it uses
TestClient to do a request. TestClient to do a request.
""" """
self.assertEqual(response.status_code, status_code, if hasattr(response, 'redirect_chain'):
("Response didn't redirect as expected: Response code was %d" # The request was a followed redirect
" (expected %d)" % (response.status_code, status_code))) self.assertTrue(len(response.redirect_chain) > 0,
url = response['Location'] ("Response didn't redirect as expected: Response code was %d"
scheme, netloc, path, query, fragment = urlsplit(url) " (expected %d)" % (response.status_code, status_code)))
self.assertEqual(response.redirect_chain[0][1], status_code,
("Initial response didn't redirect as expected: Response code was %d"
" (expected %d)" % (response.redirect_chain[0][1], status_code)))
url, status_code = response.redirect_chain[-1]
self.assertEqual(response.status_code, target_status_code,
("Response didn't redirect as expected: Final Response code was %d"
" (expected %d)" % (response.status_code, target_status_code)))
else:
# Not a followed redirect
self.assertEqual(response.status_code, status_code,
("Response didn't redirect as expected: Response code was %d"
" (expected %d)" % (response.status_code, status_code)))
url = response['Location']
scheme, netloc, path, query, fragment = urlsplit(url)
redirect_response = response.client.get(path, QueryDict(query))
# Get the redirection page, using the same client that was used
# to obtain the original response.
self.assertEqual(redirect_response.status_code, target_status_code,
("Couldn't retrieve redirection page '%s': response code was %d"
" (expected %d)") %
(path, redirect_response.status_code, target_status_code))
e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url) e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url)
if not (e_scheme or e_netloc): if not (e_scheme or e_netloc):
expected_url = urlunsplit(('http', host or 'testserver', e_path, expected_url = urlunsplit(('http', host or 'testserver', e_path,
e_query, e_fragment)) e_query, e_fragment))
self.assertEqual(url, expected_url, self.assertEqual(url, expected_url,
"Response redirected to '%s', expected '%s'" % (url, expected_url)) "Response redirected to '%s', expected '%s'" % (url, expected_url))
# Get the redirection page, using the same client that was used
# to obtain the original response.
redirect_response = response.client.get(path, QueryDict(query))
self.assertEqual(redirect_response.status_code, target_status_code,
("Couldn't retrieve redirection page '%s': response code was %d"
" (expected %d)") %
(path, redirect_response.status_code, target_status_code))
def assertContains(self, response, text, count=None, status_code=200): def assertContains(self, response, text, count=None, status_code=200):
""" """

View File

@ -478,7 +478,8 @@ arguments at time of construction:
Once you have a ``Client`` instance, you can call any of the following Once you have a ``Client`` instance, you can call any of the following
methods: methods:
.. method:: Client.get(path, data={}) .. method:: Client.get(path, data={}, follow=False)
Makes a GET request on the provided ``path`` and returns a ``Response`` Makes a GET request on the provided ``path`` and returns a ``Response``
object, which is documented below. object, which is documented below.
@ -505,7 +506,18 @@ arguments at time of construction:
If you provide URL both an encoded GET data and a data argument, If you provide URL both an encoded GET data and a data argument,
the data argument will take precedence. the data argument will take precedence.
.. method:: Client.post(path, data={}, content_type=MULTIPART_CONTENT) If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
If you had an url ``/redirect_me/`` that redirected to ``/next/``, that
redirected to ``/final/``, this is what you'd see::
>>> response = c.get('/redirect_me/')
>>> response.redirect_chain
[(u'http://testserver/next/', 302), (u'http://testserver/final/', 302)]
.. method:: Client.post(path, data={}, content_type=MULTIPART_CONTENT, follow=False)
Makes a POST request on the provided ``path`` and returns a Makes a POST request on the provided ``path`` and returns a
``Response`` object, which is documented below. ``Response`` object, which is documented below.
@ -556,7 +568,7 @@ arguments at time of construction:
Note that you should manually close the file after it has been provided Note that you should manually close the file after it has been provided
to ``post()``. to ``post()``.
.. versionadded:: development .. versionchanged:: 1.1
If the URL you request with a POST contains encoded parameters, these If the URL you request with a POST contains encoded parameters, these
parameters will be made available in the request.GET data. For example, parameters will be made available in the request.GET data. For example,
@ -568,7 +580,11 @@ arguments at time of construction:
to retrieve the username and password, and could interrogate request.GET to retrieve the username and password, and could interrogate request.GET
to determine if the user was a visitor. to determine if the user was a visitor.
.. method:: Client.head(path, data={}) If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.head(path, data={}, follow=False)
.. versionadded:: development .. versionadded:: development
@ -576,14 +592,22 @@ arguments at time of construction:
object. Useful for testing RESTful interfaces. Acts just like object. Useful for testing RESTful interfaces. Acts just like
:meth:`Client.get` except it does not return a message body. :meth:`Client.get` except it does not return a message body.
.. method:: Client.options(path, data={}) If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.options(path, data={}, follow=False)
.. versionadded:: development .. versionadded:: development
Makes an OPTIONS request on the provided ``path`` and returns a Makes an OPTIONS request on the provided ``path`` and returns a
``Response`` object. Useful for testing RESTful interfaces. ``Response`` object. Useful for testing RESTful interfaces.
.. method:: Client.put(path, data={}, content_type=MULTIPART_CONTENT) If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.put(path, data={}, content_type=MULTIPART_CONTENT, follow=False)
.. versionadded:: development .. versionadded:: development
@ -591,13 +615,21 @@ arguments at time of construction:
``Response`` object. Useful for testing RESTful interfaces. Acts just ``Response`` object. Useful for testing RESTful interfaces. Acts just
like :meth:`Client.post` except with the PUT request method. like :meth:`Client.post` except with the PUT request method.
.. method:: Client.delete(path) If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.delete(path, follow=False)
.. versionadded:: development .. versionadded:: development
Makes an DELETE request on the provided ``path`` and returns a Makes an DELETE request on the provided ``path`` and returns a
``Response`` object. Useful for testing RESTful interfaces. ``Response`` object. Useful for testing RESTful interfaces.
If you set ``follow`` to ``True`` the client will follow any redirects
and a ``redirect_chain`` attribute will be set in the response object
containing tuples of the intermediate urls and status codes.
.. method:: Client.login(**credentials) .. method:: Client.login(**credentials)
.. versionadded:: 1.0 .. versionadded:: 1.0
@ -1028,9 +1060,15 @@ applications:
.. method:: assertRedirects(response, expected_url, status_code=302, target_status_code=200) .. method:: assertRedirects(response, expected_url, status_code=302, target_status_code=200)
Asserts that the response return a ``status_code`` redirect status, it Asserts that the response return a ``status_code`` redirect status, it
redirected to ``expected_url`` (including any GET data), and the subsequent redirected to ``expected_url`` (including any GET data), and the final
page was received with ``target_status_code``. page was received with ``target_status_code``.
.. versionadded:: 1.1
If your request used the ``follow`` argument, the ``expected_url`` and
``target_status_code`` will be the url and status code for the final
point of the redirect chain.
E-mail services E-mail services
--------------- ---------------

View File

@ -132,6 +132,12 @@ class ClientTest(TestCase):
# the attempt to get the redirection location returned 301 when retrieved # the attempt to get the redirection location returned 301 when retrieved
self.assertRedirects(response, 'http://testserver/test_client/permanent_redirect_view/', target_status_code=301) self.assertRedirects(response, 'http://testserver/test_client/permanent_redirect_view/', target_status_code=301)
def test_follow_redirect(self):
"A URL that redirects can be followed to termination."
response = self.client.get('/test_client/double_redirect_view/', follow=True)
self.assertRedirects(response, 'http://testserver/test_client/get_view/', status_code=302, target_status_code=200)
self.assertEquals(len(response.redirect_chain), 2)
def test_notfound_response(self): def test_notfound_response(self):
"GET a URL that responds as '404:Not Found'" "GET a URL that responds as '404:Not Found'"
response = self.client.get('/test_client/bad_view/') response = self.client.get('/test_client/bad_view/')

View File

@ -148,6 +148,107 @@ class AssertRedirectsTests(TestCase):
except AssertionError, e: except AssertionError, e:
self.assertEquals(str(e), "Couldn't retrieve redirection page '/test_client/permanent_redirect_view/': response code was 301 (expected 200)") self.assertEquals(str(e), "Couldn't retrieve redirection page '/test_client/permanent_redirect_view/': response code was 301 (expected 200)")
def test_redirect_chain(self):
"You can follow a redirect chain of multiple redirects"
response = self.client.get('/test_client_regress/redirects/further/more/', {}, follow=True)
self.assertRedirects(response, '/test_client_regress/no_template_view/',
status_code=301, target_status_code=200)
self.assertEquals(len(response.redirect_chain), 1)
self.assertEquals(response.redirect_chain[0], ('http://testserver/test_client_regress/no_template_view/', 301))
def test_multiple_redirect_chain(self):
"You can follow a redirect chain of multiple redirects"
response = self.client.get('/test_client_regress/redirects/', {}, follow=True)
self.assertRedirects(response, '/test_client_regress/no_template_view/',
status_code=301, target_status_code=200)
self.assertEquals(len(response.redirect_chain), 3)
self.assertEquals(response.redirect_chain[0], ('http://testserver/test_client_regress/redirects/further/', 301))
self.assertEquals(response.redirect_chain[1], ('http://testserver/test_client_regress/redirects/further/more/', 301))
self.assertEquals(response.redirect_chain[2], ('http://testserver/test_client_regress/no_template_view/', 301))
def test_redirect_chain_to_non_existent(self):
"You can follow a chain to a non-existent view"
response = self.client.get('/test_client_regress/redirect_to_non_existent_view2/', {}, follow=True)
self.assertRedirects(response, '/test_client_regress/non_existent_view/',
status_code=301, target_status_code=404)
def test_redirect_chain_to_self(self):
"Redirections to self are caught and escaped"
response = self.client.get('/test_client_regress/redirect_to_self/', {}, follow=True)
# The chain of redirects stops once the cycle is detected.
self.assertRedirects(response, '/test_client_regress/redirect_to_self/',
status_code=301, target_status_code=301)
self.assertEquals(len(response.redirect_chain), 2)
def test_circular_redirect(self):
"Circular redirect chains are caught and escaped"
response = self.client.get('/test_client_regress/circular_redirect_1/', {}, follow=True)
# The chain of redirects will get back to the starting point, but stop there.
self.assertRedirects(response, '/test_client_regress/circular_redirect_2/',
status_code=301, target_status_code=301)
self.assertEquals(len(response.redirect_chain), 4)
def test_redirect_chain_post(self):
"A redirect chain will be followed from an initial POST post"
response = self.client.post('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_head(self):
"A redirect chain will be followed from an initial HEAD request"
response = self.client.head('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_options(self):
"A redirect chain will be followed from an initial OPTIONS request"
response = self.client.options('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_put(self):
"A redirect chain will be followed from an initial PUT request"
response = self.client.put('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_delete(self):
"A redirect chain will be followed from an initial DELETE request"
response = self.client.delete('/test_client_regress/redirects/',
{'nothing': 'to_send'}, follow=True)
self.assertRedirects(response,
'/test_client_regress/no_template_view/', 301, 200)
self.assertEquals(len(response.redirect_chain), 3)
def test_redirect_chain_on_non_redirect_page(self):
"An assertion is raised if the original page couldn't be retrieved as expected"
# This page will redirect with code 301, not 302
response = self.client.get('/test_client/get_view/', follow=True)
try:
self.assertRedirects(response, '/test_client/get_view/')
except AssertionError, e:
self.assertEquals(str(e), "Response didn't redirect as expected: Response code was 200 (expected 302)")
def test_redirect_on_non_redirect_page(self):
"An assertion is raised if the original page couldn't be retrieved as expected"
# This page will redirect with code 301, not 302
response = self.client.get('/test_client/get_view/')
try:
self.assertRedirects(response, '/test_client/get_view/')
except AssertionError, e:
self.assertEquals(str(e), "Response didn't redirect as expected: Response code was 200 (expected 302)")
class AssertFormErrorTests(TestCase): class AssertFormErrorTests(TestCase):
def test_unknown_form(self): def test_unknown_form(self):
"An assertion is raised if the form name is unknown" "An assertion is raised if the form name is unknown"

View File

@ -1,4 +1,5 @@
from django.conf.urls.defaults import * from django.conf.urls.defaults import *
from django.views.generic.simple import redirect_to
import views import views
urlpatterns = patterns('', urlpatterns = patterns('',
@ -8,6 +9,15 @@ urlpatterns = patterns('',
(r'^request_data/$', views.request_data), (r'^request_data/$', views.request_data),
url(r'^arg_view/(?P<name>.+)/$', views.view_with_argument, name='arg_view'), url(r'^arg_view/(?P<name>.+)/$', views.view_with_argument, name='arg_view'),
(r'^login_protected_redirect_view/$', views.login_protected_redirect_view), (r'^login_protected_redirect_view/$', views.login_protected_redirect_view),
(r'^redirects/$', redirect_to, {'url': '/test_client_regress/redirects/further/'}),
(r'^redirects/further/$', redirect_to, {'url': '/test_client_regress/redirects/further/more/'}),
(r'^redirects/further/more/$', redirect_to, {'url': '/test_client_regress/no_template_view/'}),
(r'^redirect_to_non_existent_view/$', redirect_to, {'url': '/test_client_regress/non_existent_view/'}),
(r'^redirect_to_non_existent_view2/$', redirect_to, {'url': '/test_client_regress/redirect_to_non_existent_view/'}),
(r'^redirect_to_self/$', redirect_to, {'url': '/test_client_regress/redirect_to_self/'}),
(r'^circular_redirect_1/$', redirect_to, {'url': '/test_client_regress/circular_redirect_2/'}),
(r'^circular_redirect_2/$', redirect_to, {'url': '/test_client_regress/circular_redirect_3/'}),
(r'^circular_redirect_3/$', redirect_to, {'url': '/test_client_regress/circular_redirect_1/'}),
(r'^set_session/$', views.set_session_view), (r'^set_session/$', views.set_session_view),
(r'^check_session/$', views.check_session_view), (r'^check_session/$', views.check_session_view),
(r'^request_methods/$', views.request_methods_view), (r'^request_methods/$', views.request_methods_view),