From 8eb87f6fef8ca6c3a86b2698f9043b24cca4b773 Mon Sep 17 00:00:00 2001 From: Parman Date: Mon, 15 Sep 2025 00:44:00 +0330 Subject: [PATCH] Remove base_url param, add sandbox support --- README.md | 1 - src/devo_global_comms_python/client.py | 22 +++- .../resources/email.py | 2 + .../resources/messages.py | 2 +- src/devo_global_comms_python/resources/sms.py | 20 +++- .../resources/whatsapp.py | 6 +- tests/test_client.py | 5 +- tests/test_sandbox.py | 109 ++++++++++++++++++ tests/test_sms.py | 3 +- 9 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 tests/test_sandbox.py diff --git a/README.md b/README.md index 446ac02..f072816 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,6 @@ client = DevoClient(api_key="your-api-key") ```python client = DevoClient( api_key="your-api-key", - base_url="https://api.devo.com", # Optional: custom base URL timeout=30.0, # Optional: request timeout ) ``` diff --git a/src/devo_global_comms_python/client.py b/src/devo_global_comms_python/client.py index d8695ab..6e02c74 100644 --- a/src/devo_global_comms_python/client.py +++ b/src/devo_global_comms_python/client.py @@ -55,7 +55,7 @@ class DevoClient: def __init__( self, api_key: str, - base_url: Optional[str] = None, + sandbox_api_key: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT, max_retries: int = 3, session: Optional[requests.Session] = None, @@ -65,7 +65,7 @@ def __init__( Args: api_key: API key for authentication - base_url: Base URL for the API (defaults to production) + sandbox_api_key: Optional sandbox API key for testing environments timeout: Request timeout in seconds max_retries: Maximum number of retries for failed requests session: Custom requests session (optional) @@ -76,7 +76,9 @@ def __init__( if not api_key or not api_key.strip(): raise DevoMissingAPIKeyException() - self.base_url = base_url or self.DEFAULT_BASE_URL + self.api_key = api_key.strip() + self.sandbox_api_key = sandbox_api_key.strip() if sandbox_api_key else None + self.base_url = self.DEFAULT_BASE_URL self.timeout = timeout # Set up authentication @@ -121,6 +123,7 @@ def request( data: Optional[Dict[str, Any]] = None, json: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, + sandbox: bool = False, ) -> requests.Response: """ Make an authenticated request to the API. @@ -132,6 +135,7 @@ def request( data: Form data json: JSON data headers: Additional headers + sandbox: Use sandbox API key for this request (default: False) Returns: requests.Response: The API response @@ -142,6 +146,10 @@ def request( """ url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}" + # Validate sandbox usage + if sandbox and not self.sandbox_api_key: + raise DevoException("Sandbox API key required when sandbox=True") + # Prepare headers request_headers = { "User-Agent": f"devo-python-sdk/{__version__}", @@ -151,7 +159,13 @@ def request( request_headers.update(headers) # Add authentication headers - auth_headers = self.auth.get_headers() + if sandbox and self.sandbox_api_key: + # Use sandbox API key for this request + sandbox_auth = APIKeyAuth(self.sandbox_api_key) + auth_headers = sandbox_auth.get_headers() + else: + # Use regular API key + auth_headers = self.auth.get_headers() request_headers.update(auth_headers) try: diff --git a/src/devo_global_comms_python/resources/email.py b/src/devo_global_comms_python/resources/email.py index 1041d12..7e705ab 100644 --- a/src/devo_global_comms_python/resources/email.py +++ b/src/devo_global_comms_python/resources/email.py @@ -27,6 +27,7 @@ def send_email( body: str, sender: str, recipient: str, + sandbox: bool = False, ) -> "EmailSendResponse": """ Send an email using the exact API specification. @@ -36,6 +37,7 @@ def send_email( body: Email body content sender: Sender email address recipient: Recipient email address + sandbox: Use sandbox environment for testing (default: False) Returns: EmailSendResponse: The email send response diff --git a/src/devo_global_comms_python/resources/messages.py b/src/devo_global_comms_python/resources/messages.py index b629f64..9f006cc 100644 --- a/src/devo_global_comms_python/resources/messages.py +++ b/src/devo_global_comms_python/resources/messages.py @@ -15,7 +15,7 @@ class MessagesResource(BaseResource): across any channel (SMS, Email, WhatsApp, RCS). """ - def send(self, data: "SendMessageDto") -> "SendMessageSerializer": + def send(self, data: "SendMessageDto", sandbox: bool = False) -> "SendMessageSerializer": """ Send a message through any channel (omni-channel endpoint). diff --git a/src/devo_global_comms_python/resources/sms.py b/src/devo_global_comms_python/resources/sms.py index 9907046..9aeff25 100644 --- a/src/devo_global_comms_python/resources/sms.py +++ b/src/devo_global_comms_python/resources/sms.py @@ -58,6 +58,7 @@ def send_sms( message: str, sender: str, hirvalidation: bool = True, + sandbox: bool = False, ) -> "SMSQuickSendResponse": """ Send an SMS message using the quick-send API. @@ -67,6 +68,7 @@ def send_sms( message: The SMS message content sender: The sender phone number or sender ID hirvalidation: Enable HIR validation (default: True) + sandbox: Use sandbox environment for testing (default: False) Returns: SMSQuickSendResponse: The sent message details including ID and status @@ -102,7 +104,7 @@ def send_sms( ) # Send request to the exact API endpoint - response = self.client.post("user-api/sms/quick-send", json=request_data.dict()) + response = self.client.post("user-api/sms/quick-send", json=request_data.dict(), sandbox=sandbox) # Parse response according to API spec from ..models.sms import SMSQuickSendResponse @@ -112,10 +114,13 @@ def send_sms( return result - def get_senders(self) -> "SendersListResponse": + def get_senders(self, sandbox: bool = False) -> "SendersListResponse": """ Retrieve the list of available senders for the account. + Args: + sandbox: Use sandbox environment for testing (default: False) + Returns: SendersListResponse: List of available senders with their details @@ -131,7 +136,7 @@ def get_senders(self) -> "SendersListResponse": logger.info("Fetching available senders") # Send request to the exact API endpoint - response = self.client.get("user-api/me/senders") + response = self.client.get("user-api/me/senders", sandbox=sandbox) # Parse response according to API spec from ..models.sms import SendersListResponse @@ -151,6 +156,7 @@ def buy_number( is_longcode: bool = True, agreement_last_sent_date: Optional[datetime] = None, is_automated_enabled: bool = True, + sandbox: bool = False, ) -> "NumberPurchaseResponse": """ Purchase a phone number. @@ -164,6 +170,7 @@ def buy_number( is_longcode: Whether this is a long code number (default: True) agreement_last_sent_date: Last date agreement was sent (optional) is_automated_enabled: Whether automated messages are enabled (default: True) + sandbox: Use sandbox environment for testing (default: False) Returns: NumberPurchaseResponse: Details of the purchased number including features @@ -227,6 +234,7 @@ def get_available_numbers( type: Optional[str] = None, prefix: Optional[str] = None, region: str = "US", + sandbox: bool = False, ) -> "AvailableNumbersResponse": """ Get available phone numbers for purchase. @@ -238,6 +246,7 @@ def get_available_numbers( type: Filter by type (optional) prefix: Filter by prefix (optional) region: Filter by region (Country ISO Code), default: "US" + sandbox: Use sandbox environment for testing (default: False) Returns: AvailableNumbersResponse: List of available numbers with their features @@ -292,7 +301,9 @@ def get_available_numbers( return result # Legacy methods for backward compatibility - def send(self, to: str, body: str, from_: Optional[str] = None, **kwargs) -> "SMSQuickSendResponse": + def send( + self, to: str, body: str, from_: Optional[str] = None, sandbox: bool = False, **kwargs + ) -> "SMSQuickSendResponse": """ Legacy method for sending SMS (backward compatibility). @@ -300,6 +311,7 @@ def send(self, to: str, body: str, from_: Optional[str] = None, **kwargs) -> "SM to: The recipient's phone number in E.164 format body: The message body text from_: The sender's phone number (optional) + sandbox: Use sandbox environment for testing (default: False) **kwargs: Additional parameters (ignored for compatibility) Returns: diff --git a/src/devo_global_comms_python/resources/whatsapp.py b/src/devo_global_comms_python/resources/whatsapp.py index fd4598d..60d69ac 100644 --- a/src/devo_global_comms_python/resources/whatsapp.py +++ b/src/devo_global_comms_python/resources/whatsapp.py @@ -50,6 +50,7 @@ def get_accounts( limit: Optional[int] = None, is_approved: Optional[bool] = None, search: Optional[str] = None, + sandbox: bool = False, ) -> "GetWhatsAppAccountsResponse": """ Get all shared WhatsApp accounts. @@ -92,7 +93,7 @@ def get_accounts( return GetWhatsAppAccountsResponse.model_validate(response.json()) - def get_template(self, name: str) -> "WhatsAppTemplate": + def get_template(self, name: str, sandbox: bool = False) -> "WhatsAppTemplate": """ Get a WhatsApp template by name. @@ -126,6 +127,7 @@ def upload_file( file_content: bytes, filename: str, content_type: str, + sandbox: bool = False, ) -> "WhatsAppUploadFileResponse": """ Upload a file for WhatsApp messaging. @@ -177,6 +179,7 @@ def send_normal_message( to: str, message: str, account_id: Optional[str] = None, + sandbox: bool = False, ) -> "WhatsAppSendMessageResponse": """ Send a normal WhatsApp message. @@ -228,6 +231,7 @@ def create_template( self, account_id: str, template: "WhatsAppTemplateRequest", + sandbox: bool = False, ) -> "WhatsAppTemplateResponse": """ Create a WhatsApp template. diff --git a/tests/test_client.py b/tests/test_client.py index 68a9353..e45a4b6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,13 +20,12 @@ def test_client_initialization_with_api_key(self, api_key): def test_client_initialization_with_custom_params(self, api_key): """Test client initialization with custom parameters.""" - base_url = "https://custom.api.com" timeout = 60.0 max_retries = 5 - client = DevoClient(api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries) + client = DevoClient(api_key=api_key, timeout=timeout, max_retries=max_retries) - assert client.base_url == base_url + assert client.base_url == DevoClient.DEFAULT_BASE_URL assert client.timeout == timeout def test_client_has_all_resources(self, api_key): diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..45fa63a --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Tests for sandbox functionality in the Devo Global Communications Python SDK. +""" + +from unittest.mock import Mock, patch + +import pytest + +from devo_global_comms_python.client import DevoClient +from devo_global_comms_python.exceptions import DevoException + + +class TestSandboxFunctionality: + """Test sandbox API key switching functionality.""" + + def test_client_initialization_with_sandbox_api_key(self): + """Test that client can be initialized with sandbox API key.""" + client = DevoClient(api_key="test-api-key", sandbox_api_key="sandbox-api-key") + + assert client.api_key == "test-api-key" + assert client.sandbox_api_key == "sandbox-api-key" + + def test_client_initialization_without_sandbox_api_key(self): + """Test that client can be initialized without sandbox API key.""" + client = DevoClient(api_key="test-api-key") + + assert client.api_key == "test-api-key" + assert client.sandbox_api_key is None + + def test_sandbox_request_without_sandbox_api_key_raises_error(self): + """Test that sandbox request without sandbox API key raises appropriate error.""" + client = DevoClient(api_key="test-api-key") + + with pytest.raises(DevoException, match="Sandbox API key required when sandbox=True"): + client.get("test-endpoint", sandbox=True) + + @patch("devo_global_comms_python.client.requests.Session.request") + def test_sandbox_request_uses_sandbox_api_key(self, mock_request): + """Test that sandbox request uses sandbox API key for authentication.""" + # Mock successful response + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"success": True} + mock_request.return_value = mock_response + + client = DevoClient(api_key="production-api-key", sandbox_api_key="sandbox-api-key") + + # Make sandbox request + client.get("test-endpoint", sandbox=True) + + # Verify the request was made with sandbox API key + mock_request.assert_called_once() + call_args = mock_request.call_args + headers = call_args[1]["headers"] + + # Check that X-API-Key header contains sandbox API key + assert "X-API-Key" in headers + assert "sandbox-api-key" in headers["X-API-Key"] + + @patch("devo_global_comms_python.client.requests.Session.request") + def test_regular_request_uses_production_api_key(self, mock_request): + """Test that regular request uses production API key for authentication.""" + # Mock successful response + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"success": True} + mock_request.return_value = mock_response + + client = DevoClient(api_key="production-api-key", sandbox_api_key="sandbox-api-key") + + # Make regular request (sandbox=False by default) + client.get("test-endpoint") + + # Verify the request was made with production API key + mock_request.assert_called_once() + call_args = mock_request.call_args + headers = call_args[1]["headers"] + + # Check that X-API-Key header contains production API key + assert "X-API-Key" in headers + assert "production-api-key" in headers["X-API-Key"] + + @patch("devo_global_comms_python.client.requests.Session.request") + def test_sms_resource_sandbox_parameter(self, mock_request): + """Test that SMS resource functions correctly pass sandbox parameter.""" + # Mock successful response + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"senders": []} + mock_request.return_value = mock_response + + client = DevoClient(api_key="production-api-key", sandbox_api_key="sandbox-api-key") + + # Call SMS function with sandbox=True + client.sms.get_senders(sandbox=True) + + # Verify the request was made with sandbox API key + mock_request.assert_called_once() + call_args = mock_request.call_args + headers = call_args[1]["headers"] + + # Check that X-API-Key header contains sandbox API key + assert "X-API-Key" in headers + assert "sandbox-api-key" in headers["X-API-Key"] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_sms.py b/tests/test_sms.py index 86efe8c..75afd28 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -66,6 +66,7 @@ def test_send_sms_success(self, sms_resource, test_phone_number): "message": "Hello, World!", "hirvalidation": True, }, + sandbox=False, ) def test_send_sms_with_invalid_recipient(self, sms_resource): @@ -125,7 +126,7 @@ def test_get_senders_success(self, sms_resource): assert result.senders[1].istest is True # Verify the API call - sms_resource.client.get.assert_called_once_with("user-api/me/senders") + sms_resource.client.get.assert_called_once_with("user-api/me/senders", sandbox=False) def test_buy_number_success(self, sms_resource): """Test purchasing a phone number successfully."""