Repository: planetlabs/planet-client-python
Pull request #302 (2021-07-22 03:26:53)

V2 api error parsing
Move error message parsing into orders client to reflect the fact that different APIs have different error messages.

Part of #289 


Patch:
diff --git a/planet/auth.py b/planet/auth.py
index 7ee59ad..1e2502c 100644
--- a/planet/auth.py
+++ b/planet/auth.py
@@ -23,14 +23,13 @@ import jwt
 
 from . import constants, http, models
 
-AUTH_URL = constants.PLANET_BASE_URL + 'v0/auth/'
 
-ENV_API_KEY = 'PL_API_KEY'
+LOGGER = logging.getLogger(__name__)
 
+BASE_URL = constants.PLANET_BASE_URL
+ENV_API_KEY = 'PL_API_KEY'
 SECRET_FILE_PATH = os.path.join(os.path.expanduser('~'), '.planet.json')
 
-LOGGER = logging.getLogger(__name__)
-
 
 class AuthException(Exception):
     '''exceptions thrown by Auth'''
@@ -162,10 +161,12 @@ class AuthClient():
         Parameters:
             base_url: Alternate authentication api base URL.
         """
-        self._base_url = base_url or AUTH_URL
+        self._base_url = base_url or BASE_URL
         if not self._base_url.endswith('/'):
             self._base_url += '/'
 
+        self._auth_url = self._base_url + 'v0/auth/'
+
     def login(
         self,
         email: str,
@@ -184,7 +185,7 @@ class AuthClient():
              A JSON object containing an `api_key` property with the user's
         API_KEY.
         '''
-        url = self._base_url + 'login'
+        url = self._auth_url + 'login'
         data = {'email': email,
                 'password': password
                 }
diff --git a/planet/clients/orders.py b/planet/clients/orders.py
index cd4c2c8..878d2e9 100644
--- a/planet/clients/orders.py
+++ b/planet/clients/orders.py
@@ -18,8 +18,9 @@ import logging
 import os
 import time
 import typing
+import uuid
 
-from .. import constants
+from .. import constants, exceptions
 from ..http import Session
 from ..models import Order, Orders, Request, Response, StreamingBody
 
@@ -80,8 +81,14 @@ class OrdersClient():
 
     @staticmethod
     def _check_order_id(oid):
-        if not oid or not isinstance(oid, str):
-            raise OrdersClientException(f'Order id ({oid}) is invalid.')
+        msg = f'Order id ({oid}) must be a valid UUID string.'
+        if not isinstance(oid, str):
+            raise OrdersClientException(msg)
+
+        try:
+            uuid.UUID(oid)
+        except ValueError:
+            raise OrdersClientException(msg)
 
     def _orders_url(self):
         return self._base_url + ORDERS_PATH
@@ -90,7 +97,7 @@ class OrdersClient():
         return self._base_url + STATS_PATH
 
     def _order_url(self, order_id):
-        self._check_order_id
+        self._check_order_id(order_id)
         return self._orders_url() + order_id
 
     def _bulk_url(self):
@@ -121,7 +128,7 @@ class OrdersClient():
         ```python
         >>> import asyncio
         >>> from planet import Session, OrdersClient
-        >>> from planet.api.order_request import build_request, product
+        >>> from planet.order_request import build_request, product
         >>>
         >>> async def main():
         ...     image_ids = ['3949357_1454705_2020-12-01_241c']
@@ -144,11 +151,21 @@ class OrdersClient():
             The ID of the order
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            planet.exceptions.APIException: On API error.
         '''
         url = self._orders_url()
-        req = self._request(url, method='POST', data=json.dumps(request))
-        resp = await self._do_request(req)
+        req = self._request(url, method='POST', json=request)
+
+        try:
+            resp = await self._do_request(req)
+        except exceptions.BadQuery as ex:
+            msg_json = json.loads(ex.message)
+
+            # get first error field
+            field = next(iter(msg_json['field'].keys()))
+            msg = (msg_json['general'][0]['message'] + ' - ' +
+                   msg_json['field'][field][0]['message'])
+            raise exceptions.BadQuery(msg)
 
         order = Order(resp.json())
         return order
@@ -166,11 +183,19 @@ class OrdersClient():
             Order information
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            OrdersClientException: If order_id is not valid UUID.
+            planet.exceptions.APIException: On API error.
         '''
         url = self._order_url(order_id)
+
         req = self._request(url, method='GET')
-        resp = await self._do_request(req)
+
+        try:
+            resp = await self._do_request(req)
+        except exceptions.MissingResource as ex:
+            msg_json = json.loads(ex.message)
+            msg = msg_json['message']
+            raise exceptions.MissingResource(msg)
 
         order = Order(resp.json())
         return order
@@ -192,11 +217,20 @@ class OrdersClient():
             Empty response
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            OrdersClientException: If order_id is not valid UUID.
+            planet.exceptions.APIException: On API error.
         '''
         url = self._order_url(order_id)
         req = self._request(url, method='PUT')
-        await self._do_request(req)
+
+        try:
+            await self._do_request(req)
+        except exceptions.Conflict as ex:
+            msg = json.loads(ex.message)['message']
+            raise exceptions.Conflict(msg)
+        except exceptions.MissingResource as ex:
+            msg = json.loads(ex.message)['message']
+            raise exceptions.MissingResource(msg)
 
     async def cancel_orders(
         self,
@@ -212,11 +246,13 @@ class OrdersClient():
             Results of the bulk cancel request
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            planet.exceptions.APIException: On API error.
         '''
         url = self._bulk_url() + 'cancel'
         cancel_body = {}
         if order_ids:
+            for oid in order_ids:
+                self._check_order_id(oid)
             cancel_body['order_ids'] = order_ids
 
         req = self._request(url, method='POST', json=cancel_body)
@@ -230,7 +266,7 @@ class OrdersClient():
             Aggregated order counts
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            planet.exceptions.APIException: On API error.
         '''
         url = self._stats_url()
         req = self._request(url, method='GET')
@@ -258,7 +294,7 @@ class OrdersClient():
             Path to downloaded file.
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            planet.exceptions.APIException: On API error.
         """
         req = self._request(location, method='GET')
 
@@ -289,7 +325,7 @@ class OrdersClient():
             Paths to downloaded files.
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            planet.exceptions.APIException: On API error.
         """
         order = await self.get_order(order_id)
         locations = order.locations
@@ -323,8 +359,9 @@ class OrdersClient():
             Completed state of the order
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
-            OrdersClientException: If state is not supported.
+            planet.exceptions.APIException: On API error.
+            OrdersClientException: If order_id is not valid or state is not
+                supported.
         """
         completed = False
 
@@ -370,7 +407,7 @@ class OrdersClient():
             User orders that match the query
 
         Raises:
-            planet.api.exceptions.APIException: On API error.
+            planet.exceptions.APIException: On API error.
         """
         url = self._orders_url()
         if state:
diff --git a/planet/exceptions.py b/planet/exceptions.py
index 7fbd24f..70480ca 100644
--- a/planet/exceptions.py
+++ b/planet/exceptions.py
@@ -15,7 +15,9 @@
 
 class APIException(Exception):
     '''General unexpected response'''
-    pass
+    @property
+    def message(self):
+        return self.args[0]
 
 
 class BadQuery(APIException):
@@ -38,6 +40,11 @@ class MissingResource(APIException):
     pass
 
 
+class Conflict(APIException):
+    '''Request conflict with current state of the target resource, HTTP 409'''
+    pass
+
+
 class TooManyRequests(APIException):
     '''Too many requests, HTTP 429'''
     pass
diff --git a/planet/geojson.py b/planet/geojson.py
index d99985e..752df7f 100644
--- a/planet/geojson.py
+++ b/planet/geojson.py
@@ -30,9 +30,8 @@ class WrongTypeException(GeoJSONException):
     pass
 
 
-class MultipleFeaturesException(GeoJSONException):
-    '''multiple features when only one is acceptable'''
-    pass
+class DataLossWarning(UserWarning):
+    """Warn that data will be lost."""
 
 
 def as_geom(data: dict) -> dict:
@@ -81,7 +80,7 @@ def geom_from_geojson(data: dict) -> dict:
     else:
         try:
             # feature
-            ret = as_geom(data['geometry'])
+            ret = as_geom(data["geometry"])
         except KeyError:
             try:
                 # featureclass
@@ -90,9 +89,9 @@ def geom_from_geojson(data: dict) -> dict:
                 raise GeoJSONException('Invalid GeoJSON: {data}')
 
             if len(features) > 1:
-                raise MultipleFeaturesException(
-                    'FeatureClass has multiple features. Only one feature '
-                    'can be used to get geometry.')
+                LOGGER.warning(
+                    'FeatureClass has more than one Feature, using only first'
+                    ' feature.')
 
             ret = as_geom(features[0])
     return ret
diff --git a/planet/http.py b/planet/http.py
index 7f15ee1..c4746dd 100644
--- a/planet/http.py
+++ b/planet/http.py
@@ -53,7 +53,6 @@ class BaseSession():
 
     @classmethod
     def _raise_for_status(cls, response):
-        # TODO: consider using http_response.reason_phrase
         status = response.status_code
 
         miminum_bad_request_code = HTTPStatus.MOVED_PERMANENTLY
@@ -65,51 +64,21 @@ class BaseSession():
             HTTPStatus.UNAUTHORIZED: exceptions.InvalidAPIKey,
             HTTPStatus.FORBIDDEN: exceptions.NoPermission,
             HTTPStatus.NOT_FOUND: exceptions.MissingResource,
+            HTTPStatus.CONFLICT: exceptions.Conflict,
             HTTPStatus.TOO_MANY_REQUESTS: exceptions.TooManyRequests,
             HTTPStatus.INTERNAL_SERVER_ERROR: exceptions.ServerError
-        }.get(status, None)
-
+        }.get(status, exceptions.APIException)
         LOGGER.debug(f"Exception type: {exception}")
-        LOGGER.debug(f"Response text: {response.text}")
+
+        msg = response.text
+        LOGGER.debug(f"Response text: {msg}")
 
         if exception == exceptions.TooManyRequests:
             # differentiate between over quota and rate-limiting
-            if 'quota' in response.text.lower():
+            if 'quota' in msg.lower():
                 exception = exceptions.OverQuota
 
-        try:
-            msg = cls._parse_message(response)
-        except Exception:
-            msg = response.text
-
-        if exception:
-            raise exception(msg)
-
-        raise exceptions.APIException(f'{status}: {msg}')
-
-    @staticmethod
-    def _parse_message(response):
-        msg = response.json()
-        LOGGER.debug(f'Raw response message: {msg}')
-
-        try:
-            msg = msg['message']
-        except KeyError:
-            try:
-                new_msg = msg['general'][0]['message']
-                try:
-                    msg_field = msg['field']
-                    key = list(msg_field.keys())[0]
-                    LOGGER.debug(f'key: {key}')
-                    new_msg = (new_msg + ' - ' + msg_field[key][0]['message'])
-                except Exception:
-                    pass
-                msg = new_msg
-            except Exception:
-                pass
-
-        LOGGER.debug(f'Processed response message: {msg}')
-        return msg
+        raise exception(msg)
 
 
 class Session(BaseSession):


Test patch:
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
new file mode 100644
index 0000000..dfea98b
--- /dev/null
+++ b/tests/integration/conftest.py
@@ -0,0 +1,35 @@
+# Copyright 2020 Planet Labs, Inc.
+#
+# 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.
+import contextlib
+
+import pytest
+
+from planet import Session
+
+
+@pytest.fixture
+@pytest.mark.asyncio
+async def session():
+    async with Session() as ps:
+        yield ps
+
+
+@pytest.fixture
+def match_pytest_raises():
+    '''this is like pytest.raises but does a full string match'''
+    @contextlib.contextmanager
+    def cm(ex, msg):
+        with pytest.raises(ex, match=f'^{msg}$') as pt:
+            yield pt
+    return cm
diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py
new file mode 100644
index 0000000..a23510d
--- /dev/null
+++ b/tests/integration/test_auth_api.py
@@ -0,0 +1,86 @@
+# Copyright 2021 Planet Labs, Inc.
+#
+# 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 http import HTTPStatus
+import logging
+
+import httpx
+import jwt
+import pytest
+import respx
+
+from planet import exceptions
+from planet.auth import AuthClient
+
+
+TEST_URL = 'http://MockNotRealURL/'
+AUTH_URL = TEST_URL + 'v0/auth/'
+
+LOGGER = logging.getLogger(__name__)
+
+
+@respx.mock
+def test_AuthClient_success():
+    login_url = AUTH_URL + 'login'
+
+    payload = {'api_key': 'iamakey'}
+    resp = {'token': jwt.encode(payload, 'key')}
+    mock_resp = httpx.Response(HTTPStatus.OK, json=resp)
+    respx.post(login_url).return_value = mock_resp
+
+    cl = AuthClient(base_url=TEST_URL)
+    auth_data = cl.login('email', 'password')
+
+    assert auth_data == payload
+
+
+@respx.mock
+def test_AuthClient_invalid_email():
+    login_url = AUTH_URL + 'login'
+
+    resp = {
+        "errors": {
+            "email": [
+                "Not a valid email address."
+            ]
+        },
+        "message": "error validating request against UserAuthenticationSchema",
+        "status": 400,
+        "success": False
+    }
+    mock_resp = httpx.Response(400, json=resp)
+    respx.post(login_url).return_value = mock_resp
+
+    cl = AuthClient(base_url=TEST_URL)
+    with pytest.raises(exceptions.APIException,
+                       match='Not a valid email address.'):
+        _ = cl.login('email', 'password')
+
+
+@respx.mock
+def test_AuthClient_invalid_password():
+    login_url = AUTH_URL + 'login'
+
+    resp = {
+        "errors": None,
+        "message": "Invalid email or password",
+        "status": 401,
+        "success": False
+    }
+    mock_resp = httpx.Response(401, json=resp)
+    respx.post(login_url).return_value = mock_resp
+
+    cl = AuthClient(base_url=TEST_URL)
+    with pytest.raises(exceptions.APIException,
+                       match='Incorrect email or password.'):
+        _ = cl.login('email', 'password')
diff --git a/tests/integration/test_orders_api.py b/tests/integration/test_orders_api.py
index 1957526..f837e1a 100644
--- a/tests/integration/test_orders_api.py
+++ b/tests/integration/test_orders_api.py
@@ -24,7 +24,7 @@ import httpx
 import pytest
 import respx
 
-from planet import OrdersClient, Session
+from planet import OrdersClient, clients, exceptions
 
 
 TEST_URL = 'http://MockNotRealURL/'
@@ -32,13 +32,6 @@ TEST_URL = 'http://MockNotRealURL/'
 LOGGER = logging.getLogger(__name__)
 
 
-@pytest.fixture
-@pytest.mark.asyncio
-async def session():
-    async with Session() as ps:
-        yield ps
-
-
 @pytest.fixture
 def order_descriptions(order_description):
     order1 = order_description
@@ -50,6 +43,12 @@ def order_descriptions(order_description):
     return [order1, order2, order3]
 
 
+@pytest.fixture
+def oid2():
+    # obtained from uuid.uuid1()
+    return '5ece1dc0-ea81-11eb-837c-acde48001122'
+
+
 @respx.mock
 @pytest.mark.asyncio
 async def test_list_orders_basic(order_descriptions, session):
@@ -105,6 +104,14 @@ async def test_list_orders_state(order_descriptions, session):
     assert oids == ['oid1', 'oid2']
 
 
+@pytest.mark.asyncio
+async def test_list_orders_state_invalid_state(session):
+    cl = OrdersClient(session, base_url=TEST_URL)
+
+    with pytest.raises(clients.orders.OrdersClientException):
+        _ = await cl.list_orders(state='invalidstate')
+
+
 @respx.mock
 @pytest.mark.asyncio
 async def test_list_orders_limit(order_descriptions, session):
@@ -178,6 +185,74 @@ async def test_create_order(oid, order_description, order_request, session):
     assert order.json == order_description
 
 
+@respx.mock
+@pytest.mark.asyncio
+async def test_create_order_bad_item_type(order_request, session):
+    create_url = TEST_URL + 'orders/v2/'
+
+    resp = {
+        "field": {
+            "Products": [
+                {
+                    "message": ("Bad item type 'invalid' for bundle type " +
+                                "'analytic'")
+                }
+            ]
+        },
+        "general": [
+            {
+                "message": "Unable to accept order"
+            }
+        ]
+    }
+    mock_resp = httpx.Response(400, json=resp)
+    respx.post(create_url).return_value = mock_resp
+    order_request['products'][0]['item_type'] = 'invalid'
+    cl = OrdersClient(session, base_url=TEST_URL)
+
+    expected_msg = (
+        "Unable to accept order - Bad item type 'invalid' for bundle type " +
+        "'analytic'")
+
+    with pytest.raises(exceptions.BadQuery, match=expected_msg):
+        _ = await cl.create_order(order_request)
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_create_order_item_id_does_not_exist(
+        order_request, session, match_pytest_raises):
+    create_url = TEST_URL + 'orders/v2/'
+
+    resp = {
+        "field": {
+            "Details": [
+                {
+                    "message": ("Item ID 4500474_2133707_2021-05-20_2419 / " +
+                                "Item Type PSScene3Band doesn't exist")
+                }
+            ]
+        },
+        "general": [
+            {
+                "message": "Unable to accept order"
+            }
+        ]
+    }
+    mock_resp = httpx.Response(400, json=resp)
+    respx.post(create_url).return_value = mock_resp
+    order_request['products'][0]['item_ids'] = \
+        '4500474_2133707_2021-05-20_2419'
+    cl = OrdersClient(session, base_url=TEST_URL)
+
+    expected_msg = (
+        "Unable to accept order - Item ID 4500474_2133707_2021-05-20_2419 " +
+        "/ Item Type PSScene3Band doesn't exist")
+
+    with match_pytest_raises(exceptions.BadQuery, expected_msg):
+        _ = await cl.create_order(order_request)
+
+
 @respx.mock
 @pytest.mark.asyncio
 async def test_get_order(oid, order_description, session):
@@ -191,6 +266,32 @@ async def test_get_order(oid, order_description, session):
     assert order.state == 'queued'
 
 
+@pytest.mark.asyncio
+async def test_get_order_invalid_id(session):
+    cl = OrdersClient(session, base_url=TEST_URL)
+    with pytest.raises(clients.orders.OrdersClientException):
+        _ = await cl.get_order('-')
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_get_order_id_doesnt_exist(
+        oid, session, match_pytest_raises):
+    get_url = TEST_URL + 'orders/v2/' + oid
+
+    msg = f'Could not load order ID: {oid}.'
+    resp = {
+        "message": msg
+    }
+    mock_resp = httpx.Response(404, json=resp)
+    respx.get(get_url).return_value = mock_resp
+
+    cl = OrdersClient(session, base_url=TEST_URL)
+
+    with match_pytest_raises(exceptions.MissingResource, msg):
+        _ = await cl.get_order(oid)
+
+
 @respx.mock
 @pytest.mark.asyncio
 async def test_cancel_order(oid, order_description, session):
@@ -205,20 +306,65 @@ async def test_cancel_order(oid, order_description, session):
     await cl.cancel_order(oid)
 
 
+@pytest.mark.asyncio
+async def test_cancel_order_invalid_id(session):
+    cl = OrdersClient(session, base_url=TEST_URL)
+    with pytest.raises(clients.orders.OrdersClientException):
+        _ = await cl.cancel_order('invalid_order_id')
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_cancel_order_id_doesnt_exist(
+        oid, session, match_pytest_raises):
+    cancel_url = TEST_URL + 'orders/v2/' + oid
+
+    msg = f'No such order ID: {oid}.'
+    resp = {
+        "message": msg
+    }
+    mock_resp = httpx.Response(404, json=resp)
+    respx.put(cancel_url).return_value = mock_resp
+
+    cl = OrdersClient(session, base_url=TEST_URL)
+
+    with match_pytest_raises(exceptions.MissingResource, msg):
+        _ = await cl.cancel_order(oid)
+
+
+@respx.mock
+@pytest.mark.asyncio
+async def test_cancel_order_id_cannot_be_cancelled(
+        oid, session, match_pytest_raises):
+    cancel_url = TEST_URL + 'orders/v2/' + oid
+
+    msg = 'Order not in a cancellable state'
+    resp = {
+        "message": msg
+    }
+    mock_resp = httpx.Response(409, json=resp)
+    respx.put(cancel_url).return_value = mock_resp
+
+    cl = OrdersClient(session, base_url=TEST_URL)
+
+    with match_pytest_raises(exceptions.Conflict, msg):
+        _ = await cl.cancel_order(oid)
+
+
 @respx.mock
 @pytest.mark.asyncio
-async def test_cancel_orders_by_ids(session):
+async def test_cancel_orders_by_ids(session, oid, oid2):
     bulk_cancel_url = TEST_URL + 'bulk/orders/v2/cancel'
-    test_ids = ["oid1", "oid2", "oid3"]
+    test_ids = [oid, oid2]
     example_result = {
         "result": {
-            "succeeded": {"count": 2},
+            "succeeded": {"count": 1},
             "failed": {
                 "count": 1,
                 "failures": [
                     {
-                        "order_id": "oid3",
-                        "message": "bummer"
+                        "order_id": oid2,
+                        "message": "Order not in a cancellable state",
                     }
                 ]
             }
@@ -239,6 +385,13 @@ async def test_cancel_orders_by_ids(session):
     assert actual_body == expected_body
 
 
+@pytest.mark.asyncio
+async def test_cancel_orders_by_ids_invalid_id(session, oid):
+    cl = OrdersClient(session, base_url=TEST_URL)
+    with pytest.raises(clients.orders.OrdersClientException):
+        _ = await cl.cancel_orders([oid, "invalid_oid"])
+
+
 @respx.mock
 @pytest.mark.asyncio
 async def test_cancel_orders_all(session):
@@ -296,6 +449,20 @@ async def test_poll(oid, order_description, session):
     assert state == 'running'
 
 
+@pytest.mark.asyncio
+async def test_poll_invalid_oid(session):
+    cl = OrdersClient(session, base_url=TEST_URL)
+    with pytest.raises(clients.orders.OrdersClientException):
+        _ = await cl.poll("invalid_oid", wait=0)
+
+
+@pytest.mark.asyncio
+async def test_poll_invalid_state(oid, session):
+    cl = OrdersClient(session, base_url=TEST_URL)
+    with pytest.raises(clients.orders.OrdersClientException):
+        _ = await cl.poll(oid, state="invalid_state", wait=0)
+
+
 @respx.mock
 @pytest.mark.asyncio
 async def test_aggegated_order_stats(session):
diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py
index 81b7caa..6ffd330 100644
--- a/tests/unit/test_auth.py
+++ b/tests/unit/test_auth.py
@@ -11,14 +11,10 @@
 # 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 http import HTTPStatus
 import json
 import logging
 
-import httpx
-import jwt
 import pytest
-import respx
 
 from planet import auth
 
@@ -109,19 +105,16 @@ def test_Auth_from_env_alternate_doesnotexist(monkeypatch):
         _ = auth.Auth.from_env(alternate)
 
 
-@respx.mock
 def test_Auth_from_login(monkeypatch):
-    test_url = 'http://MockNotRealURL/'
-    login_url = test_url + 'login'
+    auth_data = 'authdata'
 
-    response = {
-        'token': jwt.encode({'api_key': 'foobar'}, key='')
-        }
-    mock_resp = httpx.Response(HTTPStatus.OK, json=response)
-    respx.post(login_url).return_value = mock_resp
+    def login(*args, **kwargs):
+        return {'api_key': auth_data}
 
-    test_auth = auth.Auth.from_login('email', 'pw', base_url=test_url)
-    assert test_auth.value == 'foobar'
+    monkeypatch.setattr(auth.AuthClient, 'login', login)
+
+    test_auth = auth.Auth.from_login('email', 'pw')
+    assert test_auth.value == auth_data
 
 
 def test_Auth_write_doesnotexist(tmp_path):
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 4ae039d..9506e0d 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -34,28 +34,6 @@ def runner():
     return CliRunner()
 
 
-def test_cli_info_verbosity(runner, monkeypatch):
-    log_level = None
-
-    def configtest(stream, level, format):
-        nonlocal log_level
-        log_level = level
-    monkeypatch.setattr(planet.scripts.cli.logging, 'basicConfig', configtest)
-
-    def patch(*args, **kwargs):
-        pass
-    monkeypatch.setattr(planet.scripts.cli, 'value', patch)
-
-    _ = runner.invoke(cli, args=['auth', 'value'])
-    assert log_level == logging.WARNING
-
-    _ = runner.invoke(cli, args=['-v', 'auth', 'value'])
-    assert log_level == logging.INFO
-
-    _ = runner.invoke(cli, args=['-vv', 'auth', 'value'])
-    assert log_level == logging.DEBUG
-
-
 def test_cli_auth_init_bad_pw(runner, monkeypatch):
     def apiexcept(*args, **kwargs):
         raise planet.exceptions.APIException('nope')
diff --git a/tests/unit/test_geojson.py b/tests/unit/test_geojson.py
index 28f9539..6094d5f 100644
--- a/tests/unit/test_geojson.py
+++ b/tests/unit/test_geojson.py
@@ -45,6 +45,10 @@ def test_geom_from_geojson_success(
     fcgeo = geojson.geom_from_geojson(featureclass_geojson)
     assert_geom_equal(fcgeo, geom_geojson)
 
+    featureclass_geojson['features'].append(feature_geojson)
+    ffcgeo = geojson.geom_from_geojson(featureclass_geojson)
+    assert_geom_equal(ffcgeo, geom_geojson)
+
 
 def test_geom_from_geojson_no_geometry(feature_geojson):
     feature_geojson.pop('geometry')
@@ -64,13 +68,6 @@ def test_geom_from_geojson_missing_type(geom_geojson):
         _ = geojson.geom_from_geojson(geom_geojson)
 
 
-def test_geom_from_geojson_multiple_features(featureclass_geojson):
-    # duplicate the feature
-    featureclass_geojson['features'] = 2 * featureclass_geojson['features']
-    with pytest.raises(geojson.MultipleFeaturesException):
-        _ = geojson.geom_from_geojson(featureclass_geojson)
-
-
 def test_validate_geom_invalid_type(geom_geojson):
     geom_geojson['type'] = 'invalid'
     with pytest.raises(geojson.GeoJSONException):
diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py
index 436079a..9107466 100644
--- a/tests/unit/test_http.py
+++ b/tests/unit/test_http.py
@@ -81,49 +81,6 @@ def test_basesession__raise_for_status(mock_response):
         ))
 
 
-def test_basesession__parse_message(mock_response):
-    create_order_bad_id_msg = {
-        "field": {
-            "Details": [
-                {"message": "Item ID 1 / Item Type PSScene3Band doesn't exist"}
-            ]
-        },
-        "general": [
-            {"message": "Unable to accept order"}
-        ]
-    }
-
-    msg = http.BaseSession._parse_message(mock_response(
-            HTTPStatus.BAD_REQUEST,
-            json=create_order_bad_id_msg
-        ))
-    assert msg == (
-        "Unable to accept order - " +
-        "Item ID 1 / Item Type PSScene3Band doesn't exist"
-    )
-
-    oid = 'f8da0a3e-174f-4359-b088-a961ac76f0e7'
-    id_not_found_msg = {
-        "message": f"Could not load order ID: {oid}."
-    }
-    msg = http.BaseSession._parse_message(mock_response(
-            HTTPStatus.NOT_FOUND,
-            json=id_not_found_msg
-        ))
-    assert msg == f"Could not load order ID: {oid}."
-
-    bad_oid = 'f8da0a3e-174f-4359-b088-a961ac76f0e'
-    bad_id_msg = {
-        "code": 601,
-        "message": f"order_id in path must be of type uuid: \"{bad_oid}\""
-    }
-    msg = http.BaseSession._parse_message(mock_response(
-            HTTPStatus.BAD_REQUEST,
-            json=bad_id_msg
-        ))
-    assert msg == f'order_id in path must be of type uuid: "{bad_oid}"'
-
-
 @pytest.mark.asyncio
 async def test_session_contextmanager():
     async with http.Session():