From d8435aaad8abdbea68cca95343de1f4a32d46fb7 Mon Sep 17 00:00:00 2001 From: Parman Date: Thu, 28 Aug 2025 13:51:56 +0330 Subject: [PATCH] feat: implement omni-channel messaging endpoint --- examples/README.md | 58 ++- examples/omni_channel_example.py | 442 ++++++++++++++++++ .../models/__init__.py | 5 +- .../models/messages.py | 74 ++- .../resources/messages.py | 73 ++- src/devo_global_comms_python/utils.py | 37 +- tests/test_messages.py | 324 +++++++++++++ 7 files changed, 977 insertions(+), 36 deletions(-) create mode 100644 examples/omni_channel_example.py create mode 100644 tests/test_messages.py diff --git a/examples/README.md b/examples/README.md index 6942792..810fed8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,6 +6,7 @@ This directory contains comprehensive examples for using the Devo Global Communi ### πŸš€ Overview - **`basic_usage.py`** - Interactive overview and launcher for all examples +- **`omni_channel_example.py`** - βœ… **Complete Omni-channel Messaging** - Unified API for all channels ### πŸ“± Communication Resources - **`sms_example.py`** - βœ… **Complete SMS API implementation** @@ -14,9 +15,14 @@ This directory contains comprehensive examples for using the Devo Global Communi - Search and purchase phone numbers - Legacy compatibility methods +- **`rcs_example.py`** - βœ… **Complete RCS API implementation** + - Account management and messaging operations + - Template and brand management + - Tester management and capability testing + - Rich messaging features (cards, carousels, suggestions) + - **`email_example.py`** - 🚧 **Placeholder** (Email functionality) - **`whatsapp_example.py`** - 🚧 **Placeholder** (WhatsApp functionality) -- **`rcs_example.py`** - 🚧 **Placeholder** (RCS functionality) ### πŸ‘₯ Management Resources - **`contacts_example.py`** - 🚧 **Placeholder** (Contact management) @@ -47,14 +53,62 @@ This provides an interactive menu to choose and run specific examples. #### Option 2: Run Individual Examples ```bash +# Omni-channel messaging (fully implemented) +python examples/omni_channel_example.py + # SMS functionality (fully implemented) python examples/sms_example.py +# RCS functionality (fully implemented) +python examples/rcs_example.py + # Other resources (placeholder examples) python examples/email_example.py python examples/whatsapp_example.py python examples/contacts_example.py -python examples/rcs_example.py +``` + +## 🌐 Omni-channel Messaging Examples (Fully Implemented) + +The unified messaging resource provides a single API endpoint to send messages through any channel: + +### πŸ”§ Available Functions +1. **Send Message** - `client.messages.send()` + - Uses POST `/api/v1/user-api/messages/send` + - Supports SMS, Email, WhatsApp, and RCS channels + - Channel-specific payload flexibility + - Unified response format + +### πŸ’‘ Key Features +- **Unified Interface**: One endpoint for all channels +- **Channel-specific Payloads**: Flexible payload structure for each channel +- **Type Safety**: Full Pydantic model validation +- **Metadata Support**: Custom metadata and webhook URLs +- **Bulk Messaging**: Easy iteration across multiple channels + +### πŸ“ Example Usage +```python +from devo_global_comms_python.models.messages import SendMessageDto + +# Send SMS +sms_data = SendMessageDto( + channel="sms", + to="+1234567890", + payload={"text": "Hello World"} +) +result = client.messages.send(sms_data) + +# Send Email +email_data = SendMessageDto( + channel="email", + to="user@example.com", + payload={ + "subject": "Test Email", + "text": "Hello World", + "html": "

Hello World

" + } +) +result = client.messages.send(email_data) ``` ## πŸ“± SMS Examples (Fully Implemented) diff --git a/examples/omni_channel_example.py b/examples/omni_channel_example.py new file mode 100644 index 0000000..23dbaa8 --- /dev/null +++ b/examples/omni_channel_example.py @@ -0,0 +1,442 @@ +import os +from datetime import datetime + +from devo_global_comms_python import DevoClient +from devo_global_comms_python.models.messages import SendMessageDto + + +def main(): + """ + Demonstrate omni-channel messaging capabilities. + + Shows how to send messages through different channels using + the unified messages.send() endpoint with channel-specific payloads. + """ + + # Initialize the client + api_key = os.getenv("DEVO_API_KEY") + if not api_key: + print("❌ Error: DEVO_API_KEY environment variable not set") + return + + client = DevoClient(api_key=api_key) + + print("πŸš€ Devo Global Communications - Omni-channel Messaging Example") + print("=" * 70) + + # Example 1: Send SMS Message + print("\nπŸ“± Sending SMS Message...") + try: + sms_message = SendMessageDto( + channel="sms", + to="+1234567890", + **{"from": "+0987654321"}, # Use dict unpacking for 'from' field + payload={"text": "Hello from Devo! This is an SMS message sent via omni-channel API."}, + callback_url="https://example.com/sms-webhook", + metadata={"campaign": "omni-demo", "type": "sms"}, + ) + + sms_result = client.messages.send(sms_message) + print("βœ… SMS sent successfully!") + print(f" Message ID: {sms_result.id}") + print(f" Status: {sms_result.status}") + print(f" Channel: {sms_result.channel}") + print(f" Created: {sms_result.created_at}") + + except Exception as e: + print(f"❌ SMS Error: {str(e)}") + + # Example 2: Send Email Message + print("\nπŸ“§ Sending Email Message...") + try: + email_message = SendMessageDto( + channel="email", + to="recipient@example.com", + **{"from": "sender@yourcompany.com"}, # Use dict unpacking for 'from' field + payload={ + "subject": "Welcome to Devo Global Communications!", + "text": "Hello! This is a plain text email sent via our omni-channel API.", + "html": """ + + +

Welcome to Devo Global Communications!

+

This is an HTML email sent via our omni-channel API.

+

Key features:

+ +

Best regards,
The Devo Team

+ + + """, + "attachments": [ + { + "filename": "welcome.pdf", + "content_type": "application/pdf", + "url": "https://example.com/files/welcome.pdf", + } + ], + }, + callback_url="https://example.com/email-webhook", + metadata={"campaign": "omni-demo", "type": "email"}, + ) + + email_result = client.messages.send(email_message) + print("βœ… Email sent successfully!") + print(f" Message ID: {email_result.id}") + print(f" Status: {email_result.status}") + print(f" Channel: {email_result.channel}") + print(f" Subject: {email_result.content.get('subject', 'N/A')}") + + except Exception as e: + print(f"❌ Email Error: {str(e)}") + + # Example 3: Send WhatsApp Message + print("\nπŸ’¬ Sending WhatsApp Message...") + try: + whatsapp_message = SendMessageDto( + channel="whatsapp", + to="+1234567890", + payload={ + "type": "text", + "text": { + "body": ( + "πŸŽ‰ Hello from Devo! This WhatsApp message was sent " + "using our omni-channel API. Pretty cool, right?" + ) + }, + }, + callback_url="https://example.com/whatsapp-webhook", + metadata={"campaign": "omni-demo", "type": "whatsapp"}, + ) + + whatsapp_result = client.messages.send(whatsapp_message) + print("βœ… WhatsApp message sent successfully!") + print(f" Message ID: {whatsapp_result.id}") + print(f" Status: {whatsapp_result.status}") + print(f" Channel: {whatsapp_result.channel}") + + except Exception as e: + print(f"❌ WhatsApp Error: {str(e)}") + + # Example 4: Send WhatsApp Template Message + print("\nπŸ“‹ Sending WhatsApp Template Message...") + try: + whatsapp_template = SendMessageDto( + channel="whatsapp", + to="+1234567890", + payload={ + "type": "template", + "template": { + "name": "welcome_message", + "language": {"code": "en"}, + "components": [ + { + "type": "body", + "parameters": [ + {"type": "text", "text": "John Doe"}, + { + "type": "text", + "text": "Devo Communications", + }, + ], + } + ], + }, + }, + metadata={"campaign": "omni-demo", "type": "whatsapp-template"}, + ) + + template_result = client.messages.send(whatsapp_template) + print("βœ… WhatsApp template sent successfully!") + print(f" Message ID: {template_result.id}") + print(f" Status: {template_result.status}") + + except Exception as e: + print(f"❌ WhatsApp Template Error: {str(e)}") + + # Example 5: Send RCS Message + print("\nπŸ’Ž Sending RCS Message...") + try: + rcs_message = SendMessageDto( + channel="rcs", + to="+1234567890", + payload={ + "message_type": "text", + "text": ( + "Hello from Devo! This is a Rich Communication Services " "(RCS) message with enhanced features." + ), + "agent_id": "your-rcs-agent-id", + "suggestions": [ + { + "reply": { + "text": "Learn More", + "postback_data": "learn_more", + } + }, + { + "action": { + "text": "Visit Website", + "postback_data": "visit_website", + "open_url": {"url": "https://devo.com"}, + } + }, + ], + }, + callback_url="https://example.com/rcs-webhook", + metadata={"campaign": "omni-demo", "type": "rcs"}, + ) + + rcs_result = client.messages.send(rcs_message) + print("βœ… RCS message sent successfully!") + print(f" Message ID: {rcs_result.id}") + print(f" Status: {rcs_result.status}") + print(f" Channel: {rcs_result.channel}") + + except Exception as e: + print(f"❌ RCS Error: {str(e)}") + + # Example 6: Send RCS Rich Card + print("\n🎴 Sending RCS Rich Card...") + try: + rcs_rich_card = SendMessageDto( + channel="rcs", + to="+1234567890", + payload={ + "message_type": "rich_card", + "rich_card": { + "standalone_card": { + "card_content": { + "title": "Devo Global Communications", + "description": "Experience the power of omni-channel messaging with our unified API.", + "media": { + "height": "TALL", + "content_info": { + "file_url": "https://example.com/images/devo-card.jpg", + "force_refresh": False, + }, + }, + }, + "card_actions": [ + { + "action_type": "open_url", + "action_data": "https://devo.com", + "label": "Learn More", + }, + { + "action_type": "dial", + "action_data": "+1234567890", + "label": "Call Us", + }, + ], + } + }, + }, + metadata={"campaign": "omni-demo", "type": "rcs-rich-card"}, + ) + + rich_card_result = client.messages.send(rcs_rich_card) + print("βœ… RCS rich card sent successfully!") + print(f" Message ID: {rich_card_result.id}") + print(f" Status: {rich_card_result.status}") + + except Exception as e: + print(f"❌ RCS Rich Card Error: {str(e)}") + + # Example 7: Bulk messaging across channels + print("\nπŸ“Š Bulk Messaging Demo...") + try: + recipients = [ + { + "channel": "sms", + "to": "+1234567890", + "message": "SMS bulk message", + }, + { + "channel": "email", + "to": "user1@example.com", + "message": "Email bulk message", + }, + { + "channel": "whatsapp", + "to": "+1234567891", + "message": "WhatsApp bulk message", + }, + ] + + bulk_results = [] + + for recipient in recipients: + if recipient["channel"] == "sms": + payload = {"text": recipient["message"]} + elif recipient["channel"] == "email": + payload = { + "subject": "Bulk Message from Devo", + "text": recipient["message"], + "html": f"

{recipient['message']}

", + } + elif recipient["channel"] == "whatsapp": + payload = { + "type": "text", + "text": {"body": recipient["message"]}, + } + + bulk_message = SendMessageDto( + channel=recipient["channel"], + to=recipient["to"], + payload=payload, + metadata={"campaign": "bulk-demo", "batch_id": "batch_001"}, + ) + + result = client.messages.send(bulk_message) + bulk_results.append(result) + print(f" βœ… {recipient['channel'].upper()}: {result.id} -> {result.status}") + + print(f"πŸ“ˆ Bulk messaging completed! Sent {len(bulk_results)} messages") + + except Exception as e: + print(f"❌ Bulk Messaging Error: {str(e)}") + + print("\n" + "=" * 70) + print("🎯 Omni-channel messaging demo completed!") + print("\nKey Benefits:") + print("β€’ Unified API for all communication channels") + print("β€’ Channel-specific payload flexibility") + print("β€’ Consistent response format") + print("β€’ Real-time status tracking") + print("β€’ Metadata and webhook support") + print("β€’ Type-safe models with validation") + + +def send_notification_example(): + """ + Example of a practical notification system using omni-channel messaging. + + This demonstrates how you might implement a notification service that + sends the same message through different channels based on user preferences. + """ + + print("\n" + "=" * 50) + print("πŸ“’ Notification System Example") + print("=" * 50) + + # Simulated user preferences + users = [ + { + "name": "Alice", + "channel": "email", + "contact": "alice@example.com", + "language": "en", + }, + { + "name": "Bob", + "channel": "sms", + "contact": "+1234567890", + "language": "en", + }, + { + "name": "Carlos", + "channel": "whatsapp", + "contact": "+1234567891", + "language": "es", + }, + { + "name": "Diana", + "channel": "rcs", + "contact": "+1234567892", + "language": "en", + }, + ] + + # Common notification content + notification = { + "en": { + "title": "System Maintenance Notice", + "message": ( + "Our system will undergo maintenance on Sunday, 2:00 AM - 4:00 AM EST. " + "Services may be temporarily unavailable." + ), + }, + "es": { + "title": "Aviso de Mantenimiento del Sistema", + "message": ( + "Nuestro sistema se someterΓ‘ a mantenimiento el domingo de 2:00 AM a 4:00 AM EST. " + "Los servicios pueden no estar disponibles temporalmente." + ), + }, + } + + api_key = os.getenv("DEVO_API_KEY") + if not api_key: + print("❌ Error: DEVO_API_KEY environment variable not set") + return + + client = DevoClient(api_key=api_key) + + for user in users: + try: + content = notification[user["language"]] + + # Create channel-specific payload + if user["channel"] == "email": + payload = { + "subject": content["title"], + "text": content["message"], + "html": f""" + + +

{content["title"]}

+

{content["message"]}

+

This is an automated notification.

+ + + """, + } + elif user["channel"] == "sms": + payload = {"text": f"{content['title']}: {content['message']}"} + elif user["channel"] == "whatsapp": + payload = { + "type": "text", + "text": {"body": f"*{content['title']}*\n\n{content['message']}"}, + } + elif user["channel"] == "rcs": + payload = { + "message_type": "text", + "text": f"{content['title']}\n\n{content['message']}", + "suggestions": [ + { + "reply": { + "text": "Acknowledged", + "postback_data": "maintenance_ack", + } + } + ], + } + + # Send notification + message = SendMessageDto( + channel=user["channel"], + to=user["contact"], + payload=payload, + metadata={ + "notification_type": "maintenance", + "user_name": user["name"], + "language": user["language"], + "timestamp": datetime.now().isoformat(), + }, + ) + + result = client.messages.send(message) + print(f"βœ… {user['name']} ({user['channel']}): {result.id} -> {result.status}") + + except Exception as e: + print(f"❌ Failed to notify {user['name']}: {str(e)}") + + print("\nπŸ“Š Notification broadcast completed!") + + +if __name__ == "__main__": + main() + send_notification_example() diff --git a/src/devo_global_comms_python/models/__init__.py b/src/devo_global_comms_python/models/__init__.py index 7e092d4..94a4502 100644 --- a/src/devo_global_comms_python/models/__init__.py +++ b/src/devo_global_comms_python/models/__init__.py @@ -1,6 +1,6 @@ from .contacts import Contact from .email import EmailMessage -from .messages import Message +from .messages import Message, SendMessageDto, SendMessageSerializer from .rcs import RCSMessage from .sms import ( AvailableNumbersResponse, @@ -26,6 +26,9 @@ "RCSMessage", "Contact", "Message", + # Omni-channel messaging models + "SendMessageDto", + "SendMessageSerializer", # New SMS API models "SMSQuickSendRequest", "SMSQuickSendResponse", diff --git a/src/devo_global_comms_python/models/messages.py b/src/devo_global_comms_python/models/messages.py index cc3fb87..ce219bc 100644 --- a/src/devo_global_comms_python/models/messages.py +++ b/src/devo_global_comms_python/models/messages.py @@ -1,9 +1,57 @@ from datetime import datetime -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Literal, Optional from pydantic import BaseModel, Field +# Omni-channel send message models +class SendMessageDto(BaseModel): + """ + Unified send message DTO for omni-channel messaging. + + Allows sending messages through any channel (SMS, Email, WhatsApp, RCS) + with channel-specific payloads. + """ + + channel: Literal["sms", "email", "whatsapp", "rcs"] = Field(..., description="Communication channel to use") + to: str = Field(..., description="Recipient identifier (phone/email)") + from_: Optional[str] = Field(None, alias="from", description="Sender identifier") + + # Channel-specific payload + payload: Dict[str, Any] = Field(..., description="Channel-specific message payload") + + # Common optional fields + callback_url: Optional[str] = Field(None, description="Webhook URL for status updates") + metadata: Optional[Dict[str, Any]] = Field(None, description="Custom metadata") + + class Config: + populate_by_name = True + + +class SendMessageSerializer(BaseModel): + """ + Response serializer for omni-channel message sending. + """ + + id: str = Field(..., description="Message ID") + channel: str = Field(..., description="Channel used for sending") + to: str = Field(..., description="Recipient") + from_: Optional[str] = Field(None, alias="from", description="Sender") + status: str = Field(..., description="Message status") + direction: str = Field(..., description="Message direction") + + # Channel-specific content + content: Dict[str, Any] = Field(..., description="Message content") + + # Tracking and metadata + pricing: Optional[Dict[str, Any]] = Field(None, description="Pricing information") + created_at: datetime = Field(..., description="Creation timestamp") + metadata: Optional[Dict[str, Any]] = Field(None, description="Custom metadata") + + class Config: + populate_by_name = True + + class Message(BaseModel): """ Unified message model. @@ -14,9 +62,7 @@ class Message(BaseModel): id: str = Field(..., description="Unique identifier for the message") account_id: Optional[str] = Field(None, description="Account identifier") - channel: str = Field( - ..., description="Communication channel (sms, email, whatsapp, rcs)" - ) + channel: str = Field(..., description="Communication channel (sms, email, whatsapp, rcs)") type: str = Field(..., description="Message type") # Recipient/sender information @@ -24,18 +70,14 @@ class Message(BaseModel): from_: Optional[str] = Field(None, alias="from", description="Sender identifier") # Message content (varies by channel) - content: Dict[str, Any] = Field( - ..., description="Message content (channel-specific)" - ) + content: Dict[str, Any] = Field(..., description="Message content (channel-specific)") # Status and delivery status: str = Field(..., description="Message status") direction: str = Field(..., description="Message direction (inbound/outbound)") # Delivery tracking - delivery_status: Optional[Dict[str, Any]] = Field( - None, description="Detailed delivery status" - ) + delivery_status: Optional[Dict[str, Any]] = Field(None, description="Detailed delivery status") # Pricing and billing pricing: Optional[Dict[str, Any]] = Field(None, description="Pricing information") @@ -45,17 +87,11 @@ class Message(BaseModel): error_message: Optional[str] = Field(None, description="Error message if failed") # Timestamps - date_created: Optional[datetime] = Field( - None, description="Message creation timestamp" - ) + date_created: Optional[datetime] = Field(None, description="Message creation timestamp") date_sent: Optional[datetime] = Field(None, description="Message sent timestamp") - date_delivered: Optional[datetime] = Field( - None, description="Message delivered timestamp" - ) + date_delivered: Optional[datetime] = Field(None, description="Message delivered timestamp") date_read: Optional[datetime] = Field(None, description="Message read timestamp") - date_updated: Optional[datetime] = Field( - None, description="Message last updated timestamp" - ) + date_updated: Optional[datetime] = Field(None, description="Message last updated timestamp") # Metadata metadata: Optional[Dict[str, Any]] = Field(None, description="Custom metadata") diff --git a/src/devo_global_comms_python/resources/messages.py b/src/devo_global_comms_python/resources/messages.py index 1169e4c..221e9e9 100644 --- a/src/devo_global_comms_python/resources/messages.py +++ b/src/devo_global_comms_python/resources/messages.py @@ -1,20 +1,83 @@ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ..utils import validate_required_string +from ..utils import validate_required_string, validate_response from .base import BaseResource if TYPE_CHECKING: - from ..models.messages import Message + from ..models.messages import Message, SendMessageDto, SendMessageSerializer class MessagesResource(BaseResource): """ Unified messages resource for managing messages across all channels. - This resource provides a unified interface to view and manage messages - sent through any channel (SMS, Email, WhatsApp, RCS). + This resource provides a unified interface to send, view and manage messages + across any channel (SMS, Email, WhatsApp, RCS). """ + def send(self, data: "SendMessageDto") -> "SendMessageSerializer": + """ + Send a message through any channel (omni-channel endpoint). + + This unified endpoint allows sending messages through SMS, Email, + WhatsApp, or RCS channels using channel-specific payloads. + + Args: + data: SendMessageDto containing channel, recipient, and payload + + Returns: + SendMessageSerializer with sent message details + + Example: + # Send SMS + from ..models.messages import SendMessageDto + + sms_data = SendMessageDto( + channel="sms", + to="+1234567890", + payload={"text": "Hello World"} + ) + message = client.messages.send(sms_data) + + # Send Email + email_data = SendMessageDto( + channel="email", + to="user@example.com", + payload={ + "subject": "Test Email", + "text": "Hello World", + "html": "

Hello World

" + } + ) + message = client.messages.send(email_data) + + # Send WhatsApp + whatsapp_data = SendMessageDto( + channel="whatsapp", + to="+1234567890", + payload={ + "type": "text", + "text": {"body": "Hello World"} + } + ) + message = client.messages.send(whatsapp_data) + + # Send RCS + rcs_data = SendMessageDto( + channel="rcs", + to="+1234567890", + payload={ + "message_type": "text", + "text": "Hello World" + } + ) + message = client.messages.send(rcs_data) + """ + from ..models.messages import SendMessageSerializer + + response = self.client.post("messages/send", data=data.model_dump(by_alias=True, exclude_none=True)) + return validate_response(response, SendMessageSerializer) + def get(self, message_id: str) -> "Message": """ Retrieve a message by ID from any channel. diff --git a/src/devo_global_comms_python/utils.py b/src/devo_global_comms_python/utils.py index ec30454..4c4d38e 100644 --- a/src/devo_global_comms_python/utils.py +++ b/src/devo_global_comms_python/utils.py @@ -1,11 +1,11 @@ import re -from typing import Optional +from typing import Optional, Type, TypeVar -from .exceptions import ( - DevoInvalidEmailException, - DevoInvalidPhoneNumberException, - DevoValidationException, -) +from pydantic import BaseModel + +from .exceptions import DevoInvalidEmailException, DevoInvalidPhoneNumberException, DevoValidationException + +T = TypeVar("T", bound=BaseModel) def validate_phone_number(phone_number: str) -> str: @@ -29,9 +29,7 @@ def validate_phone_number(phone_number: str) -> str: # Check if it starts with + and has digits if not re.match(r"^\+\d{10,15}$", cleaned): - raise DevoInvalidPhoneNumberException( - "Phone number must be in E.164 format (e.g., +1234567890)" - ) + raise DevoInvalidPhoneNumberException("Phone number must be in E.164 format (e.g., +1234567890)") return cleaned @@ -108,6 +106,27 @@ def format_datetime(dt) -> str: return dt.isoformat() +def validate_response(response, model_class: Type[T]) -> T: + """ + Validate and parse API response into a Pydantic model. + + Args: + response: HTTP response object with json() method + model_class: Pydantic model class to parse response into + + Returns: + Parsed model instance + + Raises: + DevoValidationException: If response parsing fails + """ + try: + data = response.json() + return model_class(**data) + except Exception as e: + raise DevoValidationException(f"Failed to parse response: {str(e)}") + + def parse_webhook_signature(signature_header: str) -> dict: """ Parse webhook signature header. diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..2fad7b9 --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,324 @@ +from datetime import datetime +from unittest.mock import Mock + +import pytest + +from src.devo_global_comms_python.models.messages import SendMessageDto, SendMessageSerializer +from src.devo_global_comms_python.resources.messages import MessagesResource + + +class TestMessagesResource: + """Test cases for the MessagesResource class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.mock_client = Mock() + self.messages_resource = MessagesResource(self.mock_client) + + def test_send_sms_message(self): + """Test sending an SMS message through omni-channel endpoint.""" + # Arrange + send_data = SendMessageDto(channel="sms", to="+1234567890", payload={"text": "Hello World"}) + + mock_response = Mock() + mock_response.json.return_value = { + "id": "msg_123", + "channel": "sms", + "to": "+1234567890", + "from": "+0987654321", + "status": "sent", + "direction": "outbound", + "content": {"text": "Hello World"}, + "created_at": "2024-01-01T12:00:00Z", + } + self.mock_client.post.return_value = mock_response + + # Act + result = self.messages_resource.send(send_data) + + # Assert + assert isinstance(result, SendMessageSerializer) + assert result.id == "msg_123" + assert result.channel == "sms" + assert result.to == "+1234567890" + assert result.status == "sent" + assert result.content == {"text": "Hello World"} + + self.mock_client.post.assert_called_once_with( + "messages/send", data={"channel": "sms", "to": "+1234567890", "payload": {"text": "Hello World"}} + ) + + def test_send_email_message(self): + """Test sending an email message through omni-channel endpoint.""" + # Arrange + send_data = SendMessageDto( + channel="email", + to="user@example.com", + **{"from": "sender@example.com"}, + payload={"subject": "Test Email", "text": "Hello World", "html": "

Hello World

"}, + callback_url="https://example.com/webhook", + metadata={"campaign": "test"}, + ) + + mock_response = Mock() + mock_response.json.return_value = { + "id": "email_456", + "channel": "email", + "to": "user@example.com", + "from": "sender@example.com", + "status": "queued", + "direction": "outbound", + "content": {"subject": "Test Email", "text": "Hello World", "html": "

Hello World

"}, + "created_at": "2024-01-01T12:00:00Z", + "metadata": {"campaign": "test"}, + } + self.mock_client.post.return_value = mock_response + + # Act + result = self.messages_resource.send(send_data) + + # Assert + assert isinstance(result, SendMessageSerializer) + assert result.id == "email_456" + assert result.channel == "email" + assert result.to == "user@example.com" + assert result.from_ == "sender@example.com" + assert result.status == "queued" + assert result.metadata == {"campaign": "test"} + + self.mock_client.post.assert_called_once_with( + "messages/send", + data={ + "channel": "email", + "to": "user@example.com", + "from": "sender@example.com", + "payload": {"subject": "Test Email", "text": "Hello World", "html": "

Hello World

"}, + "callback_url": "https://example.com/webhook", + "metadata": {"campaign": "test"}, + }, + ) + + def test_send_whatsapp_message(self): + """Test sending a WhatsApp message through omni-channel endpoint.""" + # Arrange + send_data = SendMessageDto( + channel="whatsapp", to="+1234567890", payload={"type": "text", "text": {"body": "Hello World"}} + ) + + mock_response = Mock() + mock_response.json.return_value = { + "id": "wa_789", + "channel": "whatsapp", + "to": "+1234567890", + "status": "sent", + "direction": "outbound", + "content": {"type": "text", "text": {"body": "Hello World"}}, + "created_at": "2024-01-01T12:00:00Z", + } + self.mock_client.post.return_value = mock_response + + # Act + result = self.messages_resource.send(send_data) + + # Assert + assert isinstance(result, SendMessageSerializer) + assert result.id == "wa_789" + assert result.channel == "whatsapp" + assert result.to == "+1234567890" + assert result.status == "sent" + + self.mock_client.post.assert_called_once_with( + "messages/send", + data={ + "channel": "whatsapp", + "to": "+1234567890", + "payload": {"type": "text", "text": {"body": "Hello World"}}, + }, + ) + + def test_send_rcs_message(self): + """Test sending an RCS message through omni-channel endpoint.""" + # Arrange + send_data = SendMessageDto( + channel="rcs", + to="+1234567890", + payload={"message_type": "text", "text": "Hello World", "agent_id": "agent_123"}, + ) + + mock_response = Mock() + mock_response.json.return_value = { + "id": "rcs_abc", + "channel": "rcs", + "to": "+1234567890", + "status": "sent", + "direction": "outbound", + "content": {"message_type": "text", "text": "Hello World", "agent_id": "agent_123"}, + "created_at": "2024-01-01T12:00:00Z", + } + self.mock_client.post.return_value = mock_response + + # Act + result = self.messages_resource.send(send_data) + + # Assert + assert isinstance(result, SendMessageSerializer) + assert result.id == "rcs_abc" + assert result.channel == "rcs" + assert result.to == "+1234567890" + assert result.status == "sent" + + self.mock_client.post.assert_called_once_with( + "messages/send", + data={ + "channel": "rcs", + "to": "+1234567890", + "payload": {"message_type": "text", "text": "Hello World", "agent_id": "agent_123"}, + }, + ) + + def test_send_message_with_validation_error(self): + """Test send message with invalid channel.""" + # Arrange & Act & Assert + with pytest.raises(ValueError): + SendMessageDto( + channel="invalid_channel", to="+1234567890", payload={"text": "Hello World"} # Invalid channel + ) + + def test_send_message_api_error(self): + """Test send message when API returns error.""" + # Arrange + send_data = SendMessageDto(channel="sms", to="+1234567890", payload={"text": "Hello World"}) + + self.mock_client.post.side_effect = Exception("API Error") + + # Act & Assert + with pytest.raises(Exception, match="API Error"): + self.messages_resource.send(send_data) + + def test_send_message_dto_alias_handling(self): + """Test that SendMessageDto properly handles the 'from' field alias.""" + # Arrange + send_data = SendMessageDto( + channel="sms", + to="+1234567890", + **{"from": "+0987654321"}, # Use from as keyword argument + payload={"text": "Hello World"}, + ) + + # Act + data_dict = send_data.model_dump(by_alias=True, exclude_none=True) + + # Assert + assert "from" in data_dict + assert "from_" not in data_dict + assert data_dict["from"] == "+0987654321" + + def test_send_message_serializer_datetime_parsing(self): + """Test that SendMessageSerializer properly parses datetime fields.""" + # Arrange + data = { + "id": "msg_123", + "channel": "sms", + "to": "+1234567890", + "status": "sent", + "direction": "outbound", + "content": {"text": "Hello World"}, + "created_at": "2024-01-01T12:00:00Z", + } + + # Act + serializer = SendMessageSerializer(**data) + + # Assert + assert isinstance(serializer.created_at, datetime) + assert serializer.created_at.year == 2024 + assert serializer.created_at.month == 1 + assert serializer.created_at.day == 1 + + +class TestSendMessageDto: + """Test cases for SendMessageDto model.""" + + def test_valid_sms_payload(self): + """Test creating valid SMS payload.""" + dto = SendMessageDto(channel="sms", to="+1234567890", payload={"text": "Hello World"}) + + assert dto.channel == "sms" + assert dto.to == "+1234567890" + assert dto.payload == {"text": "Hello World"} + + def test_valid_email_payload(self): + """Test creating valid email payload.""" + dto = SendMessageDto( + channel="email", to="user@example.com", payload={"subject": "Test", "text": "Hello", "html": "

Hello

"} + ) + + assert dto.channel == "email" + assert dto.to == "user@example.com" + assert dto.payload["subject"] == "Test" + + def test_invalid_channel(self): + """Test that invalid channel raises validation error.""" + with pytest.raises(ValueError): + SendMessageDto(channel="invalid", to="+1234567890", payload={"text": "Hello"}) + + def test_optional_fields(self): + """Test optional fields are handled correctly.""" + dto = SendMessageDto( + channel="sms", + to="+1234567890", + **{"from": "+0987654321"}, # Use from as keyword argument + payload={"text": "Hello"}, + callback_url="https://example.com/webhook", + metadata={"key": "value"}, + ) + + assert dto.from_ == "+0987654321" + assert dto.callback_url == "https://example.com/webhook" + assert dto.metadata == {"key": "value"} + + +class TestSendMessageSerializer: + """Test cases for SendMessageSerializer model.""" + + def test_basic_serialization(self): + """Test basic serialization of response data.""" + data = { + "id": "msg_123", + "channel": "sms", + "to": "+1234567890", + "status": "sent", + "direction": "outbound", + "content": {"text": "Hello World"}, + "created_at": "2024-01-01T12:00:00Z", + } + + serializer = SendMessageSerializer(**data) + + assert serializer.id == "msg_123" + assert serializer.channel == "sms" + assert serializer.to == "+1234567890" + assert serializer.status == "sent" + assert serializer.direction == "outbound" + assert serializer.content == {"text": "Hello World"} + + def test_optional_fields_serialization(self): + """Test serialization with optional fields.""" + data = { + "id": "msg_123", + "channel": "email", + "to": "user@example.com", + "from": "sender@example.com", + "status": "delivered", + "direction": "outbound", + "content": {"subject": "Test", "text": "Hello"}, + "pricing": {"cost": 0.01, "currency": "USD"}, + "created_at": "2024-01-01T12:00:00Z", + "metadata": {"campaign": "test"}, + } + + serializer = SendMessageSerializer(**data) + + assert serializer.from_ == "sender@example.com" + assert serializer.pricing == {"cost": 0.01, "currency": "USD"} + assert serializer.metadata == {"campaign": "test"}