| | 1 | from cStringIO import StringIO |
| | 2 | from django.contrib.admin.views.decorators import LOGIN_FORM_KEY, _encode_post_data |
| | 3 | from django.core.handlers.base import BaseHandler |
| | 4 | from django.core.handlers.wsgi import WSGIRequest |
| | 5 | from django.dispatch import dispatcher |
| | 6 | from django.http import urlencode, SimpleCookie |
| | 7 | from django.template import signals |
| | 8 | from django.utils.functional import curry |
| | 9 | |
| | 10 | class BrowserHandler(BaseHandler): |
| | 11 | """ |
| | 12 | A HTTP Handler that can be used for testing purposes. |
| | 13 | Uses the WSGI interface to compose requests, but returns |
| | 14 | the raw HttpResponse object |
| | 15 | """ |
| | 16 | def __call__(self, environ): |
| | 17 | from django.conf import settings |
| | 18 | from django.core import signals |
| | 19 | |
| | 20 | # Set up middleware if needed. We couldn't do this earlier, because |
| | 21 | # settings weren't available. |
| | 22 | if self._request_middleware is None: |
| | 23 | self.load_middleware() |
| | 24 | |
| | 25 | dispatcher.send(signal=signals.request_started) |
| | 26 | try: |
| | 27 | request = WSGIRequest(environ) |
| | 28 | response = self.get_response(request.path, request) |
| | 29 | |
| | 30 | # Apply response middleware |
| | 31 | for middleware_method in self._response_middleware: |
| | 32 | response = middleware_method(request, response) |
| | 33 | |
| | 34 | finally: |
| | 35 | dispatcher.send(signal=signals.request_finished) |
| | 36 | |
| | 37 | return response |
| | 38 | |
| | 39 | def store_rendered_templates(store, signal, sender, template, context): |
| | 40 | "A utility function for storing templates and contexts that are rendered" |
| | 41 | store.setdefault('template',[]).append(template) |
| | 42 | store.setdefault('context',[]).append(context) |
| | 43 | |
| | 44 | def encode_multipart(boundary, data): |
| | 45 | """ |
| | 46 | A simple method for encoding multipart POST data from a dictionary of |
| | 47 | form values. |
| | 48 | |
| | 49 | The key will be used as the form data name; the value will be transmitted |
| | 50 | as content. If the value is a file, the contents of the file will be sent |
| | 51 | as an application/octet-stream; otherwise, str(value) will be sent. |
| | 52 | """ |
| | 53 | lines = [] |
| | 54 | for (key, value) in data.items(): |
| | 55 | if isinstance(value, file): |
| | 56 | lines.append('--' + boundary) |
| | 57 | lines.append('Content-Disposition: form-data; name="%s"' % key) |
| | 58 | lines.append('') |
| | 59 | lines.append('--' + boundary) |
| | 60 | lines.append('Content-Disposition: form-data; name="%s_file"; filename="%s"' % (key, value.name)) |
| | 61 | lines.append('Content-Type: application/octet-stream') |
| | 62 | lines.append('') |
| | 63 | lines.append(value.read()) |
| | 64 | else: |
| | 65 | lines.append('--' + boundary) |
| | 66 | lines.append('Content-Disposition: form-data; name="%s"' % key) |
| | 67 | lines.append('') |
| | 68 | lines.append(str(value)) |
| | 69 | |
| | 70 | lines.append('--' + boundary + '--') |
| | 71 | lines.append('') |
| | 72 | return '\r\n'.join(lines) |
| | 73 | |
| | 74 | class Browser: |
| | 75 | """ |
| | 76 | A class that can act as a browser for testing purposes. |
| | 77 | |
| | 78 | It allows the user to compose GET and POST requests, and |
| | 79 | obtain the response that the server gave to those requests. |
| | 80 | The server Response objects are annotated with the details |
| | 81 | of the contexts and templates that were rendered during the |
| | 82 | process of serving the request. |
| | 83 | |
| | 84 | Browser objects are stateful - they will retain cookie (and |
| | 85 | thus session) details for the lifetime of the Browser instance. |
| | 86 | |
| | 87 | This is not intended as a replacement for Twill/Selenium or |
| | 88 | the like - it is here to allow testing against the |
| | 89 | contexts and templates produced by a view, rather than the |
| | 90 | HTML rendered to the end-user. |
| | 91 | """ |
| | 92 | def __init__(self, **defaults): |
| | 93 | self.handler = TestHandler() |
| | 94 | self.defaults = defaults |
| | 95 | self.cookie = SimpleCookie() |
| | 96 | |
| | 97 | def request(self, **request): |
| | 98 | """ |
| | 99 | The master request method. Composes the environment dictionary |
| | 100 | and passes to the handler, returning the result of the handler. |
| | 101 | Assumes defaults for the query environment, which can be overridden |
| | 102 | using the arguments to the request. |
| | 103 | """ |
| | 104 | |
| | 105 | |
| | 106 | # Set up the base request environment |
| | 107 | environ = { |
| | 108 | 'HTTP_COOKIE': self.cookie, |
| | 109 | 'PATH_INFO': '/', |
| | 110 | 'QUERY_STRING': '', |
| | 111 | 'REQUEST_METHOD': 'GET', |
| | 112 | 'SCRIPT_NAME': None, |
| | 113 | 'SERVER_NAME': 'testserver', |
| | 114 | 'SERVER_PORT': 80, |
| | 115 | 'SERVER_PROTOCOL': 'HTTP/1.1', |
| | 116 | } |
| | 117 | |
| | 118 | # Appy any default or request specific environment updates |
| | 119 | environ.update(self.defaults) |
| | 120 | environ.update(request) |
| | 121 | |
| | 122 | # Curry a data dictionary into an instance of |
| | 123 | # the template renderer callback function |
| | 124 | data = {} |
| | 125 | on_template_render = curry(store_rendered_templates, data) |
| | 126 | |
| | 127 | # Connect the curried function to the template_rendered signal |
| | 128 | dispatcher.connect(on_template_render, signal=signals.template_rendered) |
| | 129 | |
| | 130 | # Handle the request |
| | 131 | response = self.handler(environ) |
| | 132 | |
| | 133 | # Add any rendered template detail to the response |
| | 134 | # If there was only one template rendered (the most likely case), |
| | 135 | # flatten the list to a single element |
| | 136 | for detail in ('template', 'context'): |
| | 137 | if data.get(detail): |
| | 138 | if len(data[detail]) == 1: |
| | 139 | setattr(response, detail, data[detail][0]); |
| | 140 | else: |
| | 141 | setattr(response, detail, data[detail]) |
| | 142 | else: |
| | 143 | setattr(response, detail, None) |
| | 144 | |
| | 145 | # If the response requested a new cookie be set, store it |
| | 146 | if response.cookies: |
| | 147 | self.cookie.update(response.cookies) |
| | 148 | return response |
| | 149 | |
| | 150 | def get(self, path, data={}, **extra): |
| | 151 | "Request a response from the server using GET." |
| | 152 | r = { |
| | 153 | 'CONTENT_LENGTH': None, |
| | 154 | 'CONTENT_TYPE': 'text/html; charset=utf-8', |
| | 155 | 'PATH_INFO': path, |
| | 156 | 'QUERY_STRING': urlencode(data), |
| | 157 | 'REQUEST_METHOD': 'GET', |
| | 158 | } |
| | 159 | r.update(extra) |
| | 160 | |
| | 161 | return self.request(**r) |
| | 162 | |
| | 163 | def post(self, path, data={}, **extra): |
| | 164 | "Request a response from the server using POST." |
| | 165 | |
| | 166 | BOUNDARY = 'BoUnDaRyStRiNg' |
| | 167 | |
| | 168 | encoded = encode_multipart(BOUNDARY, data) |
| | 169 | stream = StringIO(encoded) |
| | 170 | r = { |
| | 171 | 'CONTENT_LENGTH': len(encoded), |
| | 172 | 'CONTENT_TYPE': 'multipart/form-data; boundary=%s' % BOUNDARY, |
| | 173 | 'PATH_INFO': path, |
| | 174 | 'REQUEST_METHOD': 'POST', |
| | 175 | 'wsgi.input': stream, |
| | 176 | } |
| | 177 | r.update(extra) |
| | 178 | |
| | 179 | return self.request(**r) |
| | 180 | |
| | 181 | def login(self, path, username, password, **extra): |
| | 182 | """ |
| | 183 | A specialized sequence of GET and POST to log into a view that |
| | 184 | is protected by @login_required or a similar access decorator. |
| | 185 | |
| | 186 | path should be the URL of the login page, or of any page that |
| | 187 | is login protected. |
| | 188 | |
| | 189 | Returns True if login was successful; False if otherwise. |
| | 190 | """ |
| | 191 | # First, GET the login page. |
| | 192 | # This is required to establish the session. |
| | 193 | response = self.get(path) |
| | 194 | if response.status_code != 200: |
| | 195 | return False |
| | 196 | |
| | 197 | # Set up the block of form data required by the login page. |
| | 198 | form_data = { |
| | 199 | 'username': username, |
| | 200 | 'password': password, |
| | 201 | 'this_is_the_login_form':1, |
| | 202 | 'post_data':_encode_post_data({LOGIN_FORM_KEY: 1}) |
| | 203 | } |
| | 204 | response = self.post('/admin/', data=form_data, **extra) |
| | 205 | |
| | 206 | # login page should response 200 (if you requested the login |
| | 207 | # page specifically), or 302 (if you requested a login |
| | 208 | # protected page, to which the login can redirect). |
| | 209 | return response.status_code in (200,302) |