diff --git a/README.rst b/README.rst index 4557a87..ea16061 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,32 @@ Welcome to the python ebaysdk ============================= -This SDK is a programmatic interface into the eBay APIs. It simplifies development and cuts development time by standardizing calls, response processing, error handling, and debugging across the Finding, Shopping, Merchandising & Trading APIs. +This SDK is a programmatic interface into the eBay APIs. It simplifies development and cuts development time by standardizing calls, response processing, error handling, and debugging across the Browse, Finding, Shopping, Merchandising & Trading APIs. -Quick Example:: +**Important Note**: The Finding and Shopping APIs were decommissioned on February 5, 2025. Please migrate to the new Browse API for continued functionality. + +Quick Example (Browse API - Recommended):: + + from ebaysdk.exception import ConnectionError + from ebaysdk.browse import Connection + + try: + api = Connection(appid='YOUR_APPID', certid='YOUR_CERTID', config_file=None) + api.execute('search', {'q': 'legos', 'limit': 5}) + + assert(api.response.status_code == 200) + data = api.response.json() + items = data.get('itemSummaries', []) + assert(len(items) > 0) + + item = items[0] + print(f"Title: {item.get('title')}") + print(f"Price: {item.get('price', {}).get('value')} {item.get('price', {}).get('currency')}") + + except ConnectionError as e: + print(e) + +Legacy Example (Finding API - Deprecated):: import datetime from ebaysdk.exception import ConnectionError @@ -11,12 +34,12 @@ Quick Example:: try: api = Connection(appid='YOUR_APPID_HERE', config_file=None) - response = api.execute('findItemsAdvanced', {'keywords': 'legos'}) + response = api.execute('findItemsAdvanced', {'keywords': 'legos'}) - assert(response.reply.ack == 'Success') + assert(response.reply.ack == 'Success') assert(type(response.reply.timestamp) == datetime.datetime) assert(type(response.reply.searchResult.item) == list) - + item = response.reply.searchResult.item[0] assert(type(item.listingInfo.endTime) == datetime.datetime) assert(type(response.dict()) == dict) @@ -26,6 +49,36 @@ Quick Example:: print(e.response.dict()) +Migrating from Finding/Shopping APIs to Browse API +-------------------------------------------------- + +The Finding and Shopping APIs were decommissioned on February 5, 2025. +The Browse API is the recommended replacement that provides modern REST endpoints with JSON responses. + +Key differences: +* **Authentication**: Browse API uses OAuth 2.0 Bearer tokens instead of API keys +* **Response Format**: JSON instead of XML +* **Endpoints**: Modern REST endpoints instead of SOAP-style calls +* **Features**: Enhanced search capabilities and better performance + +Migration examples: + +Finding API (Deprecated):: + + from ebaysdk.finding import Connection + api = Connection(appid='YOUR_APPID') + response = api.execute('findItemsAdvanced', {'keywords': 'iPhone'}) + items = response.reply.searchResult.item + +Browse API (Recommended):: + + from ebaysdk.browse import Connection + api = Connection(appid='YOUR_APPID', certid='YOUR_CERTID') + response = api.execute('search', {'q': 'iPhone'}) + items = response.json().get('itemSummaries', []) + +For more detailed migration examples, see the `samples/browse.py` file. + Migrating from v1 to v2 ----------------------- @@ -37,9 +90,10 @@ Getting Started 1) SDK Classes +* `Browse API Class`_ - modern REST API for searching and retrieving eBay items (replaces Finding & Shopping APIs). * `Trading API Class`_ - secure, authenticated access to private eBay data. -* `Finding API Class`_ - access eBay's next generation search capabilities. -* `Shopping API Class`_ - performance-optimized, lightweight APIs for accessing public eBay data. +* `Finding API Class`_ - access eBay's next generation search capabilities (DEPRECATED - use Browse API). +* `Shopping API Class`_ - performance-optimized, lightweight APIs for accessing public eBay data (DEPRECATED - use Browse API). * `Merchandising API Class`_ - find items and products on eBay that provide good value or are otherwise popular with eBay buyers. * `HTTP Class`_ - generic back-end class the enbles and standardized way to make API calls. * `Parallel Class`_ - SDK support for concurrent API calls. @@ -47,10 +101,10 @@ Getting Started 2) SDK Configuration * Using the SDK without YAML configuration - + ebaysdk.finding.Connection(appid='...', config_file=None) -* `YAML Configuration`_ +* `YAML Configuration`_ * `Understanding eBay Credentials`_ 3) Sample code can be found in the `samples directory`_. @@ -80,6 +134,7 @@ License .. _Understanding eBay Credentials: https://github.com/timotheus/ebaysdk-python/wiki/eBay-Credentials .. _eBay Developer Site: http://developer.ebay.com/ .. _YAML Configuration: https://github.com/timotheus/ebaysdk-python/wiki/YAML-Configuration +.. _Browse API Class: https://developer.ebay.com/api-docs/buy/browse/overview.html .. _Trading API Class: https://github.com/timotheus/ebaysdk-python/wiki/Trading-API-Class .. _Finding API Class: https://github.com/timotheus/ebaysdk-python/wiki/Finding-API-Class .. _Shopping API Class: https://github.com/timotheus/ebaysdk-python/wiki/Shopping-API-Class @@ -88,6 +143,6 @@ License .. _Parallel Class: https://github.com/timotheus/ebaysdk-python/wiki/Parallel-Class .. _eBay Developer Forums: https://forums.developer.ebay.com .. _Github issue tracking: https://github.com/timotheus/ebaysdk-python/issues -.. _v1 to v2 guide: https://github.com/timotheus/ebaysdk-python/wiki/Migrating-from-v1-to-v2 +.. _v1 to v2 guide: https://github.com/timotheus/ebaysdk-python/wiki/Migrating-from-v1-to-v2 .. _samples directory: https://github.com/timotheus/ebaysdk-python/tree/master/samples .. _Request Dictionary: https://github.com/timotheus/ebaysdk-python/wiki/Request-Dictionary diff --git a/ebaysdk/__init__.py b/ebaysdk/__init__.py index 5630928..9b7a763 100644 --- a/ebaysdk/__init__.py +++ b/ebaysdk/__init__.py @@ -9,7 +9,7 @@ import platform import logging -__version__ = '2.2.0' +__version__ = '2.3.0' Version = __version__ # for backward compatibility try: @@ -103,3 +103,12 @@ def parallel(*args, **kwargs): 'from ebaysdk.parallel import Parallel as parallel', ) ) + + +def browse(*args, **kwargs): + raise ImportError( + 'SDK import must be changed as follows:\n\n- %s\n+ %s\n\n' % ( + 'from ebaysdk import browse', + 'from ebaysdk.browse import Connection as browse', + ) + ) diff --git a/ebaysdk/browse/__init__.py b/ebaysdk/browse/__init__.py new file mode 100644 index 0000000..1e2b753 --- /dev/null +++ b/ebaysdk/browse/__init__.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- + +''' +Copyright 2012-2025 eBay Inc. +Authored by: Jeremy Setton +Licensed under CDDL 1.0 +''' + +import json + +from authlib.integrations.requests_client import OAuth2Session +from authlib.oauth2 import OAuth2Error +from ebaysdk import log, UserAgent +from ebaysdk.connection import BaseConnection, HTTP_SSL +from ebaysdk.config import Config +from ebaysdk.utils import smart_encode, smart_encode_request_data +from ebaysdk.exception import ConnectionError +from requests import Request, RequestException + + +class Connection(BaseConnection): + """Browse API class + + API documentation: + https://developer.ebay.com/api-docs/buy/browse/overview.html + + Supported calls: + search (item_summary) + searchByImage (item_summary) + getItem (item) + getItemByLegacyId (item) + getItems (item) + getItemsByItemGroup (item) + checkCompatibility (item) + + Doctests: + >>> b = Connection(config_file=os.environ.get('EBAY_YAML')) + >>> retval = b.execute('search', {'q': 'Python programming'}) + >>> print(b.response.status_code) + 200 + >>> print(b.error()) + None + """ + + def __init__(self, **kwargs): + """Browse class constructor. + + Keyword arguments: + domain -- API endpoint (default: api.ebay.com) + config_file -- YAML defaults (default: ebay.yaml) + debug -- debugging enabled (default: False) + warnings -- warnings enabled (default: True) + errors -- errors enabled (default: True) + uri -- API endpoint uri (default: /buy/browse/v1) + appid -- eBay application id (client id) + devid -- eBay developer id + certid -- eBay cert id (client secret) + siteid -- eBay country site id (default: EBAY-US) + version -- version number (default: v1) + https -- execute of https (default: True) + proxy_host -- proxy hostname + proxy_port -- proxy port number + timeout -- HTTP request timeout (default: 20) + parallel -- ebaysdk parallel object + """ + + super(Connection, self).__init__(method='GET', **kwargs) + + self.config = Config(domain=kwargs.get('domain', 'api.ebay.com'), + connection_kwargs=kwargs, + config_file=kwargs.get('config_file', 'ebay.yaml')) + + # override yaml defaults with args sent to the constructor + self.config.set('domain', kwargs.get('domain', 'api.ebay.com')) + self.config.set('uri', '/buy/browse/v1') + self.config.set('https', True, force=True) + self.config.set('warnings', True) + self.config.set('errors', True) + self.config.set('siteid', 'EBAY-US') + self.config.set('proxy_host', None) + self.config.set('proxy_port', None) + self.config.set('appid', None) + self.config.set('devid', None) + self.config.set('certid', None) + self.config.set('version', 'v1') + self.config.set('service', 'BrowseAPI') + self.config.set( + 'doc_url', 'https://developer.ebay.com/api-docs/buy/browse/overview.html') + + # Browse API uses JSON, so no datetime_nodes or base_list_nodes needed + self.datetime_nodes = [] + self.base_list_nodes = [] + + def build_request_headers(self, verb): + """Build request headers for Browse API.""" + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.access_token}', + 'User-Agent': UserAgent, + 'X-EBAY-C-MARKETPLACE-ID': self.config.get('siteid', 'EBAY_US') + } + + def build_request_data(self, verb, data, verb_attrs): + """Build request data for Browse API.""" + if verb in ['searchByImage', 'checkCompatibility'] and isinstance(data, dict): + return json.dumps(data) + return "" + + def build_request_query(self, verb, data): + """Build request query for Browse API.""" + if verb not in ['searchByImage', 'checkCompatibility'] and isinstance(data, dict): + return { + key: smart_encode(value) + for key, value in data.items() + if (verb not in ["getItem", "checkCompatibility"] or key != "item_id") + and value is not None + } + return None + + def build_request_url(self, verb): + """Build request URL for Browse API.""" + base_url = "%s://%s%s" % ( + HTTP_SSL[self.config.get('https', True)], + self.config.get('domain'), + self.config.get('uri') + ) + + # Map verb to appropriate endpoint + endpoint_map = { + 'search': '/item_summary/search', + 'searchByImage': '/item_summary/search_by_image', + 'getItem': '/item/{item_id}', + 'getItemByLegacyId': '/item/get_item_by_legacy_id', + 'getItems': '/item/', + 'getItemsByItemGroup': '/item/get_items_by_item_group', + 'checkCompatibility': '/item/{item_id}/check_compatibility' + } + + endpoint = endpoint_map.get(verb, f'/{verb}') + + # Handle item_id parameter for getItem and checkCompatibility + if verb in ['getItem', 'checkCompatibility']: + if not self._request_dict or 'item_id' not in self._request_dict: + raise ValueError('item_id is required for getItem and checkCompatibility') + endpoint = endpoint.format(item_id=self._request_dict['item_id']) + + return base_url + endpoint + + def build_request(self, verb, data, verb_attrs, files=None): + """Override build_request to handle GET vs POST methods.""" + self.verb = verb + self._request_dict = data + + # Determine HTTP method based on verb + if verb in ['searchByImage', 'checkCompatibility']: + self.method = 'POST' + else: + self.method = 'GET' + + url = self.build_request_url(verb) + headers = self.build_request_headers(verb) + params = self.build_request_query(verb, data) + requestData = self.build_request_data(verb, data, verb_attrs) + + request = Request( + self.method, + url, + params=params, + data=smart_encode_request_data(requestData), + headers=headers, + files=files, + ) + + self.request = request.prepare() + + def process_response(self, parse_response=True): + """Post processing of the response""" + + if self.response.status_code != 200: + self._response_error = self.response.reason + + def _get_resp_body_errors(self): + """Parses the response content to pull errors for Browse API JSON responses.""" + + if self._resp_body_errors and len(self._resp_body_errors) > 0: + return self._resp_body_errors + + errors = [] + warnings = [] + resp_codes = [] + + if self.verb is None or self.response.status_code != 200: + return errors + + response_data = self.response.json() + + for error in response_data.get('errors', []): + error_code = error.get('errorId') + error_category = error.get('category') + error_message = error.get('message') + + if error_code not in resp_codes: + resp_codes.append(error_code) + + msg = f"Category: {error_category}, Code: {error_code}, {error_message}" + errors.append(msg) + + for warning in response_data.get('warnings', []): + warning_code = warning.get('errorId') + warning_category = warning.get('category') + warning_message = warning.get('message') + + if warning_code not in resp_codes: + resp_codes.append(warning_code) + + msg = f"Category: {warning_category}, Code: {warning_code}, {warning_message}" + warnings.append(msg) + + self._resp_body_warnings = warnings + self._resp_body_errors = errors + self._resp_codes = resp_codes + + if self.config.get("warnings") and len(warnings) > 0: + log.warning("%s: %s\n\n" % (self.verb, "\n".join(warnings))) + + if self.config.get("errors") and len(errors) > 0: + log.error("%s: %s\n\n" % (self.verb, "\n".join(errors))) + + return errors + + @property + def access_token(self): + """Get OAuth access token using client credentials flow.""" + + if not hasattr(self, '_token') or self._token.is_expired(): + client_id = self.config.get('appid') + client_secret = self.config.get('certid') + + if not client_id or not client_secret: + raise ValueError( + 'appid (client id) and certid (client secret) are required for OAuth' + ) + + try: + client = OAuth2Session( + client_id=client_id, + client_secret=client_secret, + scope=f'https://{self.config.get("domain")}/oauth/api_scope', + token_endpoint_auth_method='client_secret_basic', + ) + + self._token = client.fetch_token( + url=f'https://{self.config.get("domain")}/identity/v1/oauth2/token', + grant_type='client_credentials', + ) + except (OAuth2Error, RequestException) as e: + raise ConnectionError(f'Failed to get access token: {e}') + + return self._token['access_token'] diff --git a/ebaysdk/connection.py b/ebaysdk/connection.py index a29a644..94fce09 100644 --- a/ebaysdk/connection.py +++ b/ebaysdk/connection.py @@ -193,6 +193,8 @@ def execute_request(self): allow_redirects=True ) + self.session.close() + log.debug('RESPONSE (%s):' % self._request_id) log.debug('elapsed time=%s' % self.response.elapsed) log.debug('status code=%s' % self.response.status_code) @@ -208,7 +210,6 @@ def process_response(self, parse_response=True): datetime_nodes=self.datetime_nodes, parse_response=parse_response) - self.session.close() # set for backward compatibility self._response_content = self.response.content diff --git a/samples/browse.py b/samples/browse.py new file mode 100644 index 0000000..bfc4dd6 --- /dev/null +++ b/samples/browse.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +''' +Copyright 2012-2025 eBay Inc. +Authored by: Jeremy Setton +Licensed under CDDL 1.0 +''' + +import os +import sys +from optparse import OptionParser + +sys.path.insert(0, '%s/../' % os.path.dirname(__file__)) + +import ebaysdk +from ebaysdk.exception import ConnectionError +from ebaysdk.browse import Connection as Browse + + +def init_options(): + """Initialize command line options.""" + usage = "usage: %prog [options]" + parser = OptionParser(usage=usage) + + parser.add_option('-d', '--debug', action='store_true', dest='debug', + help='Enabled debug output') + parser.add_option('-y', '--yaml', dest='yaml', default='ebay.yaml', + help='Specifies the name of the YAML defaults file. Default: ebay.yaml') + parser.add_option('-a', '--appid', dest='appid', default=None, + help='Specifies the eBay application id to use.') + parser.add_option('-c', '--certid', dest='certid', default=None, + help='Specifies the eBay cert id to use.') + parser.add_option('--domain', dest='domain', default='api.ebay.com', + help='Specifies the eBay domain to use (e.g., api.ebay.com).') + + (opts, args) = parser.parse_args() + return opts, args + + +def run_search_sample(opts): + """Run a basic search sample.""" + + try: + api = Browse( + debug=opts.debug, + config_file=opts.yaml, + appid=opts.appid, + certid=opts.certid, + domain=opts.domain, + ) + + api_request = { + 'q': 'Python programming books', + 'limit': 5, + 'filter': 'conditionIds:{3000|4000}', # New or Used condition + 'sort': 'price', + 'order': 'asc' + } + + print(f"Searching for: {api_request['q']}") + api.execute('search', api_request) + + print(f"Response Status: {api.response.status_code}") + + if api.response.status_code == 200: + data = api.response.json() + items = data.get('itemSummaries', []) + print(f"Found {len(items)} items") + + for i, item in enumerate(items, 1): + title = item.get('title', 'No title') + price = item.get('price', {}).get('value', 'N/A') + currency = item.get('price', {}).get('currency', '') + condition = item.get('condition', 'Unknown') + print(f"{i}. {title[:50]}... - {currency} {price} ({condition})") + else: + print(f"Error: {api.error()}") + + except ConnectionError as e: + print(f"Connection Error: {e}") + if hasattr(e, 'response') and e.response: + print(f"Response: {e.response.text}") + + +def run_get_item_sample(opts): + """Run a get item sample.""" + + try: + api = Browse( + config_file=opts.yaml, + appid=opts.appid, + certid=opts.certid, + domain=opts.domain, + ) + + # First, get an item ID from a search + search_request = { + 'q': 'iPhone', + 'limit': 1 + } + + print("Getting an item ID from search...") + api.execute('search', search_request) + + if api.response.status_code == 200: + data = api.response.json() + items = data.get('itemSummaries', []) + + if items: + item_id = items[0].get('itemId') + print(f"Using item ID: {item_id}") + + # Now get detailed item information + item_request = { + 'item_id': item_id, + 'fieldgroups': 'PRODUCT' + } + + print("Getting detailed item information...") + api.execute('getItem', item_request) + + if api.response.status_code == 200: + item_data = api.response.json() + print(f"Item Title: {item_data.get('title', 'N/A')}") + print(f"Item Price: {item_data.get('price', {}).get('value', 'N/A')} {item_data.get('price', {}).get('currency', '')}") + print(f"Item Condition: {item_data.get('condition', 'N/A')}") + print(f"Item Description: {item_data.get('description', 'N/A')[:100]}...") + else: + print(f"Error getting item details: {api.error()}") + else: + print("No items found in search") + else: + print(f"Error in search: {api.error()}") + + except ConnectionError as e: + print(f"Connection Error: {e}") + + +def main(): + """Main function.""" + opts, args = init_options() + + print("eBay Browse API Samples for version %s" % ebaysdk.get_version()) + print("=====================") + print("Note: This sample requires OAuth credentials (appid and certid).") + print("The Browse API replaces the deprecated Finding and Shopping APIs.") + print() + + # Run samples + run_search_sample(opts) + run_get_item_sample(opts) + + print("\nSample completed!") + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 71b8630..51cf9dd 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ license="COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0", packages=find_packages(include=['ebaysdk', 'ebaysdk.*']), provides=[PKG], - install_requires=['lxml', 'requests'], + install_requires=['authlib', 'lxml', 'requests'], test_suite='tests', long_description=long_desc, classifiers=[ diff --git a/tests/test_browse.py b/tests/test_browse.py new file mode 100644 index 0000000..1e01d69 --- /dev/null +++ b/tests/test_browse.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- + +''' +Copyright 2012-2025 eBay Inc. +Authored by: Jeremy Setton +Licensed under CDDL 1.0 +''' + +import unittest +import json +from unittest.mock import Mock, patch + +from ebaysdk.browse import Connection +from ebaysdk.exception import ConnectionError + + +class TestBrowseAPI(unittest.TestCase): + """Test cases for Browse API.""" + + def setUp(self): + """Set up test fixtures.""" + self.api = Connection(debug=False, config_file=None) + self.api.config.set('appid', 'test_appid') + self.api.config.set('certid', 'test_certid') + self.api.config.set('siteid', 'EBAY_US') + + # Mock the access_token property + self.api._token = {'access_token': 'test_token'} + + def test_connection_initialization(self): + """Test Browse API connection initialization.""" + api = Connection(debug=False, config_file=None) + + self.assertEqual(api.config.get('domain'), 'api.ebay.com') + self.assertEqual(api.config.get('uri'), '/buy/browse/v1') + self.assertEqual(api.config.get('siteid'), 'EBAY-US') + self.assertEqual(api.config.get('version'), 'v1') + self.assertEqual(api.config.get('service'), 'BrowseAPI') + + def test_build_request_headers(self): + """Test request header building.""" + headers = self.api.build_request_headers('search') + + expected_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": "Bearer test_token", + "User-Agent": "eBaySDK/2.3.0 Python/3.8.10 Linux/5.4.0-74-generic", + "X-EBAY-C-MARKETPLACE-ID": "EBAY_US" + } + + # Check that all expected headers are present + for key, value in expected_headers.items(): + if key == "User-Agent": + # User-Agent format may vary, just check it exists + self.assertIn(key, headers) + else: + self.assertEqual(headers[key], value) + + def test_build_request_headers_no_token(self): + """Test request header building without access token.""" + # Remove the token to test error case + delattr(self.api, '_token') + with self.assertRaises(ValueError): + self.api.build_request_headers('search') + + def test_build_request_url_search(self): + """Test URL building for search endpoint.""" + self.api._request_dict = {'q': 'test', 'limit': 5} + url = self.api.build_request_url('search') + + expected_url = "https://api.ebay.com/buy/browse/v1/item_summary/search" + self.assertEqual(url, expected_url) + + def test_build_request_url_get_item(self): + """Test URL building for getItem endpoint.""" + self.api._request_dict = {'item_id': '123456789'} + url = self.api.build_request_url('getItem') + + expected_url = "https://api.ebay.com/buy/browse/v1/item/123456789" + self.assertEqual(url, expected_url) + + def test_build_request_url_get_item_missing_id(self): + """Test URL building for getItem endpoint without item_id.""" + self.api._request_dict = {} + with self.assertRaises(ValueError): + self.api.build_request_url('getItem') + + def test_build_request_url_check_compatibility(self): + """Test URL building for checkCompatibility endpoint.""" + self.api._request_dict = {'item_id': '123456789'} + url = self.api.build_request_url('checkCompatibility') + + expected_url = "https://api.ebay.com/buy/browse/v1/item/123456789/check_compatibility" + self.assertEqual(url, expected_url) + + def test_build_request_data_get(self): + """Test request data building for GET requests.""" + data = {'q': 'test', 'limit': 5} + result = self.api.build_request_data('search', data, None) + + self.assertEqual(result, "") + + def test_build_request_data_post(self): + """Test request data building for POST requests.""" + data = {'image': 'base64encodedimage', 'limit': 5} + result = self.api.build_request_data('searchByImage', data, None) + + expected = json.dumps(data) + self.assertEqual(result, expected) + + def test_build_request_data_none(self): + """Test request data building with None data.""" + result = self.api.build_request_data('search', None, None) + self.assertEqual(result, "") + + def test_build_request_query(self): + """Test request query building for GET requests.""" + data = {'q': 'test', 'limit': 5} + result = self.api.build_request_query('search', data) + + expected = {'q': 'test', 'limit': 5} + self.assertEqual(result, expected) + + def test_build_request_query_post(self): + """Test request query building for POST requests.""" + data = {'image': 'base64encodedimage', 'limit': 5} + result = self.api.build_request_query('searchByImage', data) + + self.assertIsNone(result) + + def test_build_request_query_with_item_id(self): + """Test request query building excludes item_id for getItem.""" + data = {'item_id': '123456789', 'fieldgroups': 'PRODUCT'} + result = self.api.build_request_query('getItem', data) + + expected = {'fieldgroups': 'PRODUCT'} + self.assertEqual(result, expected) + + @patch('ebaysdk.browse.Connection.build_request') + def test_build_request_get_method(self, mock_build_request): + """Test that GET method is used for search requests.""" + self.api.build_request('search', {'q': 'test'}, None) + + # Verify that the method was set to GET + self.assertEqual(self.api.method, 'GET') + + @patch('ebaysdk.browse.Connection.build_request') + def test_build_request_post_method(self, mock_build_request): + """Test that POST method is used for searchByImage requests.""" + self.api.build_request('searchByImage', {'image': 'test'}, None) + + # Verify that the method was set to POST + self.assertEqual(self.api.method, 'POST') + + def test_get_resp_body_errors_no_errors(self): + """Test error parsing with no errors.""" + # Mock response with no errors + mock_response = Mock() + mock_response.json.return_value = {'itemSummaries': []} + self.api.response = mock_response + + errors = self.api._get_resp_body_errors() + self.assertEqual(errors, []) + + def test_get_resp_body_errors_with_errors(self): + """Test error parsing with errors.""" + # Mock response with errors + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'errors': [ + { + 'errorId': '123', + 'message': 'Test error', + 'category': 'ERROR' + } + ] + } + self.api.response = mock_response + + errors = self.api._get_resp_body_errors() + self.assertEqual(len(errors), 1) + self.assertIn('Test error', errors[0]) + + def test_get_resp_body_errors_with_warnings(self): + """Test error parsing with warnings.""" + # Mock response with warnings + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'warnings': [ + { + 'errorId': '456', + 'message': 'Test warning', + 'category': 'WARNING' + } + ] + } + self.api.response = mock_response + + errors = self.api._get_resp_body_errors() + self.assertEqual(len(errors), 0) # Warnings don't count as errors + self.assertEqual(len(self.api._resp_body_warnings), 1) + + def test_get_resp_body_errors_non_200_status(self): + """Test error parsing with non-200 status code.""" + # Mock response with non-200 status + mock_response = Mock() + mock_response.status_code = 400 + self.api.response = mock_response + + errors = self.api._get_resp_body_errors() + self.assertEqual(len(errors), 0) # Should return early for non-200 status + + @patch('ebaysdk.browse.OAuth2Session') + def test_access_token_property_success(self, mock_oauth2_session): + """Test successful access token property.""" + # Mock OAuth2Session and token + mock_client = Mock() + mock_token = {'access_token': 'new_token'} + mock_client.fetch_token.return_value = mock_token + mock_oauth2_session.return_value = mock_client + + # Remove existing token to force refresh + if hasattr(self.api, '_token'): + delattr(self.api, '_token') + + token = self.api.access_token + + self.assertEqual(token, 'new_token') + self.assertEqual(self.api._token, mock_token) + + @patch('ebaysdk.browse.OAuth2Session') + def test_access_token_property_failure(self, mock_oauth2_session): + """Test failed access token property.""" + # Mock OAuth2Session to raise exception + mock_client = Mock() + mock_client.fetch_token.side_effect = Exception("OAuth error") + mock_oauth2_session.return_value = mock_client + + # Remove existing token to force refresh + if hasattr(self.api, '_token'): + delattr(self.api, '_token') + + with self.assertRaises(ConnectionError): + _ = self.api.access_token + + def test_access_token_property_missing_credentials(self): + """Test access token property with missing credentials.""" + # Remove existing token and credentials + if hasattr(self.api, '_token'): + delattr(self.api, '_token') + self.api.config.set('appid', None) + self.api.config.set('certid', None) + + with self.assertRaises(ValueError): + _ = self.api.access_token + + def test_process_response_non_200(self): + """Test process_response with non-200 status code.""" + mock_response = Mock() + mock_response.status_code = 400 + mock_response.reason = "Bad Request" + self.api.response = mock_response + + self.api.process_response() + + self.assertEqual(self.api._response_error, "Bad Request") + + +class TestBrowseAPIIntegration(unittest.TestCase): + """Integration tests for Browse API (require actual credentials).""" + + def setUp(self): + """Set up integration test fixtures.""" + # These tests require actual OAuth credentials + # Skip if not available + self.skip_if_no_credentials() + + def skip_if_no_credentials(self): + """Skip test if OAuth credentials are not available.""" + import os + appid = os.environ.get('EBAY_APPID') + certid = os.environ.get('EBAY_CERTID') + + if not appid or not certid: + self.skipTest("EBAY_APPID and EBAY_CERTID environment variables required") + + def test_search_integration(self): + """Test actual search API call.""" + import os + appid = os.environ.get('EBAY_APPID') + certid = os.environ.get('EBAY_CERTID') + + api = Connection(debug=False, config_file=None, appid=appid, certid=certid) + + # Perform search + api.execute('search', {'q': 'Python programming', 'limit': 3}) + + self.assertEqual(api.response.status_code, 200) + data = api.response.json() + self.assertIn('itemSummaries', data) + + def test_get_item_integration(self): + """Test actual getItem API call.""" + import os + appid = os.environ.get('EBAY_APPID') + certid = os.environ.get('EBAY_CERTID') + + api = Connection(debug=False, config_file=None, appid=appid, certid=certid) + + # First get an item ID from search + api.execute('search', {'q': 'iPhone', 'limit': 1}) + + if api.response.status_code == 200: + data = api.response.json() + items = data.get('itemSummaries', []) + + if items: + item_id = items[0].get('itemId') + + # Now get item details + api.execute('getItem', {'item_id': item_id}) + + self.assertEqual(api.response.status_code, 200) + item_data = api.response.json() + self.assertIn('title', item_data) + + +if __name__ == '__main__': + unittest.main()