diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 19e15bf..06731c4 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -104,6 +104,9 @@ class AsyncAmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. throttling: Wait time in seconds between API calls. Defaults to 1 second. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to both API calls and OAuth2 token refresh (httpx handles + credentials embedded in the URL). Defaults to no proxy. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -121,6 +124,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + proxy: str | None = None, ) -> None: """Initialize the async Amazon Creators API client.""" # Validate version early to fail fast (before token manager initialization) @@ -139,10 +143,13 @@ def __init__( # HTTP client and token manager (initialized lazily or via context manager) self._http_client: AsyncHttpClient | None = None + # Normalize empty string to None so httpx doesn't reject proxy="". + self._proxy = proxy or None self._token_manager = AsyncOAuth2TokenManager( credential_id=credential_id, credential_secret=credential_secret, version=version, + proxy=self._proxy, ) self._owns_client = False @@ -163,7 +170,7 @@ def _validate_version(self, version: str) -> None: async def __aenter__(self) -> Self: """Enter async context manager, creating a persistent HTTP client.""" - self._http_client = AsyncHttpClient(host=API_HOST) + self._http_client = AsyncHttpClient(host=API_HOST, proxy=self._proxy) await self._http_client.__aenter__() self._owns_client = True return self @@ -498,7 +505,7 @@ async def _make_request( if self._http_client is not None: response = await self._http_client.post(endpoint, headers, body) else: - async with AsyncHttpClient(host=API_HOST) as client: + async with AsyncHttpClient(host=API_HOST, proxy=self._proxy) as client: response = await client.post(endpoint, headers, body) # Handle errors diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index 1f3878f..9105dd0 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -55,6 +55,9 @@ class AsyncOAuth2TokenManager: credential_secret: OAuth2 credential secret. version: API version (determines auth endpoint). auth_endpoint: Optional custom auth endpoint URL. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to token refresh requests (httpx handles credentials + embedded in the URL). Defaults to no proxy. """ @@ -64,12 +67,15 @@ def __init__( credential_secret: str, version: str, auth_endpoint: str | None = None, + proxy: str | None = None, ) -> None: """Initialize the async OAuth2 token manager.""" self._credential_id = credential_id self._credential_secret = credential_secret self._version = version self._auth_endpoint = self._determine_auth_endpoint(version, auth_endpoint) + # Normalize empty string to None so httpx doesn't reject proxy="". + self._proxy = proxy or None self._access_token: str | None = None self._expires_at: float | None = None @@ -186,7 +192,7 @@ async def refresh_token(self) -> str: } try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self._proxy) as client: if self.is_lwa(): response = await client.post( self._auth_endpoint, diff --git a/amazon_creatorsapi/aio/client.py b/amazon_creatorsapi/aio/client.py index 8ec143b..c7d5615 100644 --- a/amazon_creatorsapi/aio/client.py +++ b/amazon_creatorsapi/aio/client.py @@ -65,6 +65,9 @@ class AsyncHttpClient: Args: host: Base URL for API requests. Defaults to Amazon Creators API. timeout: Request timeout in seconds. Defaults to 30. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + httpx applies it to every request (and handles credentials + embedded in the URL). Defaults to no proxy. """ @@ -72,10 +75,13 @@ def __init__( self, host: str = DEFAULT_HOST, timeout: float = DEFAULT_TIMEOUT, + proxy: str | None = None, ) -> None: """Initialize the async HTTP client.""" self._host = host self._timeout = timeout + # Normalize empty string to None so httpx doesn't reject proxy="". + self._proxy = proxy or None self._client: httpx.AsyncClient | None = None self._owns_client = False @@ -85,6 +91,7 @@ async def __aenter__(self) -> Self: base_url=self._host, timeout=self._timeout, headers={"User-Agent": USER_AGENT}, + proxy=self._proxy, ) self._owns_client = True return self @@ -132,6 +139,7 @@ async def post( async with httpx.AsyncClient( base_url=self._host, timeout=self._timeout, + proxy=self._proxy, ) as client: response = await client.post( path, diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 70dc472..5c332d4 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -16,6 +16,7 @@ from amazon_creatorsapi.errors import ItemsNotFoundError from creatorsapi_python_sdk.api.default_api import DefaultApi from creatorsapi_python_sdk.api_client import ApiClient +from creatorsapi_python_sdk.configuration import Configuration from creatorsapi_python_sdk.exceptions import ApiException from creatorsapi_python_sdk.models.get_browse_nodes_request_content import ( GetBrowseNodesRequestContent, @@ -58,6 +59,8 @@ class AmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. throttling: Wait time in seconds between API calls. Defaults to 1 second. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to both regular API calls and OAuth2 token refresh. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -83,6 +86,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + proxy: str | None = None, ) -> None: """Initialize the Amazon Creators API client.""" self._credential_id = credential_id @@ -95,7 +99,11 @@ def __init__( # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) + configuration = Configuration() + configuration.proxy = proxy + self._api_client = ApiClient( + configuration=configuration, credential_id=credential_id, credential_secret=credential_secret, version=version, diff --git a/creatorsapi_python_sdk/api_client.py b/creatorsapi_python_sdk/api_client.py index 41f2b17..26bc802 100644 --- a/creatorsapi_python_sdk/api_client.py +++ b/creatorsapi_python_sdk/api_client.py @@ -384,7 +384,9 @@ def call_api( self.credential_id, self.credential_secret, self.version, self.auth_endpoint ) - self._token_manager = OAuth2TokenManager(config) + proxy = self.configuration.proxy + proxies = {"http": proxy, "https": proxy} if proxy else None + self._token_manager = OAuth2TokenManager(config, proxies=proxies) # Get token (will use cached token if valid) token = self._token_manager.get_token() # Add Authorization headers - Version only for v2.x diff --git a/creatorsapi_python_sdk/auth/oauth2_token_manager.py b/creatorsapi_python_sdk/auth/oauth2_token_manager.py index 7a730fa..65b01d8 100644 --- a/creatorsapi_python_sdk/auth/oauth2_token_manager.py +++ b/creatorsapi_python_sdk/auth/oauth2_token_manager.py @@ -30,13 +30,15 @@ class OAuth2TokenManager: """Manages OAuth2 token lifecycle including acquisition, caching, and automatic refresh""" - def __init__(self, config): + def __init__(self, config, proxies=None): """ Creates an OAuth2TokenManager instance - + :param config: The OAuth2Config instance + :param proxies: Optional dict of proxy URLs, e.g. {"http": "http://proxy:3128", "https": "http://proxy:3128"} """ self.config = config + self.proxies = proxies self.access_token = None self.expires_at = None @@ -67,6 +69,10 @@ def refresh_token(self): :raises Exception: If token refresh fails """ try: + session = requests.Session() + if self.proxies: + session.proxies.update(self.proxies) + if self.config.is_lwa(): # LWA (v3.x) uses JSON body request_data = { @@ -76,7 +82,7 @@ def refresh_token(self): 'scope': self.config.get_scope() } headers = {'Content-Type': 'application/json'} - response = requests.post( + response = session.post( self.config.get_cognito_endpoint(), json=request_data, headers=headers @@ -90,7 +96,7 @@ def refresh_token(self): 'scope': self.config.get_scope() } headers = {'Content-Type': 'application/x-www-form-urlencoded'} - response = requests.post( + response = session.post( self.config.get_cognito_endpoint(), data=request_data, headers=headers diff --git a/creatorsapi_python_sdk/rest.py b/creatorsapi_python_sdk/rest.py index d41d019..be7c135 100644 --- a/creatorsapi_python_sdk/rest.py +++ b/creatorsapi_python_sdk/rest.py @@ -110,8 +110,28 @@ def __init__(self, configuration) -> None: pool_args["headers"] = configuration.proxy_headers self.pool_manager = SOCKSProxyManager(**pool_args) else: - pool_args["proxy_url"] = configuration.proxy - pool_args["proxy_headers"] = configuration.proxy_headers + proxy_url = configuration.proxy + proxy_headers = configuration.proxy_headers + # urllib3 ProxyManager ignores credentials embedded in the + # proxy URL for HTTPS CONNECT tunneling — they must be passed + # via proxy_headers instead. Extract them here so callers can + # pass a plain "http://user:pass@host:port" URL and get correct + # CONNECT auth without any extra configuration. + if proxy_headers is None: + from urllib.parse import urlparse + _parsed = urlparse(proxy_url) + if _parsed.username: + proxy_headers = urllib3.make_headers( + proxy_basic_auth=( + f"{_parsed.username}:{_parsed.password}" + ) + ) + proxy_url = ( + f"{_parsed.scheme}://" + f"{_parsed.hostname}:{_parsed.port}" + ) + pool_args["proxy_url"] = proxy_url + pool_args["proxy_headers"] = proxy_headers self.pool_manager = urllib3.ProxyManager(**pool_args) else: self.pool_manager = urllib3.PoolManager(**pool_args) diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index 0427d55..4d03535 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -82,6 +82,37 @@ def test_accepts_lwa_version(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.marketplace, "www.amazon.com") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_init_with_proxy(self, mock_token_manager: MagicMock) -> None: + """Test proxy URL is passed through to the token manager.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + proxy=proxy_url, + ) + + self.assertEqual(api._proxy, proxy_url) + call_kwargs = mock_token_manager.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_init_without_proxy(self, mock_token_manager: MagicMock) -> None: + """Test token manager receives proxy=None when not provided.""" + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) + + call_kwargs = mock_token_manager.call_args.kwargs + self.assertIsNone(call_kwargs["proxy"]) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_raises_error_when_no_country_or_marketplace( self, mock_token_manager: MagicMock @@ -157,6 +188,31 @@ async def test_context_manager_creates_and_closes_client( mock_client.__aexit__.assert_called_once() + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_context_manager_passes_proxy_to_client( + self, + mock_http_client_class: MagicMock, + mock_token_manager: MagicMock, + ) -> None: + """Test context manager passes proxy to AsyncHttpClient.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_client = AsyncMock() + mock_http_client_class.return_value = mock_client + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + proxy=proxy_url, + ) as api: + self.assertEqual(api._proxy, proxy_url) + + call_kwargs = mock_http_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") async def test_context_manager_exit_without_client( self, @@ -1265,6 +1321,46 @@ async def test_request_without_context_manager( self.assertEqual(len(items), 1) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_request_without_context_manager_passes_proxy( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test standalone request creates temp client with proxy.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + proxy=proxy_url, + ) + + items = await api.get_items(["B0DLFMFBJW"]) + + self.assertEqual(len(items), 1) + call_kwargs = mock_http_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_request_uses_v2_authorization_header( diff --git a/tests/amazon_creatorsapi/aio/auth_test.py b/tests/amazon_creatorsapi/aio/auth_test.py index b0bd456..9f27da2 100644 --- a/tests/amazon_creatorsapi/aio/auth_test.py +++ b/tests/amazon_creatorsapi/aio/auth_test.py @@ -213,6 +213,90 @@ async def test_refreshes_token_when_expired( self.assertIsNotNone(manager._expires_at) +class TestAsyncOAuth2TokenManagerProxy(unittest.IsolatedAsyncioTestCase): + """Tests that token refresh routes through the configured proxy.""" + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_refresh_token_sets_proxy_on_client( + self, + mock_async_client_class: MagicMock, + ) -> None: + """When a proxy is provided, httpx.AsyncClient is created with it.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "tok123", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager( + "test_id", "test_secret", "2.2", proxy=proxy_url + ) + + await manager.refresh_token() + + call_kwargs = mock_async_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_refresh_token_no_proxy( + self, mock_async_client_class: MagicMock + ) -> None: + """When no proxy is configured, httpx.AsyncClient gets proxy=None.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "tok123", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + await manager.refresh_token() + + call_kwargs = mock_async_client_class.call_args.kwargs + self.assertIsNone(call_kwargs["proxy"]) + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_refresh_token_lwa_sets_proxy_on_client( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Proxy is also applied for LWA (v3.x) token refresh.""" + proxy_url = "http://proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "tok123", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager( + "test_id", "test_secret", "3.1", proxy=proxy_url + ) + + await manager.refresh_token() + + call_kwargs = mock_async_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + class TestAsyncOAuth2TokenManagerRefreshToken(unittest.IsolatedAsyncioTestCase): """Tests for refresh_token() method.""" diff --git a/tests/amazon_creatorsapi/aio/client_test.py b/tests/amazon_creatorsapi/aio/client_test.py index 3c8a605..92a37df 100644 --- a/tests/amazon_creatorsapi/aio/client_test.py +++ b/tests/amazon_creatorsapi/aio/client_test.py @@ -32,6 +32,17 @@ async def test_init_custom(self) -> None: self.assertEqual(client._host, host) self.assertEqual(client._timeout, timeout) + async def test_init_with_proxy(self) -> None: + """Test proxy is stored on the client.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + client = AsyncHttpClient(proxy=proxy_url) + self.assertEqual(client._proxy, proxy_url) + + async def test_init_without_proxy(self) -> None: + """Test proxy is None when not provided.""" + client = AsyncHttpClient() + self.assertIsNone(client._proxy) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_context_manager(self, mock_client_cls: MagicMock) -> None: """Test context manager creates and closes client.""" @@ -47,6 +58,33 @@ async def test_context_manager(self, mock_client_cls: MagicMock) -> None: self.assertFalse(client._owns_client) self.assertIsNone(client._client) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_context_manager_passes_proxy( + self, mock_client_cls: MagicMock + ) -> None: + """Test context manager passes proxy to httpx.AsyncClient.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_client_instance = AsyncMock() + mock_client_cls.return_value = mock_client_instance + + async with AsyncHttpClient(proxy=proxy_url) as client: + self.assertEqual(client._proxy, proxy_url) + + call_kwargs = mock_client_cls.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_context_manager_no_proxy(self, mock_client_cls: MagicMock) -> None: + """Test context manager passes proxy=None when not provided.""" + mock_client_instance = AsyncMock() + mock_client_cls.return_value = mock_client_instance + + async with AsyncHttpClient() as client: + self.assertIsNone(client._proxy) + + call_kwargs = mock_client_cls.call_args.kwargs + self.assertIsNone(call_kwargs["proxy"]) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_info_logging_context_manager( self, mock_client_cls: MagicMock @@ -89,6 +127,30 @@ async def test_post_without_context_manager( mock_client_instance.__aenter__.assert_called() mock_client_instance.__aexit__.assert_called() + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_post_without_context_manager_passes_proxy( + self, mock_client_cls: MagicMock + ) -> None: + """Test standalone post passes proxy to temporary httpx client.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b"{}" + mock_response.text = "{}" + + mock_client_instance = AsyncMock() + mock_client_instance.post.return_value = mock_response + mock_client_instance.__aenter__.return_value = mock_client_instance + mock_client_instance.__aexit__.return_value = None + mock_client_cls.return_value = mock_client_instance + + client = AsyncHttpClient(proxy=proxy_url) + await client.post("/test", {}, {}) + + call_kwargs = mock_client_cls.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_post_with_context_manager(self, mock_client_cls: MagicMock) -> None: """Test post request reuses existing client.""" diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index c471f51..9ffb7b2 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -99,6 +99,34 @@ def test_init_no_country_or_marketplace(self) -> None: tag=self.tag, ) + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_init_with_proxy(self, mock_client: MagicMock) -> None: + """Test that proxy URL is passed through to ApiClient configuration.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + proxy=proxy_url, + ) + call_kwargs = mock_client.call_args.kwargs + self.assertEqual(call_kwargs["configuration"].proxy, proxy_url) + + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_init_without_proxy(self, mock_client: MagicMock) -> None: + """Test that configuration.proxy is None when no proxy is provided.""" + AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + ) + call_kwargs = mock_client.call_args.kwargs + self.assertIsNone(call_kwargs["configuration"].proxy) + @mock.patch("amazon_creatorsapi.api.ApiClient") def test_throttling_disabled(self, _mock_client: MagicMock) -> None: """Test that API call is not delayed when throttling is 0.""" diff --git a/tests/amazon_creatorsapi/oauth2_token_manager_test.py b/tests/amazon_creatorsapi/oauth2_token_manager_test.py new file mode 100644 index 0000000..e0ece16 --- /dev/null +++ b/tests/amazon_creatorsapi/oauth2_token_manager_test.py @@ -0,0 +1,72 @@ +"""Unit tests for OAuth2TokenManager proxy support.""" + +from __future__ import annotations + +import unittest +from unittest import mock +from unittest.mock import MagicMock, patch + +from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config +from creatorsapi_python_sdk.auth.oauth2_token_manager import OAuth2TokenManager + + +def _make_config(version: str = "2.2") -> OAuth2Config: + return OAuth2Config( + credential_id="test_id", + credential_secret="test_secret", + version=version, + auth_endpoint=None, + ) + + +def _mock_token_response() -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"access_token": "tok123", "expires_in": 3600} + return resp + + +class TestOAuth2TokenManagerProxy(unittest.TestCase): + """Tests that OAuth2TokenManager routes token refresh through the proxy.""" + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_sets_proxies_on_session(self, mock_session_cls: MagicMock) -> None: + """When proxies are provided, Session.proxies.update is called with them.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + proxies = {"http": proxy_url, "https": proxy_url} + + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config(), proxies=proxies) + manager.refresh_token() + + mock_session.proxies.update.assert_called_once_with(proxies) + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_no_proxy_skips_proxies_update(self, mock_session_cls: MagicMock) -> None: + """When no proxy is configured, Session.proxies.update is not called.""" + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config()) + manager.refresh_token() + + mock_session.proxies.update.assert_not_called() + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_lwa_sets_proxies_on_session(self, mock_session_cls: MagicMock) -> None: + """Proxy is also applied for LWA (v3.x) token refresh.""" + proxy_url = "http://proxy.example.com:3128" + proxies = {"http": proxy_url, "https": proxy_url} + + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config(version="3.1"), proxies=proxies) + manager.refresh_token() + + mock_session.proxies.update.assert_called_once_with(proxies)