"""Payoneer connector tests.""" import json import os from typing import Generator from unittest.mock import call, MagicMock, patch import httpx import pytest from src.connectors.payoneer import get_payee_details, PayoneerApiException @pytest.fixture def mock_auth_token() -> Generator[MagicMock, None, None]: """Patch _get_auth_token to return a test token.""" with patch( 'src.connectors.payoneer._get_auth_token', return_value='test-token-longer-than-twenty-chars', ) as mock: yield mock @pytest.fixture(autouse=True) def mock_sleep() -> Generator[MagicMock, None, None]: """Mock the sleep used by tenacity so retry tests run instantly.""" with patch('src.connectors.payoneer._sleep') as mock: yield mock @pytest.fixture(autouse=True) def stub_secrets_cache() -> Generator[MagicMock, None, None]: """Stub the SecretCache so tests don't need real AWS credentials. Each test patches `get_secret_string` return value (or side_effect) via the yielded mock. """ from src.connectors import secrets fake_cache = MagicMock() with patch.object(secrets, '_get_cache', return_value=fake_cache): yield fake_cache.get_secret_string class TestGetPayeeDetails: """Tests for get_payee_details.""" @patch('src.connectors.payoneer.httpx.get') def test_success( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Test successful payee details retrieval.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'result': { 'type': 'INDIVIDUAL', 'contact': {'first_name': 'John'}, } } mock_get.return_value = mock_response result = get_payee_details(100250970, 12345) assert ( result is not None and result.type == 'INDIVIDUAL' and result.contact.first_name == 'John' ) mock_get.assert_called_once() call_url = mock_get.call_args[0][0] assert '/v4/programs/100250970/payees/12345/details' in call_url assert mock_get.call_args.kwargs['params'] == { 'show_payout_method_details': 'true' } mock_sleep.assert_not_called() @patch('src.connectors.payoneer.httpx.get') def test_payee_not_found_returns_none( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Test 404 returns None.""" mock_response = MagicMock() mock_response.status_code = 404 mock_get.return_value = mock_response result = get_payee_details(100250970, 99999) assert result is None @patch('src.connectors.payoneer.httpx.get') def test_payoneer_error_response( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Test Payoneer error in response body raises exception.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { 'error': 'INVALID_PAYEE', 'error_description': 'Payee does not exist', } mock_get.return_value = mock_response with pytest.raises(PayoneerApiException, match='Payoneer error'): get_payee_details(100250970, 12345) @patch('src.connectors.payoneer.httpx.get') def test_missing_result_key_raises_not_returns_none( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """A 200 with neither `error` nor `result` is a contract violation. Returning None would conflate it with a genuine 404, silently hiding upstream data loss. Raise instead so the processor logs and the row is visible in the ops report. """ mock_response = MagicMock() mock_response.status_code = 200 mock_response.raise_for_status = MagicMock() mock_response.json.return_value = {} mock_get.return_value = mock_response with pytest.raises( PayoneerApiException, match='missing both "error" and "result"' ): get_payee_details(100250970, 12345) @patch('src.connectors.payoneer.httpx.get') def test_validation_error_is_wrapped_as_payoneer_exception( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Malformed-but-200 payloads must surface as PayoneerApiException. Otherwise pydantic's ValidationError escapes the connector, is not caught by the processor's PayoneerApiException handler, and aborts the entire batch via future.result(). Row-level blast radius is the contract here. """ mock_response = MagicMock() mock_response.status_code = 200 mock_response.raise_for_status = MagicMock() # contact as a list instead of object — fails pydantic validation mock_response.json.return_value = { 'result': {'type': 'INDIVIDUAL', 'contact': []} } mock_get.return_value = mock_response with pytest.raises(PayoneerApiException, match='failed validation'): get_payee_details(100250970, 12345) @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_retries_on_connection_error( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Test retries on connection errors.""" mock_get.side_effect = httpx.ConnectError('Connection refused') with pytest.raises(PayoneerApiException, match='Failed after 2 retries'): get_payee_details(100250970, 12345) assert mock_get.call_count == 2 @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_retries_on_server_error( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Test retries on 5xx server errors via raise_for_status.""" mock_response = MagicMock() mock_response.status_code = 503 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( '503 Server Error', request=MagicMock(), response=mock_response ) mock_get.return_value = mock_response with pytest.raises(PayoneerApiException, match='Failed after 3 retries'): get_payee_details(100250970, 12345) assert mock_get.call_count == 3 @pytest.mark.parametrize('status_code', [400, 403, 422]) @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_permanent_4xx_fails_fast( self, mock_get: MagicMock, mock_auth_token: MagicMock, status_code: int, ) -> None: """Test permanent 4xx errors fail fast without retry.""" mock_response = MagicMock() mock_response.status_code = status_code mock_get.return_value = mock_response with pytest.raises( PayoneerApiException, match=f'Payoneer client error {status_code}' ): get_payee_details(100250970, 12345) # Permanent errors must not be retried assert mock_get.call_count == 1 @patch.dict(os.environ, {'PAYONEER_AUTH_TOKEN_ARN': 'arn:test'}, clear=False) @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.secrets.invalidate') @patch('src.connectors.payoneer.httpx.get') def test_401_clears_token_cache_and_retries( self, mock_get: MagicMock, mock_invalidate: MagicMock, mock_auth_token: MagicMock, ) -> None: """Test 401 invalidates the cached secret and retries with a fresh token.""" mock_401 = MagicMock() mock_401.status_code = 401 mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_401, mock_success] # Each `_get_auth_token` call returns a different value so the test # can assert the second HTTP request actually used the refreshed # token (regression guard for a no-op refresh path). mock_auth_token.side_effect = [ 'stale-token-aaaaaaaaaaaa', 'fresh-token-bbbbbbbbbbbb', ] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' assert mock_get.call_count == 2 # Headers rebuilt each attempt so the second call uses a fresh token assert mock_auth_token.call_count == 2 first_auth = mock_get.call_args_list[0].kwargs['headers']['Authorization'] second_auth = mock_get.call_args_list[1].kwargs['headers']['Authorization'] assert first_auth == 'Bearer stale-token-aaaaaaaaaaaa' assert second_auth == 'Bearer fresh-token-bbbbbbbbbbbb' # Cached secret was invalidated exactly once on the 401 mock_invalidate.assert_called_once_with('arn:test') @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_429_rate_limit_is_retried( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Test 429 rate-limit responses are retried, not failed fast.""" mock_response = MagicMock() mock_response.status_code = 429 mock_response.headers = {} mock_get.return_value = mock_response with pytest.raises(PayoneerApiException, match='Failed after 3 retries'): get_payee_details(100250970, 12345) assert mock_get.call_count == 3 # Sleep called between retries (attempts 1 and 2 sleep, attempt 3 does not) assert mock_sleep.call_count == 2 @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_429_honors_retry_after_header( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Test 429 responses honor the Retry-After header for backoff.""" mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': '5'} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' mock_sleep.assert_called_once_with(5.0) @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_exponential_backoff_without_retry_after( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Test exponential backoff is used when no Retry-After is present.""" mock_get.side_effect = httpx.ConnectError('fail') with pytest.raises(PayoneerApiException): get_payee_details(100250970, 12345) # 3 attempts → 2 sleeps: 1.0s and 2.0s (exponential) mock_sleep.assert_has_calls([call(1.0), call(2.0)]) @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_json_decode_error_is_retried( self, mock_get: MagicMock, mock_auth_token: MagicMock, ) -> None: """Test malformed JSON responses are retried as transient errors.""" mock_bad = MagicMock() mock_bad.status_code = 200 mock_bad.raise_for_status = MagicMock() mock_bad.json.side_effect = json.JSONDecodeError('bad json', 'doc', 0) mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_bad, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' assert mock_get.call_count == 2 @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_retries_on_timeout( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Test retries on timeout.""" mock_get.side_effect = httpx.TimeoutException('Request timed out') with pytest.raises(PayoneerApiException, match='Failed after 2 retries'): get_payee_details(100250970, 12345) @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_succeeds_after_retry( self, mock_get: MagicMock, mock_auth_token: MagicMock ) -> None: """Test successful response after transient failure.""" mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [ httpx.ConnectError('fail'), mock_success, ] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' assert mock_get.call_count == 2 @patch('src.connectors.payoneer.BACKOFF_MAX_SECONDS', 10.0) @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_retry_after_capped_at_max_backoff( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Server Retry-After larger than BACKOFF_MAX_SECONDS is capped.""" mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': '9999'} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' mock_sleep.assert_called_once_with(10.0) @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_invalid_retry_after_falls_back_to_exponential( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Malformed Retry-After header falls back to exponential backoff.""" mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': 'not-a-number'} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' # Attempt 1 failed -> exponential base * 2^0 = 1.0 mock_sleep.assert_called_once_with(1.0) @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_payoneer_exception_is_not_retried( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """PayoneerApiException raised inside fetch bypasses the retry loop.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.raise_for_status = MagicMock() mock_response.json.return_value = {'error': 'BAD', 'error_description': 'bad'} mock_get.return_value = mock_response with pytest.raises(PayoneerApiException, match='Payoneer error'): get_payee_details(100250970, 12345) assert mock_get.call_count == 1 mock_sleep.assert_not_called() @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_negative_retry_after_is_clamped_to_zero( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Negative Retry-After must not cause time.sleep to raise.""" mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': '-5'} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' mock_sleep.assert_called_once_with(0.0) @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_zero_retry_after_is_honored( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """Retry-After: 0 should sleep 0 seconds, not fall back to exponential.""" mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': '0'} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' mock_sleep.assert_called_once_with(0.0) @patch.dict(os.environ, {'PAYONEER_AUTH_TOKEN_ARN': 'arn:test'}, clear=False) @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.secrets.invalidate') @patch('src.connectors.payoneer.httpx.get') def test_secrets_invalidate_failure_surfaces_as_payoneer_exception( self, mock_get: MagicMock, mock_invalidate: MagicMock, mock_auth_token: MagicMock, ) -> None: """A 401 → secrets.invalidate failure must surface as PayoneerApiException so the processor's per-row handler catches it. Without this guard, a botocore ClientError (throttle, access denied, KMS failure) escapes _fetch_payee, is not in TRANSIENT_EXCEPTIONS, propagates out of get_payee_details, and aborts the whole ThreadPoolExecutor batch via future.result(). """ mock_401 = MagicMock() mock_401.status_code = 401 mock_get.return_value = mock_401 mock_invalidate.side_effect = RuntimeError('Secrets Manager throttled') with pytest.raises( PayoneerApiException, match='Failed to refresh Payoneer auth token' ): get_payee_details(100250970, 12345) # Single attempt: invalidate failure is non-transient, no retry. assert mock_get.call_count == 1 mock_invalidate.assert_called_once_with('arn:test') @patch.dict(os.environ, {'PAYONEER_AUTH_TOKEN_ARN': 'arn:test'}, clear=False) @patch('src.connectors.payoneer.MAX_RETRIES', 5) @patch('src.connectors.payoneer.secrets.invalidate') @patch('src.connectors.payoneer.httpx.get') def test_persistent_401_fails_fast_after_one_refresh( self, mock_get: MagicMock, mock_invalidate: MagicMock, mock_auth_token: MagicMock, ) -> None: """A 401 that persists after token refresh must not exhaust all retries.""" mock_401 = MagicMock() mock_401.status_code = 401 mock_get.return_value = mock_401 with pytest.raises(PayoneerApiException, match='401.*after token refresh'): get_payee_details(100250970, 12345) # Exactly two calls: first 401 triggers refresh, second 401 fails fast. assert mock_get.call_count == 2 mock_invalidate.assert_called_once_with('arn:test') @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_retry_after_http_date_is_parsed( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """RFC-compliant HTTP-date Retry-After values are honored.""" from datetime import datetime, timedelta, timezone from email.utils import format_datetime future = datetime.now(timezone.utc) + timedelta(seconds=7) mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': format_datetime(future, usegmt=True)} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' # Slept for roughly the delta (allow jitter for clock drift in the test) (slept,) = mock_sleep.call_args.args assert 5.0 <= slept <= 7.5, f'expected ~7s, got {slept}' @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_retry_after_naive_http_date_is_treated_as_utc( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """A non-RFC-compliant Retry-After (no timezone) must not crash on the naive-vs-aware datetime subtraction; the parser falls back to treating the value as UTC. """ mock_429 = MagicMock() mock_429.status_code = 429 # No timezone — `parsedate_to_datetime` returns a naive datetime. mock_429.headers = {'Retry-After': 'Mon, 01 Jan 2099 00:00:00'} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' # Far-future date clamps at BACKOFF_MAX_SECONDS (no exception raised). (slept,) = mock_sleep.call_args.args assert 0.0 <= slept <= 30.0 @patch('src.connectors.payoneer.MAX_RETRIES', 2) @patch('src.connectors.payoneer.httpx.get') def test_retry_after_past_http_date_is_clamped_to_zero( self, mock_get: MagicMock, mock_auth_token: MagicMock, mock_sleep: MagicMock, ) -> None: """HTTP-date in the past yields a large negative delta that must be clamped to 0, never passed to sleep() where it would raise. """ from datetime import datetime, timedelta, timezone from email.utils import format_datetime past = datetime.now(timezone.utc) - timedelta(days=365) mock_429 = MagicMock() mock_429.status_code = 429 mock_429.headers = {'Retry-After': format_datetime(past, usegmt=True)} mock_success = MagicMock() mock_success.status_code = 200 mock_success.raise_for_status = MagicMock() mock_success.json.return_value = {'result': {'type': 'INDIVIDUAL'}} mock_get.side_effect = [mock_429, mock_success] result = get_payee_details(100250970, 12345) assert result is not None and result.type == 'INDIVIDUAL' (slept,) = mock_sleep.call_args.args assert slept == 0.0 @patch('src.connectors.payoneer.MAX_RETRIES', 1) @patch('src.connectors.payoneer.httpx.get') def test_final_attempt_failure_is_logged( self, mock_get: MagicMock, mock_auth_token: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: """Final failed attempt must warn — before_sleep hook does not fire when no retry follows (regression guard for MAX_RETRIES=1).""" import logging mock_get.side_effect = httpx.ConnectError('boom') with caplog.at_level(logging.WARNING): with pytest.raises(PayoneerApiException): get_payee_details(100250970, 12345) assert any( 'attempt 1/1 failed' in rec.message and 'boom' in rec.message for rec in caplog.records ), f'Expected final-attempt warning, got: {[r.message for r in caplog.records]}' @patch('src.connectors.payoneer.MAX_RETRIES', 3) @patch('src.connectors.payoneer.httpx.get') def test_retry_error_wraps_last_exception( self, mock_get: MagicMock, mock_auth_token: MagicMock, ) -> None: """After exhaustion, PayoneerApiException chains the last exception.""" original = httpx.ConnectError('boom') mock_get.side_effect = original with pytest.raises(PayoneerApiException) as exc_info: get_payee_details(100250970, 12345) # tenacity's RetryError carries the last attempt; we unwrap it. assert exc_info.value.__cause__ is original assert 'boom' in str(exc_info.value) class TestGetAuthToken: """Tests for _get_auth_token.""" @patch('src.connectors.payoneer.ENVIRONMENT', 'dev') @patch.dict( os.environ, {'PAYONEER_AUTH_TOKEN': 'a-valid-token-that-is-long-enough'}, clear=False, ) def test_dev_uses_env_var(self) -> None: """Test dev environment uses env var.""" from src.connectors.payoneer import _get_auth_token token = _get_auth_token() assert token == 'a-valid-token-that-is-long-enough' @patch('src.connectors.payoneer.ENVIRONMENT', 'dev') @patch.dict(os.environ, {'PAYONEER_AUTH_TOKEN': 'short'}, clear=False) def test_dev_invalid_token_raises(self) -> None: """Test dev environment with short token raises.""" from src.connectors.payoneer import _get_auth_token with pytest.raises(PayoneerApiException, match='No valid Payoneer auth token'): _get_auth_token() @patch('src.connectors.payoneer.ENVIRONMENT', 'dev') def test_dev_token_length_boundary(self) -> None: """Length == MIN_DEV_TOKEN_LENGTH is accepted; length-1 is rejected. Comment on MIN_DEV_TOKEN_LENGTH says "shorter than 20 chars" — using `<=` would have rejected an exactly-20-char token, contradicting the documented boundary. """ from src.connectors.payoneer import _get_auth_token, MIN_DEV_TOKEN_LENGTH boundary = 'x' * MIN_DEV_TOKEN_LENGTH with patch.dict(os.environ, {'PAYONEER_AUTH_TOKEN': boundary}, clear=False): assert _get_auth_token() == boundary too_short = 'x' * (MIN_DEV_TOKEN_LENGTH - 1) with patch.dict(os.environ, {'PAYONEER_AUTH_TOKEN': too_short}, clear=False): with pytest.raises( PayoneerApiException, match='No valid Payoneer auth token' ): _get_auth_token() @patch('src.connectors.payoneer.ENVIRONMENT', 'dev') def test_dev_token_read_from_env_at_call_time( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Token env var must be read inside `_get_auth_token`, not captured at import. A monkeypatch applied after import must take effect. """ from src.connectors.payoneer import _get_auth_token monkeypatch.setenv('PAYONEER_AUTH_TOKEN', 'first-value-long-enough-here') assert _get_auth_token() == 'first-value-long-enough-here' monkeypatch.setenv('PAYONEER_AUTH_TOKEN', 'second-value-long-enough-here') assert _get_auth_token() == 'second-value-long-enough-here' @patch('src.connectors.payoneer.ENVIRONMENT', 'qa') @patch.dict( os.environ, { 'PAYONEER_AUTH_TOKEN_ARN': 'arn:aws:secretsmanager:us-east-1:123:secret:token' }, clear=False, ) def test_qa_uses_secrets_manager(self, stub_secrets_cache: MagicMock) -> None: """Test QA environment uses Secrets Manager.""" stub_secrets_cache.return_value = 'sm-token' from src.connectors.payoneer import _get_auth_token token = _get_auth_token() assert token == 'sm-token' stub_secrets_cache.assert_called_once_with( 'arn:aws:secretsmanager:us-east-1:123:secret:token' ) @patch('src.connectors.payoneer.ENVIRONMENT', 'uat') @patch.dict( os.environ, { 'PAYONEER_AUTH_TOKEN_ARN': 'arn:aws:secretsmanager:us-east-1:123:secret:token' }, clear=False, ) def test_uat_uses_secrets_manager(self, stub_secrets_cache: MagicMock) -> None: """Test UAT environment uses Secrets Manager (not env var).""" stub_secrets_cache.return_value = 'uat-token' from src.connectors.payoneer import _get_auth_token token = _get_auth_token() assert token == 'uat-token' stub_secrets_cache.assert_called_once() @patch('src.connectors.payoneer.ENVIRONMENT', 'qa') def test_missing_arn_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """Missing PAYONEER_AUTH_TOKEN_ARN in a remote environment surfaces as PayoneerApiException, not a raw KeyError or downstream boto error. """ monkeypatch.delenv('PAYONEER_AUTH_TOKEN_ARN', raising=False) from src.connectors.payoneer import _get_auth_token with pytest.raises( PayoneerApiException, match='PAYONEER_AUTH_TOKEN_ARN is not configured' ): _get_auth_token()