diff --git a/.travis.yml b/.travis.yml index b03f4e1..e8f456e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,15 +4,7 @@ install: - pip install tox --use-mirrors - .travis/install_pylint -env: - global: - - secure: "CQNSUBhVyedtCbioKkoueZsBFw0H+EnrPPNQIO+v3ieniPhFQbCoaCOA6rZG\n1MH1oIz5GSB1hv48jLCSSDppYOX1nKlLUFAepm9h7HSv2MaBzENKcp3ipBLP\nn8QEhVCkeWVnTkRB+IWrQHiW+8vHZ1iaERjlX9cMav7rBzzvK9g=" - - secure: "e5xYBGGzn6x06hmofDJ+tuS8iAVPuFNGqizR8cA6+2W4rSQEbh7NcKKeAvB5\n8qlmBonupo0wttkewh2hpnxvaXV7uS4C0Qt/h087Bu4cPkJMENWq++CrDo6G\nwjkAu6x6YDkzuMuxa5BTWU9hAQVX1jq+cjYOmORhw/v5FFukN44=" - - secure: "aU95NQmiY2ytyGRywEQvblN1YinIHpe/L9jnYlxazhfdHr+WXZd5aXC4Ze/U\nqlsHR+PGjycPHUCykJ/W5KU68tAX9r3PQgaQlfWd1cT89paY4givtoHiTz+f\nGu2I3BexskJ58NcUEDp6MEJqEuIXiQYUpoQ+6rNzvpe427xt6R0=" - - secure: "ilNFM41mePkXMpvK/6T7s3vsQCN36XoiHnR7Fxrnpur9sXOfwB8A1Kw7CpbM\n5rxc2QNj7SPrT2K49QE8fUKHIl88a2MqCf+ujy9mG7WgKdxYazIxrhyHCNKO\nZ47r38kijW92GnSX4KTDeORfouZgR21BpDTfoCvspiWzWzG/fYE=" - - secure: "YdUPDO7sTUTG2EwUlrxwOWKhlGXJiIK+RBWDspqvM8UQV4CQjzIsRX8urUIN\nSpSjJOfbIw25S+AsLpEBye8OJMncm/16Xp7PL5tlkNmRC12mPVG8f+wpOkrW\nt8v+2Cv/prYDn0tjoqnV1f5Nv5cEW6kAkG19UQ4QBgQzirtrs9Y=" - -script: .travis/run_travis +script: tox after_script: # Run Pylint diff --git a/.travis/run_travis b/.travis/run_travis deleted file mode 100755 index bfaee74..0000000 --- a/.travis/run_travis +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# Create a config file containing the test host's secrets - -CONFIG_DIR=~/.config/openphoto -CONFIG_FILE=$CONFIG_DIR/test - -mkdir ~/.config -mkdir $CONFIG_DIR - -echo "host = $OP_HOST" >> $CONFIG_FILE -echo "consumerKey = $OP_CONSUMER_KEY" >> $CONFIG_FILE -echo "consumerSecret = $OP_CONSUMER_SECRET" >> $CONFIG_FILE -echo "token = $OP_TOKEN" >> $CONFIG_FILE -echo "tokenSecret = $OP_TOKEN_SECRET" >> $CONFIG_FILE - -# Run the tests - -tox -e py27 diff --git a/openphoto/main.py b/openphoto/main.py index 4849625..59a41a7 100644 --- a/openphoto/main.py +++ b/openphoto/main.py @@ -81,6 +81,8 @@ def main(args=sys.argv[1:]): params, files = extract_files(params) result = client.post(options.endpoint, process_response=False, files=files, **params) + for f in files: + files[f].close() if options.verbose: print("==========\nMethod: %s\nHost: %s\nEndpoint: %s" % diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 178057e..86a8b3d 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -14,12 +14,8 @@ from openphoto.config import Config if sys.version < '3': TEXT_TYPE = unicode - # requests_oauth needs to decode to ascii for Python2 - OAUTH_DECODING = "utf-8" else: TEXT_TYPE = str - # requests_oauth needs to use (unicode) strings for Python3 - OAUTH_DECODING = None DUPLICATE_RESPONSE = {"code": 409, "message": "This photo already exists"} @@ -76,8 +72,7 @@ class OpenPhotoHttp: auth = requests_oauthlib.OAuth1(self.config.consumer_key, self.config.consumer_secret, self.config.token, - self.config.token_secret, - decoding=OAUTH_DECODING) + self.config.token_secret) else: auth = None @@ -122,8 +117,7 @@ class OpenPhotoHttp: auth = requests_oauthlib.OAuth1(self.config.consumer_key, self.config.consumer_secret, self.config.token, - self.config.token_secret, - decoding=OAUTH_DECODING) + self.config.token_secret) with requests.Session() as session: if files: # Need to pass parameters as URL query, so they get OAuth signed diff --git a/run_tests b/run_functional_tests similarity index 65% rename from run_tests rename to run_functional_tests index b800038..5208de8 100755 --- a/run_tests +++ b/run_functional_tests @@ -1,10 +1,9 @@ #!/bin/bash # -# Simple script to run all tests with multiple test servers -# across all supported Python versions +# Simple script to run all functional tests with multiple test servers # -# Default test server running latest self-hosted site +# Test server running latest self-hosted site tput setaf 3 echo @@ -12,7 +11,7 @@ echo "Testing latest self-hosted site..." tput sgr0 export OPENPHOTO_TEST_CONFIG=test unset OPENPHOTO_TEST_SERVER_API -tox $@ +python -m unittest discover --catch tests/functional # Test server running APIv1 OpenPhoto instance tput setaf 3 @@ -21,7 +20,7 @@ echo "Testing APIv1 self-hosted site..." tput sgr0 export OPENPHOTO_TEST_CONFIG=test-apiv1 export OPENPHOTO_TEST_SERVER_API=1 -tox $@ +python -m unittest discover --catch tests/functional # Test account on hosted trovebox.com site tput setaf 3 @@ -30,5 +29,5 @@ echo "Testing latest hosted site..." tput sgr0 export OPENPHOTO_TEST_CONFIG=test-hosted unset OPENPHOTO_TEST_SERVER_API -tox $@ +python -m unittest discover --catch tests/functional diff --git a/tests/README.markdown b/tests/README.markdown index 84eb6ab..793ae68 100644 --- a/tests/README.markdown +++ b/tests/README.markdown @@ -1,101 +1,34 @@ -Tests for the Open Photo API / Python Library +OpenPhoto/Trovebox Python Testing ======================= -#### OpenPhoto, a photo service for the masses + +###Unit Tests + +The unit tests mock out all HTTP requests, and verify that the various +components of the library are operating correctly. + +They run very quickly and don't require any external test hosts. + + +#### Requirements + * mock >= 1.0.0 + * httpretty >= 0.6.1 + * tox (optional) + +#### Running the Unit Tests + + python -m unittest discover tests/unit + +To run the unit tests against all supported Python versions, use ```tox```: + + tox ---------------------------------------- - -### Requirements -A computer, Python and an empty OpenPhoto test host. ---------------------------------------- - -### Setting up +###Functional Tests -Create a ``~/.config/openphoto/test`` config file containing the following: +The functional tests check that the openphoto-python library interoperates +correctly with a real OpenPhoto/Trovebox server. - # ~/.config/openphoto/test - host = your.host.com - consumerKey = your_consumer_key - consumerSecret = your_consumer_secret - token = your_access_token - tokenSecret = your_access_token_secret +They are slow to run and rely on a stable HTTP connection to a test server. -Make sure this is an empty test server, **not a production OpenPhoto server!!!** - -You can specify an alternate test config file with the following environment variable: - - export OPENPHOTO_TEST_CONFIG=test2 - ---------------------------------------- - -### Running the tests - -The following instructions are for Python 2.7. You can adapt them for earlier -Python versions using the ``unittest2`` package. - - cd /path/to/openphoto-python - python -m unittest discover -c - -The "-c" lets you stop the tests gracefully with \[CTRL\]-c. - -The easiest way to run a subset of the tests is with the ``nose`` package: - - cd /path/to/openphoto-python - nosetests -v -s --nologcapture tests/test_albums.py:TestAlbums.test_view - -All HTTP requests and responses are recorded in the file ``tests.log``. - -You can enable more verbose output to stdout with the following environment variable: - - export OPENPHOTO_TEST_DEBUG=1 - ---------------------------------------- - -### Test Details - -These tests are intended to verify the openphoto-python library. -They don't provide comprehensive testing of the OpenPhoto API, -there are PHP unit tests for that. - -Each test class is run as follows: - -**SetUpClass:** - -Check that the server is empty - -**SetUp:** - -Ensure there are: - - * Three test photos - * A single test tag applied to each - * A single album containing all three photos - -**TearDownClass:** - -Remove all photos, tags and albums - -### Testing old servers - -By default, all currently supported API versions will be tested. -It's useful to test servers that only support older API versions. -To restrict the testing to a specific maximum API version, use the -``OPENPHOTO_TEST_SERVER_API`` environment variable. - -For example, to restrict testing to APIv1 and APIv2: - - export OPENPHOTO_TEST_SERVER_API=2 - - -### Full Regression Test - -The ``run_tests`` script uses the ``tox`` package to run a full regression across: - * Multiple Python versions - * All supported API versions - -To use it, you must set up multiple OpenPhoto instances and create the following -config files containing your credentials: - - test : Latest self-hosted site - test-apiv1 : APIv1 self-hosted site - test-hosted : Credentials for test account on trovebox.com +For full details, see the [functional test README file](functional/README.markdown). diff --git a/tests/test_photo1.jpg b/tests/data/test_photo1.jpg similarity index 100% rename from tests/test_photo1.jpg rename to tests/data/test_photo1.jpg diff --git a/tests/test_photo2.jpg b/tests/data/test_photo2.jpg similarity index 100% rename from tests/test_photo2.jpg rename to tests/data/test_photo2.jpg diff --git a/tests/test_photo3.jpg b/tests/data/test_photo3.jpg similarity index 100% rename from tests/test_photo3.jpg rename to tests/data/test_photo3.jpg diff --git a/tests/functional/README.markdown b/tests/functional/README.markdown new file mode 100644 index 0000000..9f4f804 --- /dev/null +++ b/tests/functional/README.markdown @@ -0,0 +1,104 @@ +Functional Testing +======================= + +These functional tests check that the openphoto-python library interoperates +correctly with a real OpenPhoto/Trovebox server. + +They are slow to run, and require a stable HTTP connection to a test server. + +---------------------------------------- + +### Requirements +A computer, Python and an empty OpenPhoto/Trovebox test host. + +--------------------------------------- + +### Setting up + +Create a ``~/.config/openphoto/test`` config file containing the following: + + # ~/.config/openphoto/test + host = your.host.com + consumerKey = your_consumer_key + consumerSecret = your_consumer_secret + token = your_access_token + tokenSecret = your_access_token_secret + +Make sure this is an empty test server, **not a production OpenPhoto server!!!** + +You can specify an alternate test config file with the following environment variable: + + export OPENPHOTO_TEST_CONFIG=test2 + +--------------------------------------- + +### Running the tests + +The following instructions are for Python 2.7. You can adapt them for earlier +Python versions using the ``unittest2`` package. + + cd /path/to/openphoto-python + python -m unittest discover -c tests/functional + +The "-c" lets you stop the tests gracefully with \[CTRL\]-c. + +The easiest way to run a subset of the tests is with the ``nose`` package: + + cd /path/to/openphoto-python + nosetests -v -s --nologcapture tests/functional/test_albums.py:TestAlbums.test_view + +All HTTP requests and responses are recorded in the file ``tests.log``. + +You can enable more verbose output to stdout with the following environment variable: + + export OPENPHOTO_TEST_DEBUG=1 + +--------------------------------------- + +### Test Details + +These tests are intended to verify the openphoto-python library. +They don't provide comprehensive testing of the OpenPhoto API, +there are PHP unit tests for that. + +Each test class is run as follows: + +**SetUpClass:** + +Check that the server is empty + +**SetUp:** + +Ensure there are: + + * Three test photos + * A single test tag applied to each + * A single album containing all three photos + +**TearDownClass:** + +Remove all photos, tags and albums + +### Testing old servers + +By default, all currently supported API versions will be tested. +It's useful to test servers that only support older API versions. +To restrict the testing to a specific maximum API version, use the +``OPENPHOTO_TEST_SERVER_API`` environment variable. + +For example, to restrict testing to APIv1 and APIv2: + + export OPENPHOTO_TEST_SERVER_API=2 + + +### Full Regression Test + +The ``run_functional_tests`` script runs all functional tests against +all supported API versions. + +To use it, you must set up multiple OpenPhoto instances and create the following +config files containing your credentials: + + test : Latest self-hosted site + test-apiv1 : APIv1 self-hosted site + test-hosted : Credentials for test account on trovebox.com diff --git a/tests/api_versions/__init__.py b/tests/functional/__init__.py similarity index 100% rename from tests/api_versions/__init__.py rename to tests/functional/__init__.py diff --git a/tests/functional/api_versions/__init__.py b/tests/functional/api_versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api_versions/test_v1.py b/tests/functional/api_versions/test_v1.py similarity index 74% rename from tests/api_versions/test_v1.py rename to tests/functional/api_versions/test_v1.py index 92baabb..aa3c652 100644 --- a/tests/api_versions/test_v1.py +++ b/tests/functional/api_versions/test_v1.py @@ -1,4 +1,4 @@ -from tests import test_albums, test_photos, test_tags +from tests.functional import test_albums, test_photos, test_tags class TestAlbumsV1(test_albums.TestAlbums): api_version = 1 diff --git a/tests/api_versions/test_v2.py b/tests/functional/api_versions/test_v2.py similarity index 88% rename from tests/api_versions/test_v2.py rename to tests/functional/api_versions/test_v2.py index 545e647..a2c425c 100644 --- a/tests/api_versions/test_v2.py +++ b/tests/functional/api_versions/test_v2.py @@ -2,7 +2,7 @@ try: import unittest2 as unittest except ImportError: import unittest -from tests import test_base, test_albums, test_photos, test_tags +from tests.functional import test_base, test_albums, test_photos, test_tags @unittest.skipIf(test_base.get_test_server_api() < 2, "Don't test future API versions") diff --git a/tests/test_albums.py b/tests/functional/test_albums.py similarity index 97% rename from tests/test_albums.py rename to tests/functional/test_albums.py index c48420e..95b28ab 100644 --- a/tests/test_albums.py +++ b/tests/functional/test_albums.py @@ -1,6 +1,6 @@ -import tests.test_base +from tests.functional import test_base -class TestAlbums(tests.test_base.TestBase): +class TestAlbums(test_base.TestBase): testcase_name = "album API" def test_create_delete(self): diff --git a/tests/test_base.py b/tests/functional/test_base.py similarity index 96% rename from tests/test_base.py rename to tests/functional/test_base.py index eb05caf..2c3926e 100644 --- a/tests/test_base.py +++ b/tests/functional/test_base.py @@ -128,13 +128,13 @@ class TestBase(unittest.TestCase): """ Upload three test photos """ album = cls.client.album.create(cls.TEST_ALBUM) photos = [ - cls.client.photo.upload("tests/test_photo1.jpg", + cls.client.photo.upload("tests/data/test_photo1.jpg", title=cls.TEST_TITLE, albums=album.id), - cls.client.photo.upload("tests/test_photo2.jpg", + cls.client.photo.upload("tests/data/test_photo2.jpg", title=cls.TEST_TITLE, albums=album.id), - cls.client.photo.upload("tests/test_photo3.jpg", + cls.client.photo.upload("tests/data/test_photo3.jpg", title=cls.TEST_TITLE, albums=album.id), ] diff --git a/tests/test_framework.py b/tests/functional/test_framework.py similarity index 93% rename from tests/test_framework.py rename to tests/functional/test_framework.py index 6d34f73..788ac13 100644 --- a/tests/test_framework.py +++ b/tests/functional/test_framework.py @@ -1,9 +1,9 @@ import logging import openphoto -import tests.test_base +from tests.functional import test_base -class TestFramework(tests.test_base.TestBase): +class TestFramework(test_base.TestBase): testcase_name = "framework" def setUp(self): @@ -27,7 +27,7 @@ class TestFramework(tests.test_base.TestBase): """ For all API versions >0, we get a generic hello world message """ - for api_version in range(1, tests.test_base.get_test_server_api() + 1): + for api_version in range(1, test_base.get_test_server_api() + 1): client = openphoto.OpenPhoto(config_file=self.config_file, api_version=api_version) result = client.get("hello.json") diff --git a/tests/test_photos.py b/tests/functional/test_photos.py similarity index 94% rename from tests/test_photos.py rename to tests/functional/test_photos.py index d0029a4..c6c9b57 100644 --- a/tests/test_photos.py +++ b/tests/functional/test_photos.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals import openphoto -import tests.test_base +from tests.functional import test_base -class TestPhotos(tests.test_base.TestBase): +class TestPhotos(test_base.TestBase): testcase_name = "photo API" def test_delete_upload(self): @@ -19,11 +19,11 @@ class TestPhotos(tests.test_base.TestBase): self.assertEqual(self.client.photos.list(), []) # Re-upload the photos, one of them using Bas64 encoding - ret_val = self.client.photo.upload("tests/test_photo1.jpg", + ret_val = self.client.photo.upload("tests/data/test_photo1.jpg", title=self.TEST_TITLE) - self.client.photo.upload("tests/test_photo2.jpg", + self.client.photo.upload("tests/data/test_photo2.jpg", title=self.TEST_TITLE) - self.client.photo.upload_encoded("tests/test_photo3.jpg", + self.client.photo.upload_encoded("tests/data/test_photo3.jpg", title=self.TEST_TITLE) # Check there are now three photos with the correct titles @@ -61,7 +61,7 @@ class TestPhotos(tests.test_base.TestBase): """ Ensure that duplicate photos are rejected """ # Attempt to upload a duplicate with self.assertRaises(openphoto.OpenPhotoDuplicateError): - self.client.photo.upload("tests/test_photo1.jpg", + self.client.photo.upload("tests/data/test_photo1.jpg", title=self.TEST_TITLE) # Check there are still three photos diff --git a/tests/test_tags.py b/tests/functional/test_tags.py similarity index 96% rename from tests/test_tags.py rename to tests/functional/test_tags.py index 40a577e..14ed6c0 100644 --- a/tests/test_tags.py +++ b/tests/functional/test_tags.py @@ -3,11 +3,11 @@ try: except ImportError: import unittest -import tests.test_base +from tests.functional import test_base -@unittest.skipIf(tests.test_base.get_test_server_api() == 1, +@unittest.skipIf(test_base.get_test_server_api() == 1, "The tag API didn't work at v1 - see frontend issue #927") -class TestTags(tests.test_base.TestBase): +class TestTags(test_base.TestBase): testcase_name = "tag API" def test_create_delete(self, tag_id="create_tag"): diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/data/test_file.txt b/tests/unit/data/test_file.txt new file mode 100644 index 0000000..4fff881 --- /dev/null +++ b/tests/unit/data/test_file.txt @@ -0,0 +1 @@ +Test File diff --git a/tests/unit/test_albums.py b/tests/unit/test_albums.py new file mode 100644 index 0000000..a117bd5 --- /dev/null +++ b/tests/unit/test_albums.py @@ -0,0 +1,262 @@ +from __future__ import unicode_literals +import mock +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import openphoto + +class TestAlbums(unittest.TestCase): + test_host = "test.example.com" + test_albums_dict = [{"cover": {"id": "1a", "tags": ["tag1", "tag2"]}, + "id": "1", + "name": "Album 1", + "totalRows": 2}, + {"cover": {"id": "2b", "tags": ["tag3", "tag4"]}, + "id": "2", + "name": "Album 2", + "totalRows": 2}] + def setUp(self): + self.client = openphoto.OpenPhoto(host=self.test_host) + self.test_albums = [openphoto.objects.Album(self.client, album) + for album in self.test_albums_dict] + + @staticmethod + def _return_value(result, message="", code=200): + return {"message": message, "code": code, "result": result} + +class TestAlbumsList(TestAlbums): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_albums_list(self, mock_get): + """Check that the album list is returned correctly""" + mock_get.return_value = self._return_value(self.test_albums_dict) + result = self.client.albums.list() + mock_get.assert_called_with("/albums/list.json") + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, "1") + self.assertEqual(result[0].name, "Album 1") + self.assertEqual(result[1].id, "2") + self.assertEqual(result[1].name, "Album 2") + + # TODO: cover should be updated to Photo object + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_albums_list_returns_cover_photos(self, mock_get): + """Check that the album list returns cover photo objects""" + mock_get.return_value = self._return_value(self.test_albums_dict) + result = self.client.albums.list() + mock_get.assert_called_with("/albums/list.json") + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, "1") + self.assertEqual(result[0].name, "Album 1") + self.assertEqual(result[0].cover.id, "1a") + self.assertEqual(result[0].cover.tags, ["tag1", "tag2"]) + self.assertEqual(result[1].id, "2") + self.assertEqual(result[0].name, "Album 2") + self.assertEqual(result[1].cover.id, "2b") + self.assertEqual(result[1].cover.tags, ["tag3", "tag4"]) + +class TestAlbumCreate(TestAlbums): + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_create(self, mock_post): + """Check that an album can be created""" + mock_post.return_value = self._return_value(self.test_albums_dict[0]) + result = self.client.album.create(name="Test", foo="bar") + mock_post.assert_called_with("/album/create.json", name="Test", + foo="bar") + self.assertEqual(result.id, "1") + self.assertEqual(result.name, "Album 1") + # self.assertEqual(result.cover.id, "1a") + # self.assertEqual(result.cover.tags, ["tag1", "tag2"]) + +class TestAlbumDelete(TestAlbums): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_delete(self, mock_post): + """Check that an album can be deleted""" + mock_post.return_value = self._return_value(True) + result = self.client.album.delete(self.test_albums[0]) + mock_post.assert_called_with("/album/1/delete.json") + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_delete_id(self, mock_post): + """Check that an album can be deleted using its ID""" + mock_post.return_value = self._return_value(True) + result = self.client.album.delete("1") + mock_post.assert_called_with("/album/1/delete.json") + self.assertEqual(result, True) + + # TODO: album.delete should raise exception on failure + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_delete_failure(self, mock_post): + """Check that an exception is raised if an album cannot be deleted""" + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.album.delete(self.test_albums[0]) + + # TODO: after deleting object fields, name and id should be set to None + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_object_delete(self, mock_post): + """Check that an album can be deleted using the album object directly""" + mock_post.return_value = self._return_value(True) + album = self.test_albums[0] + result = album.delete() + mock_post.assert_called_with("/album/1/delete.json") + self.assertEqual(result, True) + self.assertEqual(album.get_fields(), {}) + # self.assertEqual(album.id, None) + # self.assertEqual(album.name, None) + + # TODO: album.delete should raise exception on failure + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_object_delete_failure(self, mock_post): + """ + Check that an exception is raised if an album cannot be deleted + when using the album object directly + """ + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.test_albums[0].delete() + +class TestAlbumForm(TestAlbums): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_form(self, _): + """ If album.form gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.album.form(self.test_albums[0]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_form_id(self, _): + """ If album.form gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.album.form("1") + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_object_form(self, _): + """ If album.form gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.test_albums[0].form() + +class TestAlbumAddPhotos(TestAlbums): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_add_photos(self, _): + """ If album.add_photos gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.album.add_photos(self.test_albums[0], ["Photo Objects"]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_add_photos_id(self, _): + """ If album.add_photos gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.album.add_photos("1", ["Photo Objects"]) + + # TODO: object.add_photos should accept photos list as first parameter + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_object_add_photos(self, _): + """ If album.add_photos gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.test_albums[0].add_photos(["Photo Objects"]) + +class TestAlbumRemovePhotos(TestAlbums): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_remove_photos(self, _): + """ If album.remove_photos gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.album.remove_photos(self.test_albums[0], + ["Photo Objects"]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_remove_photos_id(self, _): + """ If album.remove_photos gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.album.remove_photos("1", ["Photo Objects"]) + + # TODO: object.remove_photos should accept photos list as first parameter + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_object_remove_photos(self, _): + """ If album.remove_photos gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.test_albums[0].remove_photos(["Photo Objects"]) + +class TestAlbumUpdate(TestAlbums): + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_update(self, mock_post): + """Check that an album can be updated""" + mock_post.return_value = self._return_value(self.test_albums_dict[1]) + result = self.client.album.update(self.test_albums[0], name="Test") + mock_post.assert_called_with("/album/1/update.json", name="Test") + self.assertEqual(result.id, "2") + self.assertEqual(result.name, "Album 2") + # self.assertEqual(result.cover.id, "2b") + # self.assertEqual(result.cover.tags, ["tag3", "tag4"]) + + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_update_id(self, mock_post): + """Check that an album can be updated using its ID""" + mock_post.return_value = self._return_value(self.test_albums_dict[1]) + result = self.client.album.update("1", name="Test") + mock_post.assert_called_with("/album/1/update.json", name="Test") + self.assertEqual(result.id, "2") + self.assertEqual(result.name, "Album 2") + # self.assertEqual(result.cover.id, "2b") + # self.assertEqual(result.cover.tags, ["tag3", "tag4"]) + + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_album_object_update(self, mock_post): + """Check that an album can be updated using the album object directly""" + mock_post.return_value = self._return_value(self.test_albums_dict[1]) + album = self.test_albums[0] + album.update(name="Test") + mock_post.assert_called_with("/album/1/update.json", name="Test") + self.assertEqual(album.id, "2") + self.assertEqual(album.name, "Album 2") + # self.assertEqual(album.cover.id, "2b") + # self.assertEqual(album.cover.tags, ["tag3", "tag4"]) + +class TestAlbumView(TestAlbums): + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_album_view(self, mock_get): + """Check that an album can be viewed""" + mock_get.return_value = self._return_value(self.test_albums_dict[1]) + result = self.client.album.view(self.test_albums[0], name="Test") + mock_get.assert_called_with("/album/1/view.json", name="Test") + self.assertEqual(result.id, "2") + self.assertEqual(result.name, "Album 2") + # self.assertEqual(result.cover.id, "2b") + # self.assertEqual(result.cover.tags, ["tag3", "tag4"]) + + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_album_view_id(self, mock_get): + """Check that an album can be viewed using its ID""" + mock_get.return_value = self._return_value(self.test_albums_dict[1]) + result = self.client.album.view("1", name="Test") + mock_get.assert_called_with("/album/1/view.json", name="Test") + self.assertEqual(result.id, "2") + self.assertEqual(result.name, "Album 2") + # self.assertEqual(result.cover.id, "2b") + # self.assertEqual(result.cover.tags, ["tag3", "tag4"]) + + # TODO: cover should be updated to Photo object + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_album_object_view(self, mock_get): + """Check that an album can be viewed using the album object directly""" + mock_get.return_value = self._return_value(self.test_albums_dict[1]) + album = self.test_albums[0] + album.view(name="Test") + mock_get.assert_called_with("/album/1/view.json", name="Test") + self.assertEqual(album.id, "2") + self.assertEqual(album.name, "Album 2") + # self.assertEqual(album.cover.id, "2b") + # self.assertEqual(album.cover.tags, ["tag3", "tag4"]) + diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..c33c47b --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,122 @@ +from __future__ import unicode_literals +import os +import sys +import mock +try: + import StringIO as io # Python2 +except ImportError: + import io # Python3 +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import openphoto +from openphoto.main import main + +class TestException(Exception): + pass + +def raise_exception(_): + raise TestException() + +class TestCli(unittest.TestCase): + test_file = os.path.join("tests", "unit", "data", "test_file.txt") + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_defaults(self, _, mock_openphoto): + """Check that the default behaviour is correct""" + get = mock_openphoto.return_value.get + main([]) + mock_openphoto.assert_called_with(config_file=None) + get.assert_called_with("/photos/list.json", process_response=False) + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_config(self, _, mock_openphoto): + """Check that a config file can be specified""" + main(["--config=test"]) + mock_openphoto.assert_called_with(config_file="test") + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_get(self, mock_stdout, mock_openphoto): + """Check that the get operation is working""" + get = mock_openphoto.return_value.get + get.return_value = "Result" + main(["-X", "GET", "-h", "test_host", "-e", "test_endpoint", "-F", + "field1=1", "-F", "field2=2"]) + mock_openphoto.assert_called_with(host="test_host") + get.assert_called_with("test_endpoint", field1="1", field2="2", + process_response=False) + self.assertEqual(mock_stdout.getvalue(), "Result\n") + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_post(self, mock_stdout, mock_openphoto): + """Check that the post operation is working""" + post = mock_openphoto.return_value.post + post.return_value = "Result" + main(["-X", "POST", "-h", "test_host", "-e", "test_endpoint", "-F", + "field1=1", "-F", "field2=2"]) + mock_openphoto.assert_called_with(host="test_host") + post.assert_called_with("test_endpoint", field1="1", field2="2", + files={}, process_response=False) + self.assertEqual(mock_stdout.getvalue(), "Result\n") + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_post_files(self, _, mock_openphoto): + """Check that files are posted correctly""" + post = mock_openphoto.return_value.post + main(["-X", "POST", "-F", "photo=@%s" % self.test_file]) + # It's not possible to directly compare the file object, + # so check it manually + files = post.call_args[1]["files"] + self.assertEqual(list(files.keys()), ["photo"]) + self.assertEqual(files["photo"].name, self.test_file) + + @mock.patch.object(sys, "exit", raise_exception) + @mock.patch('sys.stderr', new_callable=io.StringIO) + def test_unknown_arg(self, mock_stderr): + """Check that an unknown argument produces an error""" + with self.assertRaises(TestException): + main(["hello"]) + self.assertIn("error: Unknown argument", mock_stderr.getvalue()) + + @mock.patch.object(sys, "exit", raise_exception) + @mock.patch('sys.stderr', new_callable=io.StringIO) + def test_unknown_option(self, mock_stderr): + """Check that an unknown option produces an error""" + with self.assertRaises(TestException): + main(["--hello"]) + self.assertIn("error: no such option", mock_stderr.getvalue()) + + @mock.patch.object(sys, "exit", raise_exception) + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_unknown_config(self, mock_stdout): + """Check that an unknown config file produces an error""" + with self.assertRaises(TestException): + main(["--config=this_config_doesnt_exist"]) + self.assertIn("No such file or directory", mock_stdout.getvalue()) + self.assertIn("You must create a configuration file", + mock_stdout.getvalue()) + self.assertIn("To get your credentials", mock_stdout.getvalue()) + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_verbose(self, mock_stdout, _): + """Check that the verbose option is working""" + main(["-v"]) + self.assertIn("Method: GET", mock_stdout.getvalue()) + self.assertIn("Endpoint: /photos/list.json", mock_stdout.getvalue()) + + @mock.patch.object(openphoto.main, "OpenPhoto") + @mock.patch('sys.stdout', new_callable=io.StringIO) + def test_pretty_print(self, mock_stdout, mock_openphoto): + """Check that the pretty-print option is working""" + get = mock_openphoto.return_value.get + get.return_value = '{"test":1}' + main(["-p"]) + self.assertEqual(mock_stdout.getvalue(), '{\n "test":1\n}\n') diff --git a/tests/test_config.py b/tests/unit/test_config.py similarity index 97% rename from tests/test_config.py rename to tests/unit/test_config.py index 6e37ab2..9016e20 100644 --- a/tests/test_config.py +++ b/tests/unit/test_config.py @@ -29,11 +29,12 @@ class TestConfig(unittest.TestCase): @staticmethod def create_config(config_file, host): + """Create a dummy config file""" with open(os.path.join(CONFIG_PATH, config_file), "w") as conf: conf.write("host = %s\n" % host) conf.write("# Comment\n\n") conf.write("consumerKey = \"%s_consumer_key\"\n" % config_file) - conf.write("\"consumerSecret\" = %s_consumer_secret\n" % config_file) + conf.write("\"consumerSecret\"= %s_consumer_secret\n" % config_file) conf.write("'token'=%s_token\n" % config_file) conf.write("tokenSecret = '%s_token_secret'\n" % config_file) diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py new file mode 100644 index 0000000..899d24f --- /dev/null +++ b/tests/unit/test_http.py @@ -0,0 +1,173 @@ +from __future__ import unicode_literals +import os +import json +import httpretty +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import openphoto + +class TestHttp(unittest.TestCase): + test_host = "test.example.com" + test_endpoint = "test.json" + test_uri = "http://%s/%s" % (test_host, test_endpoint) + test_data = {"message": "Test Message", + "code": 200, + "result": "Test Result"} + test_oauth = {"consumer_key": "dummy", + "consumer_secret": "dummy", + "token": "dummy", + "token_secret": "dummy"} + test_file = os.path.join("tests", "unit", "data", "test_file.txt") + + + def setUp(self): + self.client = openphoto.OpenPhoto(host=self.test_host, + **self.test_oauth) + + def _register_uri(self, method, uri=test_uri, data=None, body=None, + **kwds): + """Convenience wrapper around httpretty.register_uri""" + if data is None: + data = self.test_data + if body is None: + body = json.dumps(data) + httpretty.register_uri(method, uri=uri, body=body, **kwds) + + @staticmethod + def _last_request(): + """This is a temporary measure until httpretty PR#59 is merged""" + return httpretty.httpretty.last_request + + def test_attributes(self): + """Check that the host attribute has been set correctly""" + self.assertEqual(self.client.host, self.test_host) + self.assertEqual(self.client.config.host, self.test_host) + + @httpretty.activate + def test_get_with_parameters(self): + """Check that the get method accepts parameters correctly""" + self._register_uri(httpretty.GET) + response = self.client.get(self.test_endpoint, + foo="bar", spam="eggs") + self.assertIn("OAuth", self._last_request().headers["authorization"]) + self.assertEqual(self._last_request().querystring["foo"], ["bar"]) + self.assertEqual(self._last_request().querystring["spam"], ["eggs"]) + self.assertEqual(response, self.test_data) + self.assertEqual(self.client.last_url, self.test_uri) + self.assertEqual(self.client.last_params, {"foo": b"bar", + "spam": b"eggs"}) + self.assertEqual(self.client.last_response.json(), self.test_data) + + @httpretty.activate + def test_post_with_parameters(self): + """Check that the post method accepts parameters correctly""" + self._register_uri(httpretty.POST) + response = self.client.post(self.test_endpoint, + foo="bar", spam="eggs") + self.assertIn(b"spam=eggs", self._last_request().body) + self.assertIn(b"foo=bar", self._last_request().body) + self.assertEqual(response, self.test_data) + self.assertEqual(self.client.last_url, self.test_uri) + self.assertEqual(self.client.last_params, {"foo": b"bar", + "spam": b"eggs"}) + self.assertEqual(self.client.last_response.json(), self.test_data) + + @httpretty.activate + def test_get_without_oauth(self): + """Check that the get method works without OAuth parameters""" + self.client = openphoto.OpenPhoto(host=self.test_host) + self._register_uri(httpretty.GET) + response = self.client.get(self.test_endpoint) + self.assertNotIn("authorization", self._last_request().headers) + self.assertEqual(response, self.test_data) + + @httpretty.activate + def test_post_without_oauth(self): + """Check that the post method fails without OAuth parameters""" + self.client = openphoto.OpenPhoto(host=self.test_host) + self._register_uri(httpretty.POST) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.post(self.test_endpoint) + + @httpretty.activate + def test_get_without_response_processing(self): + """Check that the get method works with response processing disabled""" + self._register_uri(httpretty.GET) + response = self.client.get(self.test_endpoint, process_response=False) + self.assertEqual(response, json.dumps(self.test_data)) + + @httpretty.activate + def test_post_without_response_processing(self): + """Check that the post method works with response processing disabled""" + self._register_uri(httpretty.POST) + response = self.client.post(self.test_endpoint, process_response=False) + self.assertEqual(response, json.dumps(self.test_data)) + + @httpretty.activate + def test_get_parameter_processing(self): + """Check that the parameter processing function is working""" + self._register_uri(httpretty.GET) + photo = openphoto.objects.Photo(None, {"id": "photo_id"}) + album = openphoto.objects.Album(None, {"id": "album_id"}) + tag = openphoto.objects.Tag(None, {"id": "tag_id"}) + self.client.get(self.test_endpoint, + photo=photo, album=album, tag=tag, + list_=[photo, album, tag], + boolean=True, + unicode_="\xfcmlaut") + params = self._last_request().querystring + self.assertEqual(params["photo"], ["photo_id"]) + self.assertEqual(params["album"], ["album_id"]) + self.assertEqual(params["tag"], ["tag_id"]) + self.assertEqual(params["list_"], ["photo_id,album_id,tag_id"]) + self.assertEqual(params["boolean"], ["1"]) + self.assertIn(params["unicode_"], [["\xc3\xbcmlaut"], ["\xfcmlaut"]]) + + @httpretty.activate + def test_get_with_api_version(self): + """Check that an API version can be specified for the get method""" + self.client = openphoto.OpenPhoto(host=self.test_host, api_version=1) + self._register_uri(httpretty.GET, + uri="http://%s/v1/%s" % (self.test_host, + self.test_endpoint)) + self.client.get(self.test_endpoint) + + @httpretty.activate + def test_post_with_api_version(self): + """Check that an API version can be specified for the post method""" + self.client = openphoto.OpenPhoto(host=self.test_host, api_version=1, + **self.test_oauth) + self._register_uri(httpretty.POST, + uri="http://%s/v1/%s" % (self.test_host, + self.test_endpoint)) + self.client.post(self.test_endpoint) + + @httpretty.activate + def test_post_file(self): + """Check that a file can be posted""" + self._register_uri(httpretty.POST) + with open(self.test_file, 'rb') as in_file: + response = self.client.post(self.test_endpoint, + files={"file": in_file}) + self.assertEqual(response, self.test_data) + body = str(self._last_request().body) + self.assertIn("Content-Disposition: form-data; "+ + "name=\"file\"; filename=\"test_file.txt\"", body) + self.assertIn("Test File", str(body)) + + + @httpretty.activate + def test_post_file_parameters_are_sent_as_querystring(self): + """ + Check that parameters are send as a query string + when a file is posted + """ + self._register_uri(httpretty.POST) + with open(self.test_file, 'rb') as in_file: + response = self.client.post(self.test_endpoint, foo="bar", + files={"file": in_file}) + self.assertEqual(response, self.test_data) + self.assertEqual(self._last_request().querystring["foo"], ["bar"]) diff --git a/tests/unit/test_http_errors.py b/tests/unit/test_http_errors.py new file mode 100644 index 0000000..d95a838 --- /dev/null +++ b/tests/unit/test_http_errors.py @@ -0,0 +1,195 @@ +from __future__ import unicode_literals +import json +import httpretty + +# TEMP: Temporary hack until httpretty string checking is fixed +if httpretty.compat.PY3: + httpretty.core.basestring = (bytes, str) + +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import openphoto + +class TestHttpErrors(unittest.TestCase): + test_host = "test.example.com" + test_endpoint = "test.json" + test_uri = "http://%s/%s" % (test_host, test_endpoint) + test_data = {"message": "Test Message", + "code": 200, + "result": "Test Result"} + test_oauth = {"consumer_key": "dummy", + "consumer_secret": "dummy", + "token": "dummy", + "token_secret": "dummy"} + + def setUp(self): + self.client = openphoto.OpenPhoto(host=self.test_host, + **self.test_oauth) + + def _register_uri(self, method, uri=test_uri, + data=None, body=None, status=200, **kwds): + """Convenience wrapper around httpretty.register_uri""" + if data is None: + data = self.test_data + # Set the JSON return code to match the HTTP status + data["code"] = status + if body is None: + body = json.dumps(data) + httpretty.register_uri(method, uri=uri, body=body, status=status, + **kwds) + + @httpretty.activate + def test_get_with_error_status(self): + """ + Check that an error status causes the get method + to raise an exception + """ + self._register_uri(httpretty.GET, status=500) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.get(self.test_endpoint) + + @httpretty.activate + def test_post_with_error_status(self): + """ + Check that an error status causes the post method + to raise an exception + """ + self._register_uri(httpretty.POST, status=500) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.post(self.test_endpoint) + + # TODO: 404 status should raise 404 error, even if JSON is valid + @unittest.expectedFailure + @httpretty.activate + def test_get_with_404_status(self): + """ + Check that a 404 status causes the get method + to raise a 404 exception + """ + self._register_uri(httpretty.GET, status=404) + with self.assertRaises(openphoto.OpenPhoto404Error): + self.client.get(self.test_endpoint) + + # TODO: 404 status should raise 404 error, even if JSON is valid + @unittest.expectedFailure + @httpretty.activate + def test_post_with_404_status(self): + """ + Check that a 404 status causes the post method + to raise a 404 exception + """ + self._register_uri(httpretty.POST, status=404) + with self.assertRaises(openphoto.OpenPhoto404Error): + self.client.post(self.test_endpoint) + + @httpretty.activate + def test_get_with_invalid_json(self): + """ + Check that invalid JSON causes the get method to + raise an exception + """ + self._register_uri(httpretty.GET, body="Invalid JSON") + with self.assertRaises(ValueError): + self.client.get(self.test_endpoint) + + @httpretty.activate + def test_post_with_invalid_json(self): + """ + Check that invalid JSON causes the post method to + raise an exception + """ + self._register_uri(httpretty.POST, body="Invalid JSON") + with self.assertRaises(ValueError): + self.client.post(self.test_endpoint) + + @httpretty.activate + def test_get_with_error_status_and_invalid_json(self): + """ + Check that invalid JSON causes the get method to raise an exception, + even with an error status is returned + """ + self._register_uri(httpretty.GET, body="Invalid JSON", status=500) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.get(self.test_endpoint) + + @httpretty.activate + def test_post_with_error_status_and_invalid_json(self): + """ + Check that invalid JSON causes the post method to raise an exception, + even with an error status is returned + """ + self._register_uri(httpretty.POST, body="Invalid JSON", status=500) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.post(self.test_endpoint) + + @httpretty.activate + def test_get_with_404_status_and_invalid_json(self): + """ + Check that invalid JSON causes the get method to raise an exception, + even with a 404 status is returned + """ + self._register_uri(httpretty.GET, body="Invalid JSON", status=404) + with self.assertRaises(openphoto.OpenPhoto404Error): + self.client.get(self.test_endpoint) + + @httpretty.activate + def test_post_with_404_status_and_invalid_json(self): + """ + Check that invalid JSON causes the post method to raise an exception, + even with a 404 status is returned + """ + self._register_uri(httpretty.POST, body="Invalid JSON", status=404) + with self.assertRaises(openphoto.OpenPhoto404Error): + self.client.post(self.test_endpoint) + + @httpretty.activate + def test_get_with_duplicate_status(self): + """ + Check that a get with a duplicate status + raises a duplicate exception + """ + data = {"message": "This photo already exists", "code": 409} + self._register_uri(httpretty.GET, data=data, status=409) + with self.assertRaises(openphoto.OpenPhotoDuplicateError): + self.client.get(self.test_endpoint) + + @httpretty.activate + def test_post_with_duplicate_status(self): + """ + Check that a post with a duplicate status + raises a duplicate exception + """ + data = {"message": "This photo already exists", "code": 409} + self._register_uri(httpretty.POST, data=data, status=409) + with self.assertRaises(openphoto.OpenPhotoDuplicateError): + self.client.post(self.test_endpoint) + + # TODO: Status code mismatch should raise an exception + @unittest.expectedFailure + @httpretty.activate + def test_get_with_status_code_mismatch(self): + """ + Check that an exception is raised if a get returns a + status code that doesn't match the JSON code + """ + data = {"message": "Test Message", "code": 200} + self._register_uri(httpretty.GET, data=data, status=202) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.get(self.test_endpoint) + + # TODO: Status code mismatch should raise an exception + @unittest.expectedFailure + @httpretty.activate + def test_post_with_status_code_mismatch(self): + """ + Check that an exception is raised if a post returns a + status code that doesn't match the JSON code + """ + data = {"message": "Test Message", "code": 200} + self._register_uri(httpretty.POST, data=data, status=202) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.post(self.test_endpoint) + diff --git a/tests/unit/test_photos.py b/tests/unit/test_photos.py new file mode 100644 index 0000000..bf783fe --- /dev/null +++ b/tests/unit/test_photos.py @@ -0,0 +1,441 @@ +from __future__ import unicode_literals +import os +import base64 +import mock +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import openphoto + +class TestPhotos(unittest.TestCase): + test_host = "test.example.com" + test_file = os.path.join("tests", "unit", "data", "test_file.txt") + test_photos_dict = [{"id": "1a", "tags": ["tag1", "tag2"], + "totalPages": 1, "totalRows": 2}, + {"id": "2b", "tags": ["tag3", "tag4"], + "totalPages": 1, "totalRows": 2}] + def setUp(self): + self.client = openphoto.OpenPhoto(host=self.test_host) + self.test_photos = [openphoto.objects.Photo(self.client, photo) + for photo in self.test_photos_dict] + + @staticmethod + def _return_value(result, message="", code=200): + return {"message": message, "code": code, "result": result} + +class TestPhotosList(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photos_list(self, mock_get): + """Check that the photo list is returned correctly""" + mock_get.return_value = self._return_value(self.test_photos_dict) + + result = self.client.photos.list() + mock_get.assert_called_with("/photos/list.json") + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, "1a") + self.assertEqual(result[0].tags, ["tag1", "tag2"]) + self.assertEqual(result[1].id, "2b") + self.assertEqual(result[1].tags, ["tag3", "tag4"]) + +class TestPhotosUpdate(TestPhotos): + # TODO: photos.update should accept a list of Photo objects + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photos_update(self, mock_post): + """Check that multiple photos can be updated""" + mock_post.return_value = self._return_value(True) + result = self.client.photos.update(self.test_photos, title="Test") + mock_post.assert_called_with("/photos/update.json", + ids=["1a", "2b"], title="Test") + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photos_update_ids(self, mock_post): + """Check that multiple photos can be updated using their IDs""" + mock_post.return_value = self._return_value(True) + result = self.client.photos.update(["1a", "2b"], title="Test") + mock_post.assert_called_with("/photos/update.json", + ids=["1a", "2b"], title="Test") + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photos_update_failure(self, mock_post): + """ + Check that an exception is raised if multiple photos + cannot be updated + """ + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.photos.update(self.test_photos, title="Test") + +class TestPhotosDelete(TestPhotos): + # TODO: photos.delete should accept a list of Photo objects + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photos_delete(self, mock_post): + """Check that multiple photos can be deleted""" + mock_post.return_value = self._return_value(True) + result = self.client.photos.delete(self.test_photos) + mock_post.assert_called_with("/photos/delete.json", ids=["1a", "2b"]) + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photos_delete_ids(self, mock_post): + """Check that multiple photos can be deleted using their IDs""" + mock_post.return_value = self._return_value(True) + result = self.client.photos.delete(["1a", "2b"]) + mock_post.assert_called_with("/photos/delete.json", ids=["1a", "2b"]) + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photos_delete_failure(self, mock_post): + """ + Check that an exception is raised if multiple photos + cannot be deleted + """ + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.photos.delete(self.test_photos) + +class TestPhotoDelete(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_delete(self, mock_post): + """Check that a photo can be deleted""" + mock_post.return_value = self._return_value(True) + result = self.client.photo.delete(self.test_photos[0]) + mock_post.assert_called_with("/photo/1a/delete.json") + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_delete_id(self, mock_post): + """Check that a photo can be deleted using its ID""" + mock_post.return_value = self._return_value(True) + result = self.client.photo.delete("1a") + mock_post.assert_called_with("/photo/1a/delete.json") + self.assertEqual(result, True) + + # TODO: photo.delete should raise exception on failure + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_delete_failure(self, mock_post): + """Check that an exception is raised if a photo cannot be deleted""" + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.photo.delete(self.test_photos[0]) + + # TODO: after deleting object fields, name and id should be set to None + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_object_delete(self, mock_post): + """ + Check that a photo can be deleted when using + the photo object directly + """ + mock_post.return_value = self._return_value(True) + photo = self.test_photos[0] + result = photo.delete() + mock_post.assert_called_with("/photo/1a/delete.json") + self.assertEqual(result, True) + self.assertEqual(photo.get_fields(), {}) + # self.assertEqual(photo.id, None) + + # TODO: photo.delete should raise exception on failure + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_object_delete_failure(self, mock_post): + """ + Check that an exception is raised if a photo cannot be deleted + when using the photo object directly + """ + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.test_photos[0].delete() + +class TestPhotoEdit(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_edit(self, mock_get): + """Check that a the photo edit endpoint is working""" + mock_get.return_value = self._return_value({"markup": "
"}) + result = self.client.photo.edit(self.test_photos[0]) + mock_get.assert_called_with("/photo/1a/edit.json") + self.assertEqual(result, "") + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_edit_id(self, mock_get): + """Check that a the photo edit endpoint is working when using an ID""" + mock_get.return_value = self._return_value({"markup": ""}) + result = self.client.photo.edit("1a") + mock_get.assert_called_with("/photo/1a/edit.json") + self.assertEqual(result, "") + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_object_edit(self, mock_get): + """ + Check that a the photo edit endpoint is working + when using the photo object directly + """ + mock_get.return_value = self._return_value({"markup": ""}) + result = self.test_photos[0].edit() + mock_get.assert_called_with("/photo/1a/edit.json") + self.assertEqual(result, "") + +class TestPhotoReplace(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_replace(self, _): + """ If photo.replace gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.photo.replace(self.test_photos[0], self.test_file) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_replace_id(self, _): + """ If photo.replace gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.photo.replace("1a", self.test_file) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_object_replace(self, _): + """ If photo.replace gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.test_photos[0].replace(self.test_file) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_replace_encoded(self, _): + """ If photo.replace_encoded gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.photo.replace_encoded(self.test_photos[0], + self.test_file) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_replace_encoded_id(self, _): + """ If photo.replace_encoded gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.photo.replace_encoded("1a", self.test_file) + + # TODO: replace_encoded parameter should be called photo_file, + # not encoded_photo + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_object_replace_encoded(self, _): + """ If photo.replace_encoded gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.test_photos[0].replace_encoded(photo_file=self.test_file) + +class TestPhotoUpdate(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_update(self, mock_post): + """Check that a photo can be updated""" + mock_post.return_value = self._return_value(self.test_photos_dict[1]) + result = self.client.photo.update(self.test_photos[0], title="Test") + mock_post.assert_called_with("/photo/1a/update.json", title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_update_id(self, mock_post): + """Check that a photo can be updated using its ID""" + mock_post.return_value = self._return_value(self.test_photos_dict[1]) + result = self.client.photo.update("1a", title="Test") + mock_post.assert_called_with("/photo/1a/update.json", title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_object_update(self, mock_post): + """ + Check that a photo can be updated + when using the photo object directly + """ + mock_post.return_value = self._return_value(self.test_photos_dict[1]) + photo = self.test_photos[0] + photo.update(title="Test") + mock_post.assert_called_with("/photo/1a/update.json", title="Test") + self.assertEqual(photo.get_fields(), self.test_photos_dict[1]) + +class TestPhotoView(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_view(self, mock_get): + """Check that a photo can be viewed""" + mock_get.return_value = self._return_value(self.test_photos_dict[1]) + result = self.client.photo.view(self.test_photos[0], + returnSizes="20x20") + mock_get.assert_called_with("/photo/1a/view.json", returnSizes="20x20") + self.assertEqual(result.get_fields(), self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_view_id(self, mock_get): + """Check that a photo can be viewed using its ID""" + mock_get.return_value = self._return_value(self.test_photos_dict[1]) + result = self.client.photo.view("1a", returnSizes="20x20") + mock_get.assert_called_with("/photo/1a/view.json", returnSizes="20x20") + self.assertEqual(result.get_fields(), self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_object_view(self, mock_get): + """ + Check that a photo can be viewed + when using the photo object directly + """ + mock_get.return_value = self._return_value(self.test_photos_dict[1]) + photo = self.test_photos[0] + photo.view(returnSizes="20x20") + mock_get.assert_called_with("/photo/1a/view.json", returnSizes="20x20") + self.assertEqual(photo.get_fields(), self.test_photos_dict[1]) + +class TestPhotoUpload(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_upload(self, mock_post): + """Check that a photo can be uploaded""" + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.upload(self.test_file, title="Test") + # It's not possible to compare the file object, + # so check each parameter individually + endpoint = mock_post.call_args[0] + title = mock_post.call_args[1]["title"] + files = mock_post.call_args[1]["files"] + self.assertEqual(endpoint, ("/photo/upload.json",)) + self.assertEqual(title, "Test") + self.assertIn("photo", files) + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_upload_encoded(self, mock_post): + """Check that a photo can be uploaded using Base64 encoding""" + mock_post.return_value = self._return_value(self.test_photos_dict[0]) + result = self.client.photo.upload_encoded(self.test_file, title="Test") + with open(self.test_file, "rb") as in_file: + encoded_file = base64.b64encode(in_file.read()) + mock_post.assert_called_with("/photo/upload.json", + photo=encoded_file, title="Test") + self.assertEqual(result.get_fields(), self.test_photos_dict[0]) + +class TestPhotoDynamicUrl(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_dynamic_url(self, _): + """ If photo.dynamic_url gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.photo.dynamic_url(self.test_photos[0]) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_dynamic_url_id(self, _): + """ If photo.dynamic_url gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.client.photo.dynamic_url("1a") + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_object_dynamic_url(self, _): + """ If photo.dynamic_url gets implemented, write a test! """ + with self.assertRaises(NotImplementedError): + self.test_photos[0].dynamic_url() + +class TestPhotoNextPrevious(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_next_previous(self, mock_get): + """Check that the next/previous photos are returned""" + mock_get.return_value = self._return_value( + {"next": [self.test_photos_dict[0]], + "previous": [self.test_photos_dict[1]]}) + result = self.client.photo.next_previous(self.test_photos[0]) + mock_get.assert_called_with("/photo/1a/nextprevious.json") + self.assertEqual(result["next"][0].get_fields(), + self.test_photos_dict[0]) + self.assertEqual(result["previous"][0].get_fields(), + self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_next_previous_id(self, mock_get): + """ + Check that the next/previous photos are returned + when using the photo ID + """ + mock_get.return_value = self._return_value( + {"next": [self.test_photos_dict[0]], + "previous": [self.test_photos_dict[1]]}) + result = self.client.photo.next_previous("1a") + mock_get.assert_called_with("/photo/1a/nextprevious.json") + self.assertEqual(result["next"][0].get_fields(), + self.test_photos_dict[0]) + self.assertEqual(result["previous"][0].get_fields(), + self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_object_next_previous(self, mock_get): + """ + Check that the next/previous photos are returned + when using the photo object directly + """ + mock_get.return_value = self._return_value( + {"next": [self.test_photos_dict[0]], + "previous": [self.test_photos_dict[1]]}) + result = self.test_photos[0].next_previous() + mock_get.assert_called_with("/photo/1a/nextprevious.json") + self.assertEqual(result["next"][0].get_fields(), + self.test_photos_dict[0]) + self.assertEqual(result["previous"][0].get_fields(), + self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_next(self, mock_get): + """Check that the next photos are returned""" + mock_get.return_value = self._return_value( + {"next": [self.test_photos_dict[0]]}) + result = self.client.photo.next_previous(self.test_photos[0]) + mock_get.assert_called_with("/photo/1a/nextprevious.json") + self.assertEqual(result["next"][0].get_fields(), + self.test_photos_dict[0]) + self.assertNotIn("previous", result) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_previous(self, mock_get): + """Check that the previous photos are returned""" + mock_get.return_value = self._return_value( + {"previous": [self.test_photos_dict[1]]}) + result = self.client.photo.next_previous(self.test_photos[0]) + mock_get.assert_called_with("/photo/1a/nextprevious.json") + self.assertEqual(result["previous"][0].get_fields(), + self.test_photos_dict[1]) + self.assertNotIn("next", result) + + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_photo_multiple_next_previous(self, mock_get): + """Check that multiple next/previous photos are returned""" + mock_get.return_value = self._return_value( + {"next": [self.test_photos_dict[0], self.test_photos_dict[0]], + "previous": [self.test_photos_dict[1], self.test_photos_dict[1]]}) + result = self.client.photo.next_previous(self.test_photos[0]) + mock_get.assert_called_with("/photo/1a/nextprevious.json") + self.assertEqual(result["next"][0].get_fields(), + self.test_photos_dict[0]) + self.assertEqual(result["next"][1].get_fields(), + self.test_photos_dict[0]) + self.assertEqual(result["previous"][0].get_fields(), + self.test_photos_dict[1]) + self.assertEqual(result["previous"][1].get_fields(), + self.test_photos_dict[1]) + +class TestPhotoTransform(TestPhotos): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_transform(self, mock_post): + """Check that a photo can be transformed""" + mock_post.return_value = self._return_value(self.test_photos_dict[1]) + result = self.client.photo.transform(self.test_photos[0], rotate="90") + mock_post.assert_called_with("/photo/1a/transform.json", rotate="90") + self.assertEqual(result.get_fields(), self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_transform_id(self, mock_post): + """Check that a photo can be transformed using its ID""" + mock_post.return_value = self._return_value(self.test_photos_dict[1]) + result = self.client.photo.transform("1a", rotate="90") + mock_post.assert_called_with("/photo/1a/transform.json", rotate="90") + self.assertEqual(result.get_fields(), self.test_photos_dict[1]) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_photo_object_transform(self, mock_post): + """ + Check that a photo can be transformed + when using the photo object directly + """ + mock_post.return_value = self._return_value(self.test_photos_dict[1]) + photo = self.test_photos[0] + photo.transform(rotate="90") + mock_post.assert_called_with("/photo/1a/transform.json", rotate="90") + self.assertEqual(photo.get_fields(), self.test_photos_dict[1]) diff --git a/tests/unit/test_tags.py b/tests/unit/test_tags.py new file mode 100644 index 0000000..76fac62 --- /dev/null +++ b/tests/unit/test_tags.py @@ -0,0 +1,128 @@ +from __future__ import unicode_literals +import mock +try: + import unittest2 as unittest # Python2.6 +except ImportError: + import unittest + +import openphoto + +class TestTags(unittest.TestCase): + test_host = "test.example.com" + test_tags = None + test_tags_dict = [{"count": 11, "id":"tag1"}, + {"count": 5, "id":"tag2"}] + + def setUp(self): + self.client = openphoto.OpenPhoto(host=self.test_host) + self.test_tags = [openphoto.objects.Tag(self.client, tag) + for tag in self.test_tags_dict] + + @staticmethod + def _return_value(result, message="", code=200): + return {"message": message, "code": code, "result": result} + +class TestTagsList(TestTags): + @mock.patch.object(openphoto.OpenPhoto, 'get') + def test_tags_list(self, mock_get): + """Check that the the tag list is returned correctly""" + mock_get.return_value = self._return_value(self.test_tags_dict) + result = self.client.tags.list() + mock_get.assert_called_with("/tags/list.json") + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, "tag1") + self.assertEqual(result[0].count, 11) + self.assertEqual(result[1].id, "tag2") + self.assertEqual(result[1].count, 5) + +class TestTagCreate(TestTags): + # TODO: should return a tag object, not a result dict + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_create(self, mock_post): + """Check that a tag can be created""" + mock_post.return_value = self._return_value(self.test_tags_dict[0]) + result = self.client.tag.create(tag="Test", foo="bar") + mock_post.assert_called_with("/tag/create.json", tag="Test", foo="bar") + self.assertEqual(result.id, "tag1") + self.assertEqual(result.count, 11) + +class TestTagDelete(TestTags): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_delete(self, mock_post): + """Check that a tag can be deleted""" + mock_post.return_value = self._return_value(True) + result = self.client.tag.delete(self.test_tags[0]) + mock_post.assert_called_with("/tag/tag1/delete.json") + self.assertEqual(result, True) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_delete_id(self, mock_post): + """Check that a tag can be deleted using its ID""" + mock_post.return_value = self._return_value(True) + result = self.client.tag.delete("tag1") + mock_post.assert_called_with("/tag/tag1/delete.json") + self.assertEqual(result, True) + + # TODO: tag.delete should raise exception on failure + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_delete_failure(self, mock_post): + """Check that an exception is raised if a tag cannot be deleted""" + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.client.tag.delete(self.test_tags[0]) + + # TODO: after deleting object fields, id should be set to None + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_object_delete(self, mock_post): + """Check that a tag can be deleted when using the tag object directly""" + mock_post.return_value = self._return_value(True) + tag = self.test_tags[0] + result = tag.delete() + mock_post.assert_called_with("/tag/tag1/delete.json") + self.assertEqual(result, True) + self.assertEqual(tag.get_fields(), {}) + # self.assertEqual(tag.id, None) + + # TODO: tag.delete should raise exception on failure + @unittest.expectedFailure + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_object_delete_failure(self, mock_post): + """ + Check that an exception is raised if a tag cannot be deleted + when using the tag object directly + """ + mock_post.return_value = self._return_value(False) + with self.assertRaises(openphoto.OpenPhotoError): + self.test_tags[0].delete() + +class TestTagUpdate(TestTags): + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_update(self, mock_post): + """Check that a tag can be updated""" + mock_post.return_value = self._return_value(self.test_tags_dict[1]) + result = self.client.tag.update(self.test_tags[0], name="Test") + mock_post.assert_called_with("/tag/tag1/update.json", name="Test") + self.assertEqual(result.id, "tag2") + self.assertEqual(result.count, 5) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_update_id(self, mock_post): + """Check that a tag can be updated using its ID""" + mock_post.return_value = self._return_value(self.test_tags_dict[1]) + result = self.client.tag.update("tag1", name="Test") + mock_post.assert_called_with("/tag/tag1/update.json", name="Test") + self.assertEqual(result.id, "tag2") + self.assertEqual(result.count, 5) + + @mock.patch.object(openphoto.OpenPhoto, 'post') + def test_tag_object_update(self, mock_post): + """Check that a tag can be updated when using the tag object directly""" + mock_post.return_value = self._return_value(self.test_tags_dict[1]) + tag = self.test_tags[0] + tag.update(name="Test") + mock_post.assert_called_with("/tag/tag1/update.json", name="Test") + self.assertEqual(tag.id, "tag2") + self.assertEqual(tag.count, 5) + diff --git a/tox.ini b/tox.ini index 7f1f87f..2657d68 100644 --- a/tox.ini +++ b/tox.ini @@ -2,10 +2,15 @@ envlist = py26, py27, py33 [testenv] -commands = python -m unittest discover --catch +commands = python -m unittest discover --catch tests/unit +deps = + mock >= 1.0.0 + httpretty >= 0.6.1 [testenv:py26] -commands = unit2 discover --catch +commands = unit2 discover --catch tests/unit deps = + mock >= 1.0.0 + httpretty >= 0.6.1 unittest2 discover