diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..bebe57bfa9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[report] +include = */cyclone/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index dfb4104698..ec489a0d20 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist cyclone.egg-info dropin.cache *DS_Store* +_trial_temp diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..fc1cdc3c41 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: python +python: 2.7 + +install: + - pip install -r cyclone/tests/test_requirements.txt + - pip install coveralls + +script: coverage run `which trial` cyclone +after_success: coveralls diff --git a/cyclone/testing/__init__.py b/cyclone/testing/__init__.py new file mode 100644 index 0000000000..8da1538bad --- /dev/null +++ b/cyclone/testing/__init__.py @@ -0,0 +1,17 @@ +# +# Copyright 2014 David Novakovic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from .testcase import CycloneTestCase +from .client import Client \ No newline at end of file diff --git a/cyclone/testing/client.py b/cyclone/testing/client.py new file mode 100644 index 0000000000..053014c98c --- /dev/null +++ b/cyclone/testing/client.py @@ -0,0 +1,100 @@ +# +# Copyright 2014 David Novakovic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cyclone.httpserver import HTTPRequest, HTTPConnection +import urllib +from twisted.test import proto_helpers +from twisted.internet.defer import inlineCallbacks, returnValue + + +class Client(object): + def __init__(self, app): + self.app = app + + def get(self, uri, params=None, version="HTTP/1.0", headers=None, + body=None, remote_ip=None, protocol=None, host=None, + files=None, connection=None): + return self.request( + "GET", uri, params=params, version=version, headers=headers, + body=body, remote_ip=remote_ip, protocol=protocol, host=host, + files=files, connection=connection + ) + + def put(self, uri, params=None, version="HTTP/1.0", headers=None, + body=None, remote_ip=None, protocol=None, host=None, + files=None, connection=None): + return self.request( + "PUT", uri, params=params, version=version, headers=headers, + body=body, remote_ip=remote_ip, protocol=protocol, host=host, + files=files, connection=connection + ) + + def post(self, uri, params=None, version="HTTP/1.0", headers=None, + body=None, remote_ip=None, protocol=None, host=None, + files=None, connection=None): + return self.request( + "POST", uri, params=params, version=version, headers=headers, + body=body, remote_ip=remote_ip, protocol=protocol, host=host, + files=files, connection=connection + ) + + def delete(self, uri, params=None, version="HTTP/1.0", headers=None, + body=None, remote_ip=None, protocol=None, host=None, + files=None, connection=None): + return self.request( + "DELETE", uri, params=params, version=version, headers=headers, + body=body, remote_ip=remote_ip, protocol=protocol, host=host, + files=files, connection=connection + ) + + def head(self, uri, params=None, version="HTTP/1.0", headers=None, + body=None, remote_ip=None, protocol=None, host=None, + files=None, connection=None): + return self.request( + "HEAD", uri, params=params, version=version, headers=headers, + body=body, remote_ip=remote_ip, protocol=protocol, host=host, + files=files, connection=connection + ) + + @inlineCallbacks + def request(self, method, uri, *args, **kwargs): + params = kwargs.pop("params") + if params: + uri = uri + "?" + urllib.urlencode(params) + connection = kwargs.pop('connection') + if not connection: + connection = HTTPConnection() + connection.xheaders = False + kwargs['connection'] = connection + connection.factory = self.app + + request = HTTPRequest(method, uri, *args, **kwargs) + connection.connectionMade() + connection._request = request + connection.transport = proto_helpers.StringTransport() + request.remote_ip = connection.transport.getHost().host + handler = self.app(request) + + def setup_response(): + response_body = connection.transport.io.getvalue() + handler.content = response_body.split("\r\n\r\n", 1)[1] + handler.headers = handler._headers + + if handler._finished: + setup_response() + returnValue(handler) + yield connection.notifyFinish() + setup_response() + returnValue(handler) diff --git a/cyclone/testing/testcase.py b/cyclone/testing/testcase.py new file mode 100644 index 0000000000..53957499c7 --- /dev/null +++ b/cyclone/testing/testcase.py @@ -0,0 +1,37 @@ +# +# Copyright 2014 David Novakovic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from twisted.trial import unittest +from .client import Client + + +class CycloneTestCase(unittest.TestCase, object): + client_impl = Client + + def __init__(self, app_builder, *args, **kwargs): + """ + Create a test case for a cyclone app. + + The ``app_builder`` param should be a function that returns a + cyclone.web.Application instance will all the appropriate handlers + loaded etc. + + For most use cases this should be as simple as creating a function + that returns you application instead of just declaring it in a file + somewhere. + """ + super(CycloneTestCase, self).__init__(*args, **kwargs) + self._app = app_builder() + self.client = self.client_impl(self._app) diff --git a/cyclone/tests/__init__.py b/cyclone/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cyclone/tests/test_app.py b/cyclone/tests/test_app.py new file mode 100644 index 0000000000..d53d012f00 --- /dev/null +++ b/cyclone/tests/test_app.py @@ -0,0 +1,20 @@ +# +# Copyright 2014 David Novakovic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from twisted.trial import unittest + +class AppTests(unittest.TestCase): + def test_something(self): + pass \ No newline at end of file diff --git a/cyclone/tests/test_httpclient.py b/cyclone/tests/test_httpclient.py new file mode 100644 index 0000000000..024adb7db5 --- /dev/null +++ b/cyclone/tests/test_httpclient.py @@ -0,0 +1,197 @@ +# +# Copyright 2014 David Novakovic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from twisted.trial import unittest +from cyclone.httpclient import StringProducer, Receiver, HTTPClient +import cyclone.httpclient +from cStringIO import StringIO +from twisted.internet.defer import inlineCallbacks, Deferred, succeed +from mock import Mock +import functools +from cyclone import escape +from cyclone.web import HTTPError + + +class TestStringProducer(unittest.TestCase): + @inlineCallbacks + def test_stringproducer(self): + text = "some text" + producer = StringProducer(text) + self.assertEqual(producer.length, len(text)) + consumer = StringIO() + yield producer.startProducing(consumer) + self.assertEqual(consumer.getvalue(), text) + + +class TestReceiver(unittest.TestCase): + def test_receiver(self): + text = "Some text" + mock = Mock() + finished = Deferred().addCallback(mock) + receiver = Receiver(finished) + receiver.dataReceived(text) + receiver.dataReceived(text) + receiver.connectionLost(None) + mock.assert_called_with("Some textSome text") + + +class TestHTTPClient(unittest.TestCase): + URL = "http://example.com" + + def test_create_client(self): + client = HTTPClient(self.URL) + self.assertEqual(client._args, ()) + + def test_create_client_with_proxy(self): + client = HTTPClient(self.URL, proxy=("example.com", 8080)) + self.assertEqual(client.proxyConfig, ("example.com", 8080)) + self.assertEqual(client.agent._proxyEndpoint._port, 8080) + self.assertEqual(client.agent._proxyEndpoint._host, "example.com") + + def test_ensure_method_set_properly(self): + client = HTTPClient(self.URL, postdata="something") + self.assertEqual(client.method, "POST") + client = HTTPClient(self.URL) + self.assertEqual(client.method, "GET") + + def test_ensure_contenttype_set_properly(self): + client = HTTPClient(self.URL, postdata="something") + self.assertEqual( + client.headers, + {'Content-Type': ['application/x-www-form-urlencoded']} + ) + client = HTTPClient(self.URL, postdata="something", headers={ + "Content-Type": "nothing" + }) + self.assertEqual(client.headers, {"Content-Type": "nothing"}) + + def test_slightly_ambiguous_things(self): + """ + Test some broken things. + + This is to make sure we dont break backwards compat + if they are ever fixed. + """ + client = HTTPClient(self.URL, postdata="") + self.assertEqual(client.method, "GET") + + @inlineCallbacks + def test_fetch_basic(self): + client = HTTPClient("http://example.com") + client.agent = Mock() + _response = Mock() + _response.headers.getAllRawHeaders.return_value = {} + _response.deliverBody = lambda x: x.dataReceived("done") \ + or x.connectionLost(None) + client.agent.request.return_value = succeed(_response) + response = yield client.fetch() + self.assertEqual(response.body, "done") + + @inlineCallbacks + def test_fetch_head(self): + client = HTTPClient("http://example.com", method="HEAD") + client.agent = Mock() + _response = Mock() + _response.headers.getAllRawHeaders.return_value = {} + _response.deliverBody = lambda x: x.connectionLost(None) + client.agent.request.return_value = succeed(_response) + response = yield client.fetch() + self.assertEqual(response.body, "") + + @inlineCallbacks + def test_fetch_redirect(self): + client = HTTPClient("http://example.com") + client.agent = Mock() + _response = Mock() + _response.code = 302 + _response.headers.getAllRawHeaders.return_value = { + "Location": "http://example.com" + } + _response.deliverBody = lambda x: x.connectionLost(None) + client.agent.request.return_value = succeed(_response) + response = yield client.fetch() + self.assertEqual(response.body, "") + self.assertEqual(_response.headers, {"Location": "http://example.com"}) + + +class JsonRPCTest(unittest.TestCase): + URL = "http://example.com/jsonrpc" + + def setUp(self): + self._old_fetch = cyclone.httpclient.fetch + cyclone.httpclient.fetch = Mock() + self.client = cyclone.httpclient.JsonRPC(self.URL) + + def tearDown(self): + cyclone.httpclient.fetch = self._old_fetch + + def test_create_client(self): + client = cyclone.httpclient.JsonRPC(self.URL) + self.assertEqual(client.__dict__['_JsonRPC__rpcId'], 0) + self.assertEqual(client.__dict__['_JsonRPC__rpcUrl'], self.URL) + + def test_client_method_access(self): + method = self.client.foo + self.assertTrue(isinstance(method, functools.partial)) + self.assertTrue(method.args[0], 'foo') + + @inlineCallbacks + def test_rpc_request(self): + response = Mock() + response.code = 200 + response.body = escape.json_encode({"result": True}) + cyclone.httpclient.fetch.return_value = succeed(response) + result = yield self.client.foo() + self.assertTrue(result) + + @inlineCallbacks + def test_rpc_request_error(self): + response = Mock() + response.code = 200 + response.body = escape.json_encode({"error": {"message": "failed"}}) + cyclone.httpclient.fetch.return_value = succeed(response) + try: + yield self.client.foo() + except Exception, e: + self.assertEqual(e.message, "failed") + else: + raise Exception("Should raise an error.") + + @inlineCallbacks + def test_rpc_request_error_old(self): + response = Mock() + response.code = 200 + response.body = escape.json_encode({"error": "some error"}) + cyclone.httpclient.fetch.return_value = succeed(response) + try: + yield self.client.foo() + except Exception, e: + self.assertEqual(e.message, "some error") + else: + raise Exception("Should raise an error.") + + @inlineCallbacks + def test_rpc_request_404(self): + response = Mock() + response.code = 404 + response.phrase = "Not found." + response.body = escape.json_encode({"result": True}) + cyclone.httpclient.fetch.return_value = succeed(response) + try: + yield self.client.foo() + except HTTPError, e: + self.assertEqual(e.log_message, "Not found.") + else: + raise Exception("Should raise an error.") diff --git a/cyclone/tests/test_requirements.txt b/cyclone/tests/test_requirements.txt new file mode 100644 index 0000000000..787c3f06e3 --- /dev/null +++ b/cyclone/tests/test_requirements.txt @@ -0,0 +1,2 @@ +mock +twisted>=12.0 \ No newline at end of file diff --git a/cyclone/tests/test_testing.py b/cyclone/tests/test_testing.py new file mode 100644 index 0000000000..88e04c34aa --- /dev/null +++ b/cyclone/tests/test_testing.py @@ -0,0 +1,73 @@ +# +# Copyright 2014 David Novakovic +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from twisted.trial import unittest +from cyclone.testing import CycloneTestCase, Client +from cyclone.web import Application, RequestHandler, asynchronous +from twisted.internet import reactor +from twisted.internet.defer import inlineCallbacks + + +class TestHandler(RequestHandler): + def get(self): + self.write("Something") + + +class DeferredTestHandler(RequestHandler): + @asynchronous + def get(self): + self.write("Something...") + reactor.callLater(0.1, self.do_something) + + def do_something(self): + self.write("done!") + self.finish() + + +def mock_app_builder(): + return Application([ + (r'/testing/', TestHandler), + (r'/deferred_testing/', DeferredTestHandler) + ]) + + +class TestTestCase(unittest.TestCase): + def test_create(self): + case = CycloneTestCase(mock_app_builder) + self.assertTrue(case._app) + self.assertTrue(case.client) + + +class TestClient(unittest.TestCase): + def setUp(self): + self.app = mock_app_builder() + self.client = Client(self.app) + + def test_create_client(self): + app = mock_app_builder() + client = Client(app) + self.assertTrue(client.app) + + @inlineCallbacks + def test_get_request(self): + response = yield self.client.get("/testing/") + self.assertEqual(response.content, "Something") + self.assertTrue(len(response.headers) > 3) + + @inlineCallbacks + def test_get_deferred_request(self): + response = yield self.client.get("/deferred_testing/") + self.assertEqual(response.content, "Something...done!") + self.assertTrue(len(response.headers) > 3)