diff --git a/planet/auth.py b/planet/auth.py index 8540053e3..274522e29 100644 --- a/planet/auth.py +++ b/planet/auth.py @@ -21,15 +21,14 @@ import httpx import jwt -from . import constants, http, models +from . import http, models +from .constants import PLANET_BASE_URL, SECRET_FILE_PATH from .exceptions import AuthException - LOGGER = logging.getLogger(__name__) -BASE_URL = constants.PLANET_BASE_URL + 'v0/auth/' +BASE_URL = f'{PLANET_BASE_URL}/v0/auth' ENV_API_KEY = 'PL_API_KEY' -SECRET_FILE_PATH = os.path.join(os.path.expanduser('~'), '.planet.json') class Auth(metaclass=abc.ABCMeta): @@ -155,11 +154,12 @@ def __init__( ): """ Parameters: - base_url: Alternate authentication api base URL. + base_url: The base URL to use. Defaults to production + authentication API base url. """ self._base_url = base_url or BASE_URL - if not self._base_url.endswith('/'): - self._base_url += '/' + if self._base_url.endswith('/'): + self._base_url = self._base_url[:-1] def login( self, @@ -179,7 +179,7 @@ def login( A JSON object containing an `api_key` property with the user's API_KEY. ''' - url = self._base_url + 'login' + url = f'{self._base_url}/login' data = {'email': email, 'password': password } diff --git a/planet/cli/auth.py b/planet/cli/auth.py index 1f5aeaeac..c8ba813b5 100644 --- a/planet/cli/auth.py +++ b/planet/cli/auth.py @@ -43,7 +43,7 @@ def auth(ctx, base_url): )) def init(ctx, email, password): '''Obtain and store authentication information''' - base_url = ctx.obj["BASE_URL"] + base_url = ctx.obj['BASE_URL'] plauth = planet.Auth.from_login(email, password, base_url=base_url) plauth.write() click.echo('Initialized') diff --git a/planet/cli/orders.py b/planet/cli/orders.py index a30b561c9..2d0aac394 100644 --- a/planet/cli/orders.py +++ b/planet/cli/orders.py @@ -46,8 +46,7 @@ async def orders_client(ctx): help='Assign custom base Orders API URL.') def orders(ctx, base_url): '''Commands for interacting with the Orders API''' - auth = planet.Auth.from_file() - ctx.obj['AUTH'] = auth + ctx.obj['AUTH'] = planet.Auth.from_file() ctx.obj['BASE_URL'] = base_url @@ -157,8 +156,6 @@ def read_file_json(ctx, param, value): json_value = json.load(value) except json.decoder.JSONDecodeError: raise click.ClickException('File does not contain valid json.') - except click.FileError as e: - raise click.ClickException(e) return json_value diff --git a/planet/clients/orders.py b/planet/clients/orders.py index 274b8f61c..094055975 100644 --- a/planet/clients/orders.py +++ b/planet/clients/orders.py @@ -20,15 +20,16 @@ import typing import uuid -from .. import constants, exceptions +from .. import exceptions +from ..constants import PLANET_BASE_URL from ..http import Session from ..models import Order, Orders, Request, Response, StreamingBody -BASE_URL = constants.PLANET_BASE_URL + 'compute/ops/' -STATS_PATH = 'stats/orders/v2/' -ORDERS_PATH = 'orders/v2/' -BULK_PATH = 'bulk/orders/v2/' +BASE_URL = f'{PLANET_BASE_URL}compute/ops' +STATS_PATH = '/stats/orders/v2' +ORDERS_PATH = '/orders/v2' +BULK_PATH = '/bulk/orders/v2' # Order states https://developers.planet.com/docs/orders/ordering/#order-states ORDERS_STATES_COMPLETE = ['success', 'partial', 'cancelled', 'failed'] @@ -52,16 +53,13 @@ class OrdersClient(): >>> from planet import Session, OrdersClient >>> >>> async def main(): - ... auth = ('example_api_key', '') - ... async with Session(auth=auth) as sess: + ... async with Session() as sess: ... cl = OrdersClient(sess) ... # use client here ... >>> asyncio.run(main()) ``` - - """ def __init__( self, @@ -77,8 +75,8 @@ def __init__( self._session = session self._base_url = base_url or BASE_URL - if not self._base_url.endswith('/'): - self._base_url += '/' + if self._base_url.endswith('/'): + self._base_url = self._base_url[:-1] @staticmethod def _check_order_id(oid): @@ -92,17 +90,10 @@ def _check_order_id(oid): raise OrdersClientException(msg) def _orders_url(self): - return self._base_url + ORDERS_PATH + return f'{self._base_url}{ORDERS_PATH}' def _stats_url(self): - return self._base_url + STATS_PATH - - def _order_url(self, order_id): - self._check_order_id(order_id) - return self._orders_url() + order_id - - def _bulk_url(self): - return self._base_url + BULK_PATH + return f'{self._base_url}{STATS_PATH}' def _request(self, url, method, data=None, params=None, json=None): return Request(url, method=method, data=data, params=params, json=json) @@ -190,7 +181,8 @@ async def get_order( OrdersClientException: If order_id is not valid UUID. planet.exceptions.APIException: On API error. ''' - url = self._order_url(order_id) + self._check_order_id(order_id) + url = f'{self._orders_url()}/{order_id}' req = self._request(url, method='GET') @@ -224,7 +216,9 @@ async def cancel_order( OrdersClientException: If order_id is not valid UUID. planet.exceptions.APIException: On API error. ''' - url = self._order_url(order_id) + self._check_order_id(order_id) + url = f'{self._orders_url()}/{order_id}' + req = self._request(url, method='PUT') try: @@ -252,7 +246,7 @@ async def cancel_orders( Raises: planet.exceptions.APIException: On API error. ''' - url = self._bulk_url() + 'cancel' + url = f'{self._base_url}{BULK_PATH}/cancel' cancel_body = {} if order_ids: for oid in order_ids: diff --git a/planet/constants.py b/planet/constants.py index 8cb5fa33a..7dad8351b 100644 --- a/planet/constants.py +++ b/planet/constants.py @@ -12,5 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. '''Constants used across the code base''' +import os -PLANET_BASE_URL = 'https://api.planet.com/' +PLANET_BASE_URL = 'https://api.planet.com' + +SECRET_FILE_PATH = os.path.join(os.path.expanduser('~'), '.planet.json') diff --git a/setup.cfg b/setup.cfg index 5a74fcecd..aa5bf8a28 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,7 @@ addopts = --cov=tests --cov-report=term-missing --cov-report=xml - --cov-fail-under 95 + --cov-fail-under 97 -rxXs [coverage:run] diff --git a/tests/conftest.py b/tests/conftest.py index 6f4b57022..1dc027885 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ from planet.auth import _SecretFile + _here = Path(os.path.abspath(os.path.dirname(__file__))) _test_data_path = _here / 'data' @@ -29,6 +30,8 @@ def test_secretfile_read(): def mockreturn(self): return {'key': 'testkey'} + # monkeypatch fixture is not available above a function scope + # usage: https://docs.pytest.org/en/6.2.x/reference.html#pytest.MonkeyPatch with pytest.MonkeyPatch.context() as mp: mp.setattr(_SecretFile, 'read', mockreturn) yield diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index dfea98b11..1044a4cfe 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import contextlib +import copy import pytest @@ -33,3 +34,14 @@ def cm(ex, msg): with pytest.raises(ex, match=f'^{msg}$') as pt: yield pt return cm + + +@pytest.fixture +def order_descriptions(order_description): + order1 = order_description + order1['id'] = 'oid1' + order2 = copy.deepcopy(order_description) + order2['id'] = 'oid2' + order3 = copy.deepcopy(order_description) + order3['id'] = 'oid3' + return [order1, order2, order3] diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py index 0a6e53266..079f6b259 100644 --- a/tests/integration/test_auth_api.py +++ b/tests/integration/test_auth_api.py @@ -23,19 +23,18 @@ from planet.auth import AuthClient -TEST_URL = 'http://MockNotRealURL/' +TEST_URL = 'http://MockNotRealURL/api/path' +TEST_LOGIN_URL = f'{TEST_URL}/login' LOGGER = logging.getLogger(__name__) @respx.mock def test_AuthClient_success(): - login_url = TEST_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 + respx.post(TEST_LOGIN_URL).return_value = mock_resp cl = AuthClient(base_url=TEST_URL) auth_data = cl.login('email', 'password') @@ -45,8 +44,6 @@ def test_AuthClient_success(): @respx.mock def test_AuthClient_invalid_email(): - login_url = TEST_URL + 'login' - resp = { "errors": { "email": [ @@ -58,7 +55,7 @@ def test_AuthClient_invalid_email(): "success": False } mock_resp = httpx.Response(400, json=resp) - respx.post(login_url).return_value = mock_resp + respx.post(TEST_LOGIN_URL).return_value = mock_resp cl = AuthClient(base_url=TEST_URL) with pytest.raises(exceptions.APIException, @@ -68,8 +65,6 @@ def test_AuthClient_invalid_email(): @respx.mock def test_AuthClient_invalid_password(): - login_url = TEST_URL + 'login' - resp = { "errors": None, "message": "Invalid email or password", @@ -77,7 +72,7 @@ def test_AuthClient_invalid_password(): "success": False } mock_resp = httpx.Response(401, json=resp) - respx.post(login_url).return_value = mock_resp + respx.post(TEST_LOGIN_URL).return_value = mock_resp cl = AuthClient(base_url=TEST_URL) with pytest.raises(exceptions.APIException, diff --git a/tests/integration/test_auth_cli.py b/tests/integration/test_auth_cli.py index 02a57f052..861129f8c 100644 --- a/tests/integration/test_auth_cli.py +++ b/tests/integration/test_auth_cli.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations under # the License. from http import HTTPStatus -from unittest.mock import MagicMock +import json from click.testing import CliRunner import httpx @@ -20,36 +20,87 @@ import pytest import respx -import planet from planet.cli import cli -TEST_URL = 'http://MockNotRealURL/' +TEST_URL = 'http://MockNotRealURL/api/path' +TEST_LOGIN_URL = f'{TEST_URL}/login' -@pytest.fixture(autouse=True) -def patch_session(monkeypatch): - '''Make sure we don't actually make any http calls''' - monkeypatch.setattr(planet, 'Session', MagicMock(spec=planet.Session)) +# skip the global mock of _SecretFile.read +# for this module +@pytest.fixture(autouse=True, scope='module') +def test_secretfile_read(): + return -@respx.mock -@pytest.mark.asyncio -def test_cli_auth_init_base_url(): - '''Test base url option +@pytest.fixture +def redirect_secretfile(tmp_path): + '''patch the cli so it works with a temporary secretfile - Uses the auth init path to ensure the base url is changed to the mocked - url. So, ends up testing the auth init path somewhat as well + this is to avoid collisions with the actual planet secretfile ''' - login_url = TEST_URL + 'login' + secretfile_path = tmp_path / 'secret.json' + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(cli.auth.planet.auth, 'SECRET_FILE_PATH', secretfile_path) + yield secretfile_path + + +@respx.mock +def test_cli_auth_init_success(redirect_secretfile): + """Test the successful auth init path - payload = {'api_key': 'iamakey'} + Also tests the base-url command, since we will get an exception + if the base url is not changed to the mocked url + """ + payload = {'api_key': 'test_cli_auth_init_success_key'} resp = {'token': jwt.encode(payload, 'key')} mock_resp = httpx.Response(HTTPStatus.OK, json=resp) - respx.post(login_url).return_value = mock_resp + respx.post(TEST_LOGIN_URL).return_value = mock_resp + + result = CliRunner().invoke( + cli.main, + args=['auth', '--base-url', TEST_URL, 'init'], + input='email\npw\n') + + # we would get a 'url not mocked' exception if the base url wasn't + # changed to the mocked url + assert not result.exception + + assert 'Initialized' in result.output + + +@respx.mock +def test_cli_auth_init_bad_pw(redirect_secretfile): + resp = {"errors": None, + "message": "Invalid email or password", + "status": 401, + "success": False} + mock_resp = httpx.Response(401, json=resp) + respx.post(TEST_LOGIN_URL).return_value = mock_resp result = CliRunner().invoke( - cli.main, - args=['auth', '--base-url', TEST_URL, 'init'], - input='email\npw\n') + cli.main, + args=['auth', '--base-url', TEST_URL, 'init'], + input='email\npw\n') + + assert result.exception + assert 'Error: Incorrect email or password.\n' in result.output + +def test_cli_auth_value_success(redirect_secretfile): + key = 'test_cli_auth_value_success_key' + content = {'key': key} + with open(redirect_secretfile, 'w') as f: + json.dump(content, f) + + result = CliRunner().invoke(cli.main, ['auth', 'value']) assert not result.exception + assert result.output == f'{key}\n' + + +def test_cli_auth_value_failure(redirect_secretfile): + result = CliRunner().invoke(cli.main, ['auth', 'value']) + assert result.exception + assert 'Error: Auth information does not exist or is corrupted.' \ + in result.output diff --git a/tests/integration/test_orders_api.py b/tests/integration/test_orders_api.py index 3e9a0ecab..a34c76872 100644 --- a/tests/integration/test_orders_api.py +++ b/tests/integration/test_orders_api.py @@ -25,30 +25,17 @@ import respx from planet import OrdersClient, clients, exceptions, reporting +# from planet.clients.orders import BULK_PATH, ORDERS_PATH, STATS_PATH - -TEST_URL = 'http://MockNotRealURL/' +TEST_URL = 'http://www.MockNotRealURL.com/api/path' +TEST_BULK_CANCEL_URL = f'{TEST_URL}/bulk/orders/v2/cancel' +TEST_DOWNLOAD_URL = f'{TEST_URL}/download' +TEST_ORDERS_URL = f'{TEST_URL}/orders/v2' +TEST_STATS_URL = f'{TEST_URL}/stats/orders/v2' LOGGER = logging.getLogger(__name__) -@pytest.fixture -def order_descriptions(order_description): - order1 = order_description - order1['id'] = 'oid1' - order2 = copy.deepcopy(order_description) - order2['id'] = 'oid2' - order3 = copy.deepcopy(order_description) - order3['id'] = 'oid3' - return [order1, order2, order3] - - -@pytest.fixture -def oid2(): - # obtained from uuid.uuid1() - return '5ece1dc0-ea81-11eb-837c-acde48001122' - - @pytest.fixture def downloaded_content(): return {'key': 'downloaded_file'} @@ -67,12 +54,12 @@ def create_download_mock(downloaded_content, order_description, oid): def f(): # Create mock HTTP response - dl_url = TEST_URL + 'download/1?token=IAmAToken' + dl_url = TEST_DOWNLOAD_URL + '/1?token=IAmAToken' order_description['_links']['results'] = [ {'location': dl_url}, ] - get_url = TEST_URL + 'orders/v2/' + oid + get_url = f'{TEST_ORDERS_URL}/{oid}' mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.get(get_url).return_value = mock_resp @@ -91,8 +78,7 @@ def f(): @respx.mock @pytest.mark.asyncio async def test_list_orders_basic(order_descriptions, session): - list_url = TEST_URL + 'orders/v2/' - next_page_url = list_url + 'blob/?page_marker=IAmATest' + next_page_url = TEST_ORDERS_URL + 'blob/?page_marker=IAmATest' order1, order2, order3 = order_descriptions @@ -103,7 +89,7 @@ async def test_list_orders_basic(order_descriptions, session): "orders": [order1, order2] } mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response) - respx.get(list_url).return_value = mock_resp1 + respx.get(TEST_ORDERS_URL).return_value = mock_resp1 page2_response = { "_links": { @@ -123,7 +109,7 @@ async def test_list_orders_basic(order_descriptions, session): @respx.mock @pytest.mark.asyncio async def test_list_orders_state(order_descriptions, session): - list_url = TEST_URL + 'orders/v2/?state=failed' + list_url = TEST_ORDERS_URL + '?state=failed' order1, order2, _ = order_descriptions @@ -137,6 +123,9 @@ async def test_list_orders_state(order_descriptions, session): respx.get(list_url).return_value = mock_resp cl = OrdersClient(session, base_url=TEST_URL) + + # if the value of state doesn't get sent as a url parameter, + # the mock will fail and this test will fail orders = await cl.list_orders(state='failed') oids = list(o.id for o in orders) @@ -154,13 +143,7 @@ async def test_list_orders_state_invalid_state(session): @respx.mock @pytest.mark.asyncio async def test_list_orders_limit(order_descriptions, session): - # check that the client doesn't try to get the next page when the - # limit is already reached by providing link to next page but not - # registering a response. if the client tries to get the next - # page, an error will occur - - list_url = TEST_URL + 'orders/v2/' - nono_page_url = list_url + '?page_marker=OhNoNo' + nono_page_url = TEST_ORDERS_URL + '?page_marker=OhNoNo' order1, order2, order3 = order_descriptions @@ -171,23 +154,14 @@ async def test_list_orders_limit(order_descriptions, session): "orders": [order1, order2] } mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response) - - page2_response = { - "_links": { - "_self": "string", - }, - "orders": [order3] - } - mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response) - - respx.route(method="GET", url__eq=list_url).mock(return_value=mock_resp) - nono_route = respx.route(method="GET", url__eq=nono_page_url).mock( - return_value=mock_resp2) + respx.get(TEST_ORDERS_URL).return_value = mock_resp cl = OrdersClient(session, base_url=TEST_URL) + + # since nono_page_url is not mocked, an error will occur if the client + # attempts to access the next page when the limit is already reached orders = await cl.list_orders(limit=1) - assert not nono_route.called oids = [o.id for o in orders] assert oids == ['oid1'] @@ -195,8 +169,6 @@ async def test_list_orders_limit(order_descriptions, session): @respx.mock @pytest.mark.asyncio async def test_list_orders_asjson(order_descriptions, session): - list_url = TEST_URL + 'orders/v2/' - order1, order2, order3 = order_descriptions page1_response = { @@ -204,7 +176,7 @@ async def test_list_orders_asjson(order_descriptions, session): "orders": [order1] } mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response) - respx.get(list_url).return_value = mock_resp1 + respx.get(TEST_ORDERS_URL).return_value = mock_resp1 cl = OrdersClient(session, base_url=TEST_URL) orders = await cl.list_orders(as_json=True) @@ -214,9 +186,8 @@ async def test_list_orders_asjson(order_descriptions, session): @respx.mock @pytest.mark.asyncio async def test_create_order(oid, order_description, order_request, session): - create_url = TEST_URL + 'orders/v2/' mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) - respx.post(create_url).return_value = mock_resp + respx.post(TEST_ORDERS_URL).return_value = mock_resp cl = OrdersClient(session, base_url=TEST_URL) order = await cl.create_order(order_request) @@ -227,8 +198,6 @@ async def test_create_order(oid, order_description, order_request, session): @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": [ @@ -245,7 +214,7 @@ async def test_create_order_bad_item_type(order_request, session): ] } mock_resp = httpx.Response(400, json=resp) - respx.post(create_url).return_value = mock_resp + respx.post(TEST_ORDERS_URL).return_value = mock_resp order_request['products'][0]['item_type'] = 'invalid' cl = OrdersClient(session, base_url=TEST_URL) @@ -261,8 +230,6 @@ async def test_create_order_bad_item_type(order_request, session): @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": [ @@ -279,7 +246,7 @@ async def test_create_order_item_id_does_not_exist( ] } mock_resp = httpx.Response(400, json=resp) - respx.post(create_url).return_value = mock_resp + respx.post(TEST_ORDERS_URL).return_value = mock_resp order_request['products'][0]['item_ids'] = \ '4500474_2133707_2021-05-20_2419' cl = OrdersClient(session, base_url=TEST_URL) @@ -295,7 +262,7 @@ async def test_create_order_item_id_does_not_exist( @respx.mock @pytest.mark.asyncio async def test_get_order(oid, order_description, session): - get_url = TEST_URL + 'orders/v2/' + oid + get_url = f'{TEST_ORDERS_URL}/{oid}' mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.get(get_url).return_value = mock_resp @@ -316,7 +283,7 @@ async def test_get_order_invalid_id(session): @pytest.mark.asyncio async def test_get_order_id_doesnt_exist( oid, session, match_pytest_raises): - get_url = TEST_URL + 'orders/v2/' + oid + get_url = f'{TEST_ORDERS_URL}/{oid}' msg = f'Could not load order ID: {oid}.' resp = { @@ -334,7 +301,7 @@ async def test_get_order_id_doesnt_exist( @respx.mock @pytest.mark.asyncio async def test_cancel_order(oid, order_description, session): - cancel_url = TEST_URL + 'orders/v2/' + oid + cancel_url = f'{TEST_ORDERS_URL}/{oid}' order_description['state'] = 'cancelled' mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.put(cancel_url).return_value = mock_resp @@ -356,7 +323,7 @@ async def test_cancel_order_invalid_id(session): @pytest.mark.asyncio async def test_cancel_order_id_doesnt_exist( oid, session, match_pytest_raises): - cancel_url = TEST_URL + 'orders/v2/' + oid + cancel_url = f'{TEST_ORDERS_URL}/{oid}' msg = f'No such order ID: {oid}.' resp = { @@ -375,7 +342,7 @@ async def test_cancel_order_id_doesnt_exist( @pytest.mark.asyncio async def test_cancel_order_id_cannot_be_cancelled( oid, session, match_pytest_raises): - cancel_url = TEST_URL + 'orders/v2/' + oid + cancel_url = f'{TEST_ORDERS_URL}/{oid}' msg = 'Order not in a cancellable state' resp = { @@ -392,8 +359,8 @@ async def test_cancel_order_id_cannot_be_cancelled( @respx.mock @pytest.mark.asyncio -async def test_cancel_orders_by_ids(session, oid, oid2): - bulk_cancel_url = TEST_URL + 'bulk/orders/v2/cancel' +async def test_cancel_orders_by_ids(session, oid): + oid2 = '5ece1dc0-ea81-11eb-837c-acde48001122' test_ids = [oid, oid2] example_result = { "result": { @@ -410,7 +377,7 @@ async def test_cancel_orders_by_ids(session, oid, oid2): } } mock_resp = httpx.Response(HTTPStatus.OK, json=example_result) - respx.post(bulk_cancel_url).return_value = mock_resp + respx.post(TEST_BULK_CANCEL_URL).return_value = mock_resp cl = OrdersClient(session, base_url=TEST_URL) res = await cl.cancel_orders(test_ids) @@ -434,8 +401,6 @@ async def test_cancel_orders_by_ids_invalid_id(session, oid): @respx.mock @pytest.mark.asyncio async def test_cancel_orders_all(session): - bulk_cancel_url = TEST_URL + 'bulk/orders/v2/cancel' - example_result = { "result": { "succeeded": {"count": 2}, @@ -446,7 +411,7 @@ async def test_cancel_orders_all(session): } } mock_resp = httpx.Response(HTTPStatus.OK, json=example_result) - respx.post(bulk_cancel_url).return_value = mock_resp + respx.post(TEST_BULK_CANCEL_URL).return_value = mock_resp cl = OrdersClient(session, base_url=TEST_URL) res = await cl.cancel_orders() @@ -460,7 +425,7 @@ async def test_cancel_orders_all(session): @respx.mock @pytest.mark.asyncio async def test_poll(oid, order_description, session): - get_url = TEST_URL + 'orders/v2/' + oid + get_url = f'{TEST_ORDERS_URL}/{oid}' order_description2 = copy.deepcopy(order_description) order_description2['state'] = 'running' @@ -513,8 +478,6 @@ async def test_poll_invalid_state(oid, session): @respx.mock @pytest.mark.asyncio async def test_aggegated_order_stats(session): - stats_url = TEST_URL + 'stats/orders/v2/' - LOGGER.debug(f'url: {stats_url}') example_stats = { "organization": { "queued_orders": 0, @@ -526,7 +489,7 @@ async def test_aggegated_order_stats(session): } } mock_resp = httpx.Response(HTTPStatus.OK, json=example_stats) - respx.get(stats_url).return_value = mock_resp + respx.get(TEST_STATS_URL).return_value = mock_resp cl = OrdersClient(session, base_url=TEST_URL) res = await cl.aggregated_order_stats() @@ -537,7 +500,7 @@ async def test_aggegated_order_stats(session): @respx.mock @pytest.mark.asyncio async def test_download_asset_md(tmpdir, session): - dl_url = TEST_URL + 'download/?token=IAmAToken' + dl_url = TEST_DOWNLOAD_URL + '/1?token=IAmAToken' md_json = {'key': 'value'} md_headers = { @@ -557,7 +520,7 @@ async def test_download_asset_md(tmpdir, session): @respx.mock @pytest.mark.asyncio async def test_download_asset_img(tmpdir, open_test_img, session): - dl_url = TEST_URL + 'download/?token=IAmAToken' + dl_url = TEST_DOWNLOAD_URL + '/1?token=IAmAToken' img_headers = { 'Content-Type': 'image/tiff', @@ -597,14 +560,14 @@ async def test_download_order_success(tmpdir, order_description, oid, session): ''' # Mock an HTTP response for download - dl_url1 = TEST_URL + 'download/1?token=IAmAToken' - dl_url2 = TEST_URL + 'download/2?token=IAmAnotherToken' + dl_url1 = TEST_DOWNLOAD_URL + '/1?token=IAmAToken' + dl_url2 = TEST_DOWNLOAD_URL + '/2?token=IAmAnotherToken' order_description['_links']['results'] = [ {'location': dl_url1}, {'location': dl_url2} ] - get_url = TEST_URL + 'orders/v2/' + oid + get_url = f'{TEST_ORDERS_URL}/{oid}' mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) respx.get(get_url).return_value = mock_resp diff --git a/tests/integration/test_orders_cli.py b/tests/integration/test_orders_cli.py new file mode 100644 index 000000000..f56be7d36 --- /dev/null +++ b/tests/integration/test_orders_cli.py @@ -0,0 +1,595 @@ +# Copyright 2022 Planet Labs, PBC. +# +# 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. +'''Test Orders CLI''' +from http import HTTPStatus +import json +from pathlib import Path +from unittest.mock import Mock + +from click.testing import CliRunner +import httpx +import pytest +import respx + +from planet.cli import cli + +TEST_URL = 'http://MockNotRealURL/api/path' +TEST_DOWNLOAD_URL = f'{TEST_URL}/download' +TEST_ORDERS_URL = f'{TEST_URL}/orders/v2' + + +# NOTE: These tests use a lot of the same mocked responses as test_orders_api. + + +@pytest.fixture +def invoke(): + def _invoke(extra_args, runner=None): + runner = runner or CliRunner() + args = ['orders', '--base-url', TEST_URL] + extra_args + return runner.invoke(cli.main, args=args) + return _invoke + + +@respx.mock +def test_cli_orders_list_basic(invoke, order_descriptions): + next_page_url = TEST_ORDERS_URL + '/blob/?page_marker=IAmATest' + order1, order2, order3 = order_descriptions + + page1_response = { + "_links": { + "_self": "string", + "next": next_page_url}, + "orders": [order1, order2] + } + mock_resp1 = httpx.Response(HTTPStatus.OK, json=page1_response) + respx.get(TEST_ORDERS_URL).return_value = mock_resp1 + + page2_response = { + "_links": { + "_self": next_page_url}, + "orders": [order3] + } + mock_resp2 = httpx.Response(HTTPStatus.OK, json=page2_response) + respx.get(next_page_url).return_value = mock_resp2 + + result = invoke(['list']) + assert not result.exception + assert [order1, order2, order3] == json.loads(result.output) + + +@respx.mock +def test_cli_orders_list_empty(invoke): + page1_response = { + "_links": { + "_self": "string" + }, + "orders": [] + } + mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['list']) + assert not result.exception + assert [] == json.loads(result.output) + + +@respx.mock +def test_cli_orders_list_state(invoke, order_descriptions): + list_url = TEST_ORDERS_URL + '?state=failed' + + order1, order2, _ = order_descriptions + + page1_response = { + "_links": { + "_self": "string" + }, + "orders": [order1, order2] + } + mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response) + respx.get(list_url).return_value = mock_resp + + # if the value of state doesn't get sent as a url parameter, + # the mock will fail and this test will fail + result = invoke(['list', '--state', 'failed']) + assert not result.exception + assert [order1, order2] == json.loads(result.output) + + +@respx.mock +def test_cli_orders_list_limit(invoke, order_descriptions): + order1, order2, _ = order_descriptions + + page1_response = { + "_links": { + "_self": "string" + }, + "orders": [order1, order2] + } + mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response) + + # limiting is done within the client, no change to api call + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['list', '--limit', '1']) + assert not result.exception + assert [order1] == json.loads(result.output) + + +@respx.mock +def test_cli_orders_list_pretty(invoke, monkeypatch, order_description): + mock_echo_json = Mock() + monkeypatch.setattr(cli.orders, 'echo_json', mock_echo_json) + + page1_response = { + "_links": { + "_self": "string" + }, + "orders": [order_description] + } + mock_resp = httpx.Response(HTTPStatus.OK, json=page1_response) + respx.get(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke(['list', '--pretty']) + assert not result.exception + mock_echo_json.assert_called_once_with([order_description], True) + + +@respx.mock +def test_cli_orders_get(invoke, oid, order_description): + get_url = f'{TEST_ORDERS_URL}/{oid}' + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.get(get_url).return_value = mock_resp + + result = invoke(['get', oid]) + assert not result.exception + assert order_description == json.loads(result.output) + + +@respx.mock +def test_cli_orders_get_id_not_found(invoke, oid): + get_url = f'{TEST_ORDERS_URL}/{oid}' + error_json = {'message': 'A descriptive error message'} + mock_resp = httpx.Response(404, json=error_json) + respx.get(get_url).return_value = mock_resp + + result = invoke(['get', oid]) + assert result.exception + assert 'Error: A descriptive error message\n' == result.output + + +@respx.mock +def test_cli_orders_cancel(invoke, oid, order_description): + cancel_url = f'{TEST_ORDERS_URL}/{oid}' + order_description['state'] = 'cancelled' + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.put(cancel_url).return_value = mock_resp + + result = invoke(['cancel', oid]) + assert not result.exception + assert 'Cancelled\n' == result.output + + +@respx.mock +def test_cli_orders_cancel_id_not_found(invoke, oid): + cancel_url = f'{TEST_ORDERS_URL}/{oid}' + error_json = {'message': 'A descriptive error message'} + mock_resp = httpx.Response(404, json=error_json) + respx.put(cancel_url).return_value = mock_resp + + result = invoke(['cancel', oid]) + assert result.exception + assert 'Error: A descriptive error message\n' == result.output + + +@pytest.fixture +def mock_download_response(oid, order_description): + def _func(): + # Mock an HTTP response for polling and download + order_description['state'] = 'success' + dl_url1 = TEST_DOWNLOAD_URL + '/1?token=IAmAToken' + dl_url2 = TEST_DOWNLOAD_URL + '/2?token=IAmAnotherToken' + order_description['_links']['results'] = [ + {'location': dl_url1}, + {'location': dl_url2} + ] + + get_url = f'{TEST_ORDERS_URL}/{oid}' + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.get(get_url).return_value = mock_resp + + mock_resp1 = httpx.Response( + HTTPStatus.OK, + json={'key': 'value'}, + headers={ + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="m1.json"' + }) + respx.get(dl_url1).return_value = mock_resp1 + + mock_resp2 = httpx.Response( + HTTPStatus.OK, + json={'key2': 'value2'}, + headers={ + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="m2.json"' + }) + respx.get(dl_url2).return_value = mock_resp2 + return _func + + +@respx.mock +def test_cli_orders_download(invoke, mock_download_response, oid): + mock_download_response() + + runner = CliRunner() + with runner.isolated_filesystem() as folder: + result = invoke(['download', oid], runner=runner) + assert not result.exception + + # no message, output is only progress reporting + assert result.output.startswith('\r00:00 - order') + + # Check that the files were downloaded and have the correct contents + f1_path = Path(folder) / 'm1.json' + assert json.load(open(f1_path)) == {'key': 'value'} + f2_path = Path(folder) / 'm2.json' + assert json.load(open(f2_path)) == {'key2': 'value2'} + + +@respx.mock +def test_cli_orders_download_dest(invoke, mock_download_response, oid): + mock_download_response() + + runner = CliRunner() + with runner.isolated_filesystem() as folder: + dest_dir = Path(folder) / 'foobar' + dest_dir.mkdir() + result = invoke(['download', '--dest', 'foobar', oid], runner=runner) + assert not result.exception + + # Check that the files were downloaded to the custom directory + f1_path = dest_dir / 'm1.json' + assert json.load(open(f1_path)) == {'key': 'value'} + f2_path = dest_dir / 'm2.json' + assert json.load(open(f2_path)) == {'key2': 'value2'} + + +@respx.mock +def test_cli_orders_download_overwrite( + invoke, mock_download_response, oid, write_to_tmp_json_file): + mock_download_response() + + runner = CliRunner() + with runner.isolated_filesystem() as folder: + filepath = Path(folder) / 'm1.json' + write_to_tmp_json_file({'foo': 'bar'}, filepath) + + # check the file doesn't get overwritten by default + result = invoke(['download', oid], runner=runner) + assert not result.exception + assert json.load(open(filepath)) == {'foo': 'bar'} + + # check the file gets overwritten + result = invoke(['download', '--overwrite', oid], + runner=runner) + assert not result.exception + assert json.load(open(filepath)) == {'key': 'value'} + + +@pytest.mark.skip('https://github.com/planetlabs/planet-client-python/issues/352') # noqa +@respx.mock +def test_cli_orders_download_quiet(invoke, mock_download_response, oid): + mock_download_response() + + runner = CliRunner() + with runner.isolated_filesystem(): + result = invoke(['download', '-q', oid], runner=runner) + assert not result.exception + + # no progress reporting, just the message + message = 'Downloaded 2 files.\n' + assert message == result.output + + +@pytest.mark.parametrize( + "id_string, expected_ids", + [('4500474_2133707_2021-05-20_2419', ['4500474_2133707_2021-05-20_2419']), + ('4500474_2133707_2021-05-20_2419,4500474_2133707_2021-05-20_2420', + ['4500474_2133707_2021-05-20_2419', '4500474_2133707_2021-05-20_2420']) + ]) +@respx.mock +def test_cli_orders_create_basic_success( + expected_ids, id_string, invoke, order_description): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke([ + 'create', + '--name', 'test', + '--id', id_string, + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile' + ]) + assert not result.exception + assert order_description == json.loads(result.output) + + order_request = { + "name": "test", + "products": [{ + "item_ids": expected_ids, + "item_type": "PSOrthoTile", + "product_bundle": "analytic" + }], + } + sent_request = json.loads(respx.calls.last.request.content) + assert sent_request == order_request + + +def test_cli_orders_create_basic_item_type_invalid(invoke): + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'invalid' + ]) + assert result.exception + assert 'Error: Invalid value: item_type' in result.output + + +def test_cli_orders_create_id_empty(invoke): + result = invoke([ + 'create', + '--name', 'test', + '--id', '', + '--bundle', 'analytic', + '--item-type', 'invalid' + ]) + assert result.exit_code + assert 'id cannot be empty string.' in result.output + + +@respx.mock +def test_cli_orders_create_clip( + invoke, geom_geojson, order_description, write_to_tmp_json_file): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--clip', aoi_file + ]) + assert not result.exception + + order_request = { + "name": "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "tools": [{'clip': {'aoi': geom_geojson}}] + } + sent_request = json.loads(respx.calls.last.request.content) + assert sent_request == order_request + + +@respx.mock +def test_cli_orders_create_clip_featureclass( + invoke, featureclass_geojson, geom_geojson, order_description, + write_to_tmp_json_file): + """Tests that the clip option takes in feature class geojson as well""" + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + fc_file = write_to_tmp_json_file(featureclass_geojson, 'fc.geojson') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--clip', fc_file + ]) + assert not result.exception + + order_request = { + "name": "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "tools": [{'clip': {'aoi': geom_geojson}}] + } + sent_request = json.loads(respx.calls.last.request.content) + assert sent_request == order_request + + +def test_cli_orders_create_clip_invalid_geometry( + invoke, point_geom_geojson, write_to_tmp_json_file): + aoi_file = write_to_tmp_json_file(point_geom_geojson, 'aoi.geojson') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--clip', aoi_file + ]) + assert result.exception + error_msg = ('Error: Invalid value: Invalid geometry type: ' + + 'Point is not Polygon.') + assert error_msg in result.output + + +def test_cli_orders_create_clip_and_tools( + invoke, geom_geojson, write_to_tmp_json_file): + # interestingly, it is important that both clip and tools + # option values lead to valid json files + aoi_file = write_to_tmp_json_file(geom_geojson, 'aoi.geojson') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--clip', aoi_file, + '--tools', aoi_file + ]) + assert result.exception + assert "Specify only one of '--clip' or '--tools'" in result.output + + +@respx.mock +def test_cli_orders_create_cloudconfig( + invoke, geom_geojson, order_description, write_to_tmp_json_file): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + config_json = { + 'amazon_s3': { + 'aws_access_key_id': 'aws_access_key_id', + 'aws_secret_access_key': 'aws_secret_access_key', + 'bucket': 'bucket', + 'aws_region': 'aws_region' + }, + 'archive_type': 'zip' + } + config_file = write_to_tmp_json_file(config_json, 'config.json') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--cloudconfig', config_file + ]) + assert not result.exception + + order_request = { + "name": "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "delivery": config_json + } + sent_request = json.loads(respx.calls.last.request.content) + assert sent_request == order_request + + +@respx.mock +def test_cli_orders_create_email( + invoke, geom_geojson, order_description): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--email' + ]) + assert not result.exception + + order_request = { + "name": "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "notifications": {"email": True} + } + sent_request = json.loads(respx.calls.last.request.content) + assert sent_request == order_request + + +@respx.mock +def test_cli_orders_create_tools( + invoke, geom_geojson, order_description, write_to_tmp_json_file): + mock_resp = httpx.Response(HTTPStatus.OK, json=order_description) + respx.post(TEST_ORDERS_URL).return_value = mock_resp + + tools_json = [{'clip': {'aoi': geom_geojson}}, {'composite': {}}] + tools_file = write_to_tmp_json_file(tools_json, 'tools.json') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--tools', tools_file + ]) + assert not result.exception + + order_request = { + "name": "test", + "products": [{ + "item_ids": ["4500474_2133707_2021-05-20_2419"], + "item_type": "PSOrthoTile", + "product_bundle": "analytic", + }], + "tools": tools_json + } + sent_request = json.loads(respx.calls.last.request.content) + assert sent_request == order_request + + +def test_cli_orders_read_file_json_doesnotexist(invoke): + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--tools', 'doesnnotexist.json' + ]) + assert result.exception + error_msg = ("Error: Invalid value for '--tools': 'doesnnotexist.json': " + + "No such file or directory") + assert error_msg in result.output + + +def test_cli_orders_read_file_json_invalidjson(invoke, tmp_path): + invalid_filename = tmp_path / 'invalid.json' + with open(invalid_filename, 'w') as fp: + fp.write('[Invali]d j*son') + + result = invoke([ + 'create', + '--name', 'test', + '--id', '4500474_2133707_2021-05-20_2419', + '--bundle', 'analytic', + '--item-type', 'PSOrthoTile', + '--tools', invalid_filename + ]) + assert result.exception + error_msg = "Error: File does not contain valid json." + assert error_msg in result.output diff --git a/tests/unit/test_cli_auth.py b/tests/unit/test_cli_auth.py deleted file mode 100644 index ff98e46d6..000000000 --- a/tests/unit/test_cli_auth.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright 2022 Planet Labs, PBC. -# -# 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 unittest.mock import MagicMock - -from click.testing import CliRunner -import pytest - -import planet -from planet.cli import cli - - -@pytest.fixture -def runner(): - return CliRunner() - - -@pytest.fixture(autouse=True) -def patch_session(monkeypatch): - '''Make sure we don't actually make any http calls''' - monkeypatch.setattr(planet, 'Session', MagicMock(spec=planet.Session)) - - -def test_cli_auth_init_bad_pw(runner, monkeypatch): - def apiexcept(*args, **kwargs): - raise planet.exceptions.APIException('nope') - monkeypatch.setattr(planet.Auth, 'from_login', apiexcept) - result = runner.invoke( - cli.main, - args=['auth', 'init'], - input='email\npw\n') - assert 'Error: nope' in result.output - - -def test_cli_auth_init_success(runner, monkeypatch): - mock_api_auth = MagicMock(spec=planet.auth.APIKeyAuth) - mock_auth = MagicMock(spec=planet.Auth) - mock_auth.from_login.return_value = mock_api_auth - monkeypatch.setattr(planet, 'Auth', mock_auth) - - result = runner.invoke( - cli.main, - args=['auth', 'init'], - input='email\npw\n') - mock_auth.from_login.assert_called_once() - mock_api_auth.write.assert_called_once() - assert 'Initialized' in result.output - - -def test_cli_auth_value_failure(runner, monkeypatch): - def authexception(*args, **kwargs): - raise planet.auth.AuthException - - monkeypatch.setattr(planet.Auth, 'from_file', authexception) - - result = runner.invoke(cli.main, ['auth', 'value']) - assert 'Error: Auth information does not exist or is corrupted.' \ - in result.output - - -def test_cli_auth_value_success(runner): - result = runner.invoke(cli.main, ['auth', 'value']) - assert not result.exception - assert result.output == 'testkey\n' diff --git a/tests/unit/test_cli_io.py b/tests/unit/test_cli_io.py new file mode 100644 index 000000000..b3db23813 --- /dev/null +++ b/tests/unit/test_cli_io.py @@ -0,0 +1,28 @@ +# Copyright 2022 Planet Labs, PBC. +# +# 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 unittest.mock import patch + +import pytest + +from planet.cli import io + + +@pytest.mark.parametrize( + "pretty,expected", + [(False, '{"key": "val"}'), (True, '{\n "key": "val"\n}')]) +@patch('planet.cli.io.click.echo') +def test_cli_echo_json(mock_echo, pretty, expected): + obj = {'key': 'val'} + io.echo_json(obj, pretty) + mock_echo.assert_called_once_with(expected) diff --git a/tests/unit/test_cli_orders.py b/tests/unit/test_cli_orders.py deleted file mode 100644 index ee1f4a0ab..000000000 --- a/tests/unit/test_cli_orders.py +++ /dev/null @@ -1,351 +0,0 @@ -# Copyright 2022 Planet Labs, PBC. -# -# 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 unittest.mock import MagicMock, Mock - -from click.testing import CliRunner -import pytest - -import planet -from planet.cli import cli - - -@pytest.fixture -def runner(): - return CliRunner() - - -@pytest.fixture(autouse=True) -def patch_session(monkeypatch): - '''Make sure we don't actually make any http calls''' - monkeypatch.setattr(planet, 'Session', MagicMock(spec=planet.Session)) - - -@pytest.fixture -def patch_ordersclient(monkeypatch): - def patch(to_patch, patch_with): - monkeypatch.setattr(cli.orders.OrdersClient, to_patch, patch_with) - return patch - - -def test_cli_orders_list_empty(runner, patch_ordersclient): - async def lo(*arg, **kwarg): - return [] - patch_ordersclient('list_orders', lo) - - result = runner.invoke(cli.main, ['orders', 'list']) - assert not result.exception - assert '[]' in result.output - - -def test_cli_orders_list_success(runner, patch_ordersclient): - async def lo(*arg, **kwarg): - return [{'order': 'yep'}] - patch_ordersclient('list_orders', lo) - - result = runner.invoke(cli.main, ['orders', 'list']) - assert not result.exception - assert '{"order": "yep"}' in result.output - - -def test_cli_orders_get(runner, patch_ordersclient, order_description, oid): - async def go(*arg, **kwarg): - return planet.models.Order(order_description) - patch_ordersclient('get_order', go) - - result = runner.invoke( - cli.main, ['orders', 'get', oid]) - assert not result.exception - - -def test_cli_orders_cancel(runner, patch_ordersclient, order_description, oid): - async def co(*arg, **kwarg): - return '' - patch_ordersclient('cancel_order', co) - - result = runner.invoke( - cli.main, ['orders', 'cancel', oid]) - assert not result.exception - - -def test_cli_orders_download(runner, patch_ordersclient, oid): - all_test_files = ['file1.json', 'file2.zip', 'file3.tiff', 'file4.jpg'] - - async def do(*arg, **kwarg): - return all_test_files - patch_ordersclient('download_order', do) - - async def poll(*arg, **kwarg): - return - patch_ordersclient('poll', poll) - - # Download should not report anything - expected = '' - - # allow for some progress reporting - result = runner.invoke( - cli.main, ['orders', 'download', oid]) - assert not result.exception - assert expected in result.output - - # test quiet option, should be no progress reporting - result = runner.invoke( - cli.main, ['orders', 'download', '-q', oid]) - assert not result.exception - assert expected == result.output - - -class AsyncMock(Mock): - '''Mock an async function''' - async def __call__(self, *args, **kwargs): - return super().__call__(*args, **kwargs) - - -@pytest.fixture -def cloudconfig(): - return { - 'amazon_s3': { - 'aws_access_key_id': 'aws_access_key_id', - 'aws_secret_access_key': 'aws_secret_access_key', - 'bucket': 'bucket', - 'aws_region': 'aws_region' - }, - 'archive_type': 'zip', - } - - -@pytest.fixture -def clipaoi(feature_geojson, write_to_tmp_json_file): - return write_to_tmp_json_file(feature_geojson, 'clip.json') - - -@pytest.fixture -def tools_json(geom_geojson): - return [ - { - 'clip': {'aoi': geom_geojson} - }, { - 'composite': {} - } - ] - - -@pytest.fixture -def tools(tools_json, write_to_tmp_json_file): - return write_to_tmp_json_file(tools_json, 'tools.json') - - -@pytest.fixture -def mock_create_order(patch_ordersclient, order_description): - mock_create_order = AsyncMock( - return_value=planet.models.Order(order_description)) - patch_ordersclient('create_order', mock_create_order) - return mock_create_order - - -@pytest.fixture -def test_id(order_request): - return order_request['products'][0]['item_ids'][0] - - -def test_cli_read_file_geojson(clipaoi, geom_geojson): - with open(clipaoi, 'r') as cfile: - res = cli.orders.read_file_geojson({}, 'clip', cfile) - assert res == geom_geojson - - -@pytest.fixture -def create_order_basic_cmds(order_request, test_id): - product = order_request['products'][0] - return [ - 'orders', 'create', - '--name', order_request['name'], - '--id', test_id, - '--bundle', product['product_bundle'], - '--item-type', product['item_type'] - ] - - -@pytest.fixture -def name(order_request): - return order_request['name'] - - -@pytest.fixture -def products(order_request, test_id): - product = order_request['products'][0] - return [ - planet.order_request.product( - [test_id], - product['product_bundle'], - product['item_type']) - ] - - -def test_cli_orders_create_cloudconfig( - runner, mock_create_order, create_order_basic_cmds, name, products, - cloudconfig, write_to_tmp_json_file - ): - cc_file = write_to_tmp_json_file(cloudconfig, 'cloudconfig.json') - basic_result = runner.invoke( - cli.main, create_order_basic_cmds + ['--cloudconfig', cc_file] - ) - assert not basic_result.exception - - mock_create_order.assert_called_once() - - expected_details = { - 'name': name, - 'products': products, - 'delivery': cloudconfig - } - mock_create_order.assert_called_with(expected_details) - - -def test_cli_orders_create_clip( - runner, mock_create_order, create_order_basic_cmds, name, products, - clipaoi, geom_geojson - ): - basic_result = runner.invoke( - cli.main, create_order_basic_cmds + ['--clip', clipaoi] - ) - assert not basic_result.exception - - mock_create_order.assert_called_once() - - expected_details = { - 'name': name, - 'products': products, - 'tools': [{'clip': {'aoi': geom_geojson}}] - } - mock_create_order.assert_called_with(expected_details) - - -def test_cli_orders_create_tools( - runner, mock_create_order, create_order_basic_cmds, name, products, - tools, tools_json): - basic_result = runner.invoke( - cli.main, create_order_basic_cmds + ['--tools', tools] - ) - assert not basic_result.exception - - mock_create_order.assert_called_once() - - expected_details = { - 'name': name, - 'products': products, - 'tools': tools_json - } - mock_create_order.assert_called_with(expected_details) - - -def test_cli_orders_create_validate_id( - runner, mock_create_order, order_request, test_id - ): - # uuid generated with https://www.uuidgenerator.net/ - test_id2 = '65f4aa35-b46b-48ba-b165-12b49986795c' - success_ids = ','.join([test_id, test_id2]) - fail_ids = '1,,2' - - product = order_request['products'][0] - - # id string is correct format - success_mult_ids_result = runner.invoke( - cli.main, [ - 'orders', 'create', - '--name', order_request['name'], - '--id', success_ids, - '--bundle', product['product_bundle'], - '--item-type', product['item_type'] - ]) - - assert not success_mult_ids_result.exception - - # id string is wrong format - failed_mult_ids_result = runner.invoke( - cli.main, [ - 'orders', 'create', - '--name', order_request['name'], - '--id', fail_ids, - '--bundle', product['product_bundle'], - '--item-type', product['item_type'] - ]) - assert failed_mult_ids_result.exception - assert "id cannot be empty" in failed_mult_ids_result.output - - -def test_cli_orders_create_validate_item_type( - runner, mock_create_order, order_request, test_id - ): - # item type is not valid for bundle - failed_item_type_result = runner.invoke( - cli.main, [ - 'orders', 'create', - '--name', order_request['name'], - '--id', test_id, - '--bundle', 'analytic_udm2', - '--item-type', 'PSScene3Band' - ]) - assert failed_item_type_result.exception - assert "Invalid value: item_type" in failed_item_type_result.output - - -def test_cli_orders_create_validate_cloudconfig( - runner, mock_create_order, create_order_basic_cmds, - tmp_path, - ): - # write invalid text to file - cloudconfig = tmp_path / 'cc.json' - with open(cloudconfig, 'w') as fp: - fp.write('') - - wrong_format_result = runner.invoke( - cli.main, create_order_basic_cmds + ['--cloudconfig', cloudconfig] - ) - assert wrong_format_result.exception - assert "File does not contain valid json." \ - in wrong_format_result.output - - # cloudconfig file doesn't exist - doesnotexistfile = tmp_path / 'doesnotexist.json' - doesnotexit_result = runner.invoke( - cli.main, create_order_basic_cmds + ['--cloudconfig', doesnotexistfile] - ) - assert doesnotexit_result.exception - assert "No such file or directory" in doesnotexit_result.output - - -def test_cli_orders_create_validate_tools( - runner, mock_create_order, create_order_basic_cmds, - tools, clipaoi, - ): - - clip_and_tools_result = runner.invoke( - cli.main, - create_order_basic_cmds + ['--tools', tools, '--clip', clipaoi] - ) - assert clip_and_tools_result.exception - - -def test_cli_orders_create_validate_clip( - runner, mock_create_order, create_order_basic_cmds, - point_geom_geojson, write_to_tmp_json_file - ): - clip_point = write_to_tmp_json_file(point_geom_geojson, 'point.json') - - clip_point_result = runner.invoke( - cli.main, create_order_basic_cmds + ['--clip', clip_point] - ) - assert clip_point_result.exception - assert "Invalid geometry type: Point is not Polygon" in \ - clip_point_result.output