diff --git a/CBCClientHowTo.md b/CBCClientHowTo.md index 6e9a11a..9c4e570 100644 --- a/CBCClientHowTo.md +++ b/CBCClientHowTo.md @@ -3,6 +3,7 @@ ## Create CBCClient instance ```python +from requests_oauthlib import OAuth1 from connect_ext_ppr.client import CBCClient @@ -17,8 +18,7 @@ app_id = '*****' client = CBCClient( endpoint=endpoint, - oauth_key=client_id, - oauth_secret=client_secret, + auth=OAuth1(client_id, client_secret), app_id=app_id, ) diff --git a/connect_ext_ppr/client/auth.py b/connect_ext_ppr/client/auth.py new file mode 100644 index 0000000..232dad2 --- /dev/null +++ b/connect_ext_ppr/client/auth.py @@ -0,0 +1,22 @@ +from requests.auth import AuthBase + + +class APSTokenAuth(AuthBase): + ''' + Need to consider that APS Token has a very limited time validity and hence + the client using this type auth should not be reused. + ''' + + def __init__( + self, + aps_token: str, + aps_identity: str, + ): + self.aps_token = aps_token + self.aps_identity = aps_identity + + def __call__(self, r): + r.headers['aps-token'] = self.aps_token + r.headers['aps-identity-id'] = self.aps_identity + + return r diff --git a/connect_ext_ppr/client/client.py b/connect_ext_ppr/client/client.py index b0825dc..13010e6 100644 --- a/connect_ext_ppr/client/client.py +++ b/connect_ext_ppr/client/client.py @@ -1,6 +1,6 @@ from io import FileIO from json import JSONDecodeError -from typing import Dict, Optional +from typing import Dict, Optional, Union from requests import ( Request, @@ -9,6 +9,7 @@ ) from requests_oauthlib import OAuth1 +from connect_ext_ppr.client.auth import APSTokenAuth from connect_ext_ppr.client.exception import ClientError from connect_ext_ppr.client.ns import ( Collection, @@ -20,35 +21,31 @@ class CBCClient: def __init__( - self, - endpoint: str, - oauth_key: str, - oauth_secret: str, - app_id: str, - verify_certificate: bool = True, - default_headers: Dict[str, str] = None, + self, + endpoint: str, + auth: Union[OAuth1, APSTokenAuth], + app_id: str = None, + verify_certificate: bool = True, + default_headers: Dict[str, str] = None, ): super().__init__() self.endpoint = endpoint - self.auth_key = oauth_key - self.auth_secret = oauth_secret self.app_id = app_id self.verify = verify_certificate self.default_headers = default_headers self.path = self.endpoint + self.auth = auth if not default_headers: self.default_headers = { - 'User-Agent': 'Connect-CBC-Client', + 'user-agent': 'Connect-CBC-Client', } - self.default_headers['aps-resource-id'] = self.app_id - - self.auth = OAuth1( - client_key=oauth_key, - client_secret=oauth_secret, - ) + if type(auth) == OAuth1: + if not app_id: + raise ValueError('Parameter app_id can not be empty in case of OAuth1.') + self.default_headers['aps-resource-id'] = self.app_id def execute_request( self, @@ -148,11 +145,7 @@ def resource(self, resource_id: str): f'{self.path}/aps/2/resources/{resource_id}', ) - def get( - self, - resource_id: str, - **kwargs, - ): + def get(self, resource_id: str, **kwargs): if not isinstance(resource_id, str): raise TypeError('`identifier` must be a string.') diff --git a/connect_ext_ppr/client/exception.py b/connect_ext_ppr/client/exception.py index 1ad5f44..a7aa2ed 100644 --- a/connect_ext_ppr/client/exception.py +++ b/connect_ext_ppr/client/exception.py @@ -15,6 +15,6 @@ def __init__( self.status_code = response.status_code if response else None self.cause = cause try: - self.json = response.json() if response else None + self.json = response.json() if response is not None else None except JSONDecodeError: self.json = None diff --git a/connect_ext_ppr/services/cbc_hub.py b/connect_ext_ppr/services/cbc_hub.py index 61a2c0a..eb3395d 100644 --- a/connect_ext_ppr/services/cbc_hub.py +++ b/connect_ext_ppr/services/cbc_hub.py @@ -1,28 +1,37 @@ import base64 from functools import cached_property -from io import FileIO +from io import BufferedReader import re from typing import Dict +from requests_oauthlib import OAuth1 + from connect_ext_ppr.client import CBCClient +from connect_ext_ppr.client.auth import APSTokenAuth from connect_ext_ppr.client.exception import ClientError from connect_ext_ppr.models.cbc_extenstion import HubCredential class CBCService: + FLAT_CATALOG_TYPE = 'http://ingrammicro.com/pa/flat-catalog' PLM_TYPE = 'http://com.odin.platform/inhouse-products/application' SUBSCRIPTION_TYPE = 'http://parallels.com/aps/types/pa/subscription' ADAPTER_TYPE = 'http://connect.cloudblue.com/aps-openapi-adapter/app' + ADMIN_USER_TYPE = 'http://aps-standard.org/types/core/admin-user' + ACCOUNT_TYPE = 'http://aps-standard.org/types/core/account' def __init__(self, hub_credential: HubCredential, verify_certificate: bool = False): self.hub_credential = hub_credential + self.verify_certificate = verify_certificate self.__validate_hub_credentials_object() self.client = CBCClient( endpoint=hub_credential.controller_url, - oauth_key=hub_credential.oauth_key, - oauth_secret=hub_credential.oauth_secret, + auth=OAuth1( + hub_credential.oauth_key, + hub_credential.oauth_secret, + ), app_id=hub_credential.app_id, verify_certificate=verify_certificate, ) @@ -68,6 +77,47 @@ def subscription_service(self): def adapter_service(self): return self.client(self.ADAPTER_TYPE) + @cached_property + def account_service(self): + return self.client(self.ACCOUNT_TYPE) + + def get_aps_token_auth(self, account_id: int) -> APSTokenAuth: + accounts = self.account_service.get(id=account_id) + + if not accounts: + raise ValueError(f'Reseller Account not found for id {account_id}') + account_uuid = accounts[0]['aps']['id'] + + # Identify Reseller Admin User + reseller_admin_users = self.client.collection( + f'aps/2/collections/admin-users?organization.id={account_id}', + ).get() + + if not reseller_admin_users: + raise ValueError(f'Admin user not found for reseller {account_id}') + user_id = reseller_admin_users[0]['userId'] + + # Get Reseller Admin User APS Token + token_object = self.adapter_service.getToken.get( + user_id=user_id, + ) + + return APSTokenAuth( + token_object['aps_token'], + account_uuid, + ) + + def get_aps_token_client(self, account_id: int) -> CBCClient: + return CBCClient( + endpoint=self.hub_credential.controller_url, + auth=self.get_aps_token_auth(account_id), + verify_certificate=self.verify_certificate, + ) + + def get_flat_catalog_service(self, account_id: int): + aps_token_client = self.get_aps_token_client(account_id) + return aps_token_client(self.FLAT_CATALOG_TYPE) + def get_product_details(self, product_id: str): return self.plm_service.appDetails[product_id].get( fulfillmentSystem='connect', @@ -91,7 +141,7 @@ def update_product(self, product_id: str): }, ) - def parse_ppr(self, file: FileIO): + def parse_ppr(self, file: BufferedReader): base64_content = base64.b64encode(file.read()).decode('ascii') return self.plm_service.action( @@ -122,3 +172,110 @@ def search_task_logs_by_name(self, partial_name: str): return self.adapter_service.getTaskLog.get( task_name=f'%{partial_name}%', ) + + def parse_price_file( + self, + account_id: int, + vendor_id: str, + file: BufferedReader, + ) -> Dict: + """ + + This method is used to parse price file. + + @param account_id: Reseller account ID associated with Marketplace + @param vendor_id: Vendor ID owner of the product for which price is getting updated. + @param file: XLSX file + @return: Dict containing the information about parsed data for the price file. + """ + headers = { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } + + fcs = self.get_flat_catalog_service(account_id) + return fcs.flat_catalog.price_import_wizard.action( + name=f'upload?vendorId={vendor_id}', + file=file, + headers=headers, + ) + + def prepare_price_proposal( + self, + account_id: int, + parsed_prices: Dict, + export_costs: bool, + export_prices: bool, + export_msrp: bool, + effective_date: str, + ) -> Dict: + """ + + This method creates a proposal for the price change. This is mandatory + to create a proposal before the new prices can be applied. + + @param account_id: Reseller account ID associated with Marketplace + @param parsed_prices: Dict from parse_price_file method + @param export_costs: Do you want to change cost data? + @param export_prices: Do you want to change price data? + @param export_msrp: Do you want to change MSRP data? + @param effective_date: Date in format 'MM/DD/YYYY'. + From when this prices should be reflected? + @return: Dict with details of proposal. + """ + payload = { + 'exportCosts': export_costs, + 'exportPrices': export_prices, + 'exportMSRP': export_msrp, + 'effectiveDate': effective_date, + 'priceModel': parsed_prices['pricingModel'], + 'feeType': parsed_prices['feeType'], + 'vendorId': parsed_prices['vendorId'], + } + + fcs = self.get_flat_catalog_service(account_id) + return fcs.flat_catalog.price_import_wizard[parsed_prices['dataId']].action( + name='prepare-proposals', + payload=payload, + ) + + def apply_prices( + self, + account_id: int, + parsed_prices: Dict, + export_costs: bool, + export_prices: bool, + export_msrp: bool, + effective_date: str, + file_name: str, + ): + """ + + This method can be used to apply price changes created through proposal. + + @param account_id: Reseller account ID associated with Marketplace. + @param parsed_prices: Dict from parse_price_file method. + @param export_costs: Do you want to apply cost data? + @param export_prices: Do you want to apply price data? + @param export_msrp: Do you want to apply MSRP data? + @param effective_date: Date in format 'MM/DD/YYYY'. + From when this prices should be reflected? + @param file_name: Name of the file used to create the proposal. + """ + payload = { + 'exportCosts': export_costs, + 'exportPrices': export_prices, + 'exportMSRP': export_msrp, + 'effectiveDate': effective_date, + 'priceModel': parsed_prices['pricingModel'], + 'feeType': parsed_prices['feeType'], + 'fileName': file_name, + } + + fcs = self.get_flat_catalog_service(account_id) + headers = fcs.flat_catalog.price_import_wizard[parsed_prices['dataId']].action( + name='set-prices', + payload=payload, + output='headers', + ) + + return headers['APS-Info'] if 'APS-Info' in headers.keys() else None diff --git a/tests/client/test_client.py b/tests/client/test_client.py index efc4e1e..d6b7c2f 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -4,6 +4,7 @@ import pytest import responses from requests import Session +from requests_oauthlib import OAuth1 from connect_ext_ppr.client import CBCClient from connect_ext_ppr.client.exception import ClientError @@ -84,8 +85,10 @@ def test_client_with_default_headers( cbc_client = CBCClient( endpoint=cbc_endpoint, - oauth_key=cbc_oauth_key, - oauth_secret=cbc_oauth_secret, + auth=OAuth1( + cbc_oauth_key, + cbc_oauth_secret, + ), default_headers=headers, app_id=cbc_app_id, ) @@ -95,6 +98,24 @@ def test_client_with_default_headers( assert cbc_client.default_headers == headers +def test_client_with_oauth_without_app_id( + cbc_endpoint, + cbc_oauth_key, + cbc_oauth_secret, + cbc_app_id, +): + headers = {'Custom': 'Value'} + with pytest.raises(ValueError): + CBCClient( + endpoint=cbc_endpoint, + auth=OAuth1( + cbc_oauth_key, + cbc_oauth_secret, + ), + default_headers=headers, + ) + + def test_collection_service_discovery_empty_type(cbc_client): with pytest.raises(ValueError): cbc_client('') diff --git a/tests/client/test_service.py b/tests/client/test_service.py index c309ce0..6a892ab 100644 --- a/tests/client/test_service.py +++ b/tests/client/test_service.py @@ -149,3 +149,18 @@ def test_service_discovery_collection_blank_collection_value( with pytest.raises(ValueError): cbc_client(flat_catalog_type).collection('') + + +@responses.activate +def test_service_action_with_payload_and_file( + cbc_client, + flat_catalog_type, +): + with pytest.raises(ValueError): + cbc_client(flat_catalog_type).action( + name='upload', + payload={ + 'key': 'value', + }, + file='new file', + ) diff --git a/tests/conftest.py b/tests/conftest.py index 768ff9a..56eda43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import pandas as pd import pytest from connect.client import AsyncConnectClient, ConnectClient +from requests_oauthlib import OAuth1 from sqlalchemy.orm import sessionmaker from connect_ext_ppr.client import CBCClient @@ -410,8 +411,7 @@ def cbc_client( ): return CBCClient( endpoint=cbc_endpoint, - oauth_key=cbc_oauth_key, - oauth_secret=cbc_oauth_secret, + auth=OAuth1(cbc_oauth_key, cbc_oauth_secret), app_id=cbc_app_id, ) @@ -724,3 +724,58 @@ def task_logs_response(): 'task_id': 106, }, ] + + +@pytest.fixture +def parse_price_file_response(): + return { + 'status': 'PARSED', + 'priceListStructure': [ + 'MPN', 'Vendor ID', 'Vendor Name', + 'Service Template / Product Line', 'Product', + 'Billing Period', 'Subscription Period', + 'Billing Model', 'UOM', 'Lower Limit', + 'Effective Date', 'Cost Billing Period', + 'Cost Currency', 'Cost', 'Price Currency', + 'Price', 'MSRP', 'Margin', 'Reseller Margin', + 'Fee Type', 'Subscriptions', 'Seats', + 'Active'], + 'pricingModel': 'FLAT', + 'feeType': 'RECURRING', + 'vendorId': 'VA-000-000', + 'dataId': 1, + } + + +@pytest.fixture +def reseller_accounts(): + return json.load(open('./tests/fixtures/reseller_accounts.json')) + + +@pytest.fixture +def reseller_admin_users(): + return json.load(open('./tests/fixtures/reseller_admin_users.json')) + + +@pytest.fixture +def aps_token_response(): + return { + 'aps_token': 'Fake Token', + 'controller_uri': 'https://iamcontroller.com/', + } + + +@pytest.fixture +def price_proposal_response(): + return { + 'status': 'PREPARED', + 'dataId': 1, + 'overridings': { + 'effectiveDate': [ + 'CFQ7TTC0LF8S:0002', 'CFQ7TTC0LF8R:0001', + 'CFQ7TTC0LF8S:0002', 'CFQ7TTC0LF8R:0001', + 'CFQ7TTC0LF8R:0001', 'CFQ7TTC0LF8S:0002', + ], + 'currency': [], + }, + } diff --git a/tests/fixtures/Sweet_Pies_Price_List_USD.xlsx b/tests/fixtures/Sweet_Pies_Price_List_USD.xlsx new file mode 100644 index 0000000..0021c64 Binary files /dev/null and b/tests/fixtures/Sweet_Pies_Price_List_USD.xlsx differ diff --git a/tests/fixtures/Sweet_Pies_v2.xlsx b/tests/fixtures/Sweet_Pies_v2.xlsx index ec7c6ae..23b8d45 100644 Binary files a/tests/fixtures/Sweet_Pies_v2.xlsx and b/tests/fixtures/Sweet_Pies_v2.xlsx differ diff --git a/tests/fixtures/reseller_accounts.json b/tests/fixtures/reseller_accounts.json new file mode 100644 index 0000000..acfbe0e --- /dev/null +++ b/tests/fixtures/reseller_accounts.json @@ -0,0 +1,54 @@ +[ + { + "id": 1000001, + "type": "RESELLER", + "locked": false, + "personal": false, + "companyName": "IngramMicro US", + "techContact": { + "email": "us@example.com", + "locale": "en_US", + "telFax": "####", + "telVoice": "1#212#34234#", + "givenName": "US", + "familyName": "us", + "middleName": "", + "organizationName": "" + }, + "adminContact": { + "email": "us@example.com", + "locale": "en_US", + "telFax": "####", + "telVoice": "1#212#34234#", + "givenName": "US", + "familyName": "us", + "middleName": "", + "organizationName": "" + }, + "addressPostal": { + "region": "CA", + "locality": "CA", + "postalCode": "10001", + "countryName": "us", + "streetAddress": "St 2", + "extendedAddress": "" + }, + "billingContact": { + "email": "us@example.com", + "locale": "en_US", + "telFax": "####", + "telVoice": "1#212#34234#", + "givenName": "US", + "familyName": "us", + "middleName": "", + "organizationName": "" + }, + "aps": { + "modified": "2023-07-13T13:22:15Z", + "id": "927b0062-4aac-468d-88b2-bfdea9b42fa3", + "type": "http://parallels.com/aps/types/pa/account/1.2", + "status": "aps:ready", + "revision": 8 + } + } +] diff --git a/tests/fixtures/reseller_admin_users.json b/tests/fixtures/reseller_admin_users.json new file mode 100644 index 0000000..9ce80fd --- /dev/null +++ b/tests/fixtures/reseller_admin_users.json @@ -0,0 +1,36 @@ +[ + { + "email": "us@example.com", + "login": "reseller_us", + "roles": [ + 2 + ], + "locale": "en_US", + "locked": false, + "telFax": "", + "userId": 4, + "telWork": "1#212#34234#", + "disabled": false, + "fullName": "US us", + "memberId": 1000001, + "givenName": "US", + "familyName": "us", + "displayName": "US us", + "servicesMode": "NONE", + "addressPostal": { + "region": "CA", + "locality": "CA", + "postalCode": "10001", + "countryName": "us", + "streetAddress": "St 2" + }, + "isAccountAdmin": true, + "aps": { + "modified": "2023-07-13T12:54:28Z", + "id": "787dceb3-ad48-4be0-adb8-5d069e9576ca", + "type": "http://parallels.com/aps/types/pa/admin-user/1.2", + "status": "aps:ready", + "revision": 2 + } + } +] diff --git a/tests/services/test_cbc_hub.py b/tests/services/test_cbc_hub.py index 78ad061..d8cbf35 100644 --- a/tests/services/test_cbc_hub.py +++ b/tests/services/test_cbc_hub.py @@ -1,10 +1,12 @@ from copy import deepcopy from unittest import TestCase +from unittest.mock import patch, PropertyMock import pytest import responses from connect_ext_ppr.client.exception import ClientError +from connect_ext_ppr.client.ns import Service from connect_ext_ppr.services.cbc_hub import CBCService @@ -436,3 +438,245 @@ def test_search_task_logs_by_name_positive( task_logs = cbc_service.search_task_logs_by_name(tracking_id) TestCase().assertListEqual(task_logs, task_logs_response) + + +def __mock_for_price_operations( + account_id, + service_id, + cbc_endpoint, + aps_controller_details, + reseller_accounts, + reseller_admin_users, + aps_token_response, +): + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps', + json=aps_controller_details, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.ACCOUNT_TYPE})&id={account_id}', + json=reseller_accounts, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/collections/admin-users?organization.id={account_id}', + json=reseller_admin_users, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/{service_id}/getToken?' + f"user_id={reseller_admin_users[0]['userId']}", + json=aps_token_response, + ) + + +@responses.activate +@patch.object(Service, 'service_path', new_callable=PropertyMock) +def test_parse_price_file( + mock_service_path, + hub_credentials, + cbc_endpoint, + aps_controller_details, + services, + reseller_accounts, + reseller_admin_users, + aps_token_response, + parse_price_file_response, +): + service_id = services[0]['aps']['id'] + account_id = 1000001 + vendor_id = 'VA-000-000' + + mock_service_path.return_value = f'{cbc_endpoint}/aps/2/resources/{service_id}' + __mock_for_price_operations( + account_id, + service_id, + cbc_endpoint, + aps_controller_details, + reseller_accounts, + reseller_admin_users, + aps_token_response, + ) + + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/{service_id}/flat-catalog/' + f'price-import-wizard/upload?vendorId={vendor_id}', + json=parse_price_file_response, + ) + + with open('./tests/fixtures/Sweet_Pies_Price_List_USD.xlsx', 'rb') as file: + cbc_service = CBCService(hub_credentials) + response = cbc_service.parse_price_file( + account_id, vendor_id, file, + ) + + TestCase().assertDictEqual(response, parse_price_file_response) + + +@responses.activate +@patch.object(Service, 'service_path', new_callable=PropertyMock) +def test_prepare_price_proposal( + mock_service_path, + hub_credentials, + cbc_endpoint, + aps_controller_details, + services, + reseller_accounts, + reseller_admin_users, + aps_token_response, + parse_price_file_response, + price_proposal_response, +): + service_id = services[0]['aps']['id'] + account_id = 1000001 + + mock_service_path.return_value = f'{cbc_endpoint}/aps/2/resources/{service_id}' + __mock_for_price_operations( + account_id, + service_id, + cbc_endpoint, + aps_controller_details, + reseller_accounts, + reseller_admin_users, + aps_token_response, + ) + + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/{service_id}/flat-catalog/' + f"price-import-wizard/{parse_price_file_response['dataId']}/prepare-proposals", + json=price_proposal_response, + ) + + cbc_service = CBCService(hub_credentials) + response = cbc_service.prepare_price_proposal( + account_id, + parse_price_file_response, + True, + True, + True, + '07/20/2023', + ) + TestCase().assertDictEqual(response, price_proposal_response) + + +@responses.activate +@patch.object(Service, 'service_path', new_callable=PropertyMock) +def test_apply_prices( + mock_service_path, + hub_credentials, + cbc_endpoint, + aps_controller_details, + services, + reseller_accounts, + reseller_admin_users, + aps_token_response, + parse_price_file_response, + price_proposal_response, +): + service_id = services[0]['aps']['id'] + account_id = 1000001 + file_name = 'Sweet_Pies_Price_List_USD.xlsx' + + mock_service_path.return_value = f'{cbc_endpoint}/aps/2/resources/{service_id}' + __mock_for_price_operations( + account_id, + service_id, + cbc_endpoint, + aps_controller_details, + reseller_accounts, + reseller_admin_users, + aps_token_response, + ) + + responses.add( + method='POST', + url=f'{cbc_endpoint}/aps/2/resources/{service_id}/flat-catalog/' + f"price-import-wizard/{parse_price_file_response['dataId']}/set-prices", + headers={ + 'APS-Info': f'Update prices by {file_name}', + }, + ) + + cbc_service = CBCService(hub_credentials) + response = cbc_service.apply_prices( + account_id, + parse_price_file_response, + True, + True, + True, + '07/20/2023', + file_name, + ) + + assert response == f'Update prices by {file_name}' + + +@responses.activate +@patch.object(Service, 'service_path', new_callable=PropertyMock) +def test_get_aps_token_auth_reseller_account_not_found( + mock_service_path, + hub_credentials, + cbc_endpoint, + services, + aps_controller_details, +): + service_id = services[0]['aps']['id'] + account_id = 1000001 + mock_service_path.return_value = f'{cbc_endpoint}/aps/2/resources/{service_id}' + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps', + json=aps_controller_details, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.ACCOUNT_TYPE})&id={account_id}', + json=[], + ) + + with pytest.raises(ValueError): + cbc_service = CBCService(hub_credentials) + cbc_service.get_aps_token_auth(account_id) + + +@responses.activate +@patch.object(Service, 'service_path', new_callable=PropertyMock) +def test_get_aps_token_auth_reseller_admin_not_found( + mock_service_path, + hub_credentials, + cbc_endpoint, + services, + aps_controller_details, + reseller_accounts, +): + service_id = services[0]['aps']['id'] + account_id = 1000001 + mock_service_path.return_value = f'{cbc_endpoint}/aps/2/resources/{service_id}' + + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps', + json=aps_controller_details, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/resources/?' + f'implementing({CBCService.ACCOUNT_TYPE})&id={account_id}', + json=reseller_accounts, + ) + responses.add( + method='GET', + url=f'{cbc_endpoint}/aps/2/collections/admin-users?organization.id={account_id}', + json=[], + ) + + with pytest.raises(ValueError): + cbc_service = CBCService(hub_credentials) + cbc_service.get_aps_token_auth(account_id)