| 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) |