diff --git a/docs/api_reference.rst b/docs/api_reference.rst index f5535e8..9fce384 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -73,6 +73,12 @@ Wake API .. automodule:: textverified.wake_api :members: +Mocking API +~~~~~~~~~~~ + +.. automodule:: textverified.mocking + :members: + PaginatedList --------------- diff --git a/docs/examples.rst b/docs/examples.rst index b16cd9a..4acc208 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -206,17 +206,10 @@ Wakeable Rental Example # 2. Start a wake request for the rental print("Sending wake request and waiting for active window...") wake_request = wake_requests.create(rental) - duration = wake_request.usage_window_end - wake_request.usage_window_start - print( - f"Number {rental.number} is active from {wake_request.usage_window_start}" - f" to {wake_request.usage_window_end} (duration: {duration})" - ) - - # 3. Wait for the wake request to complete - time_until_start = wake_request.usage_window_start - datetime.datetime.now(datetime.timezone.utc) - print(f"Waiting for the number to become active... ({time_until_start})") wake_response = wake_requests.wait_for_wake_request(wake_request) + duration = wake_response.usage_window_end - wake_response.usage_window_start + print(f"Number {rental.number} is now active until {wake_response.usage_window_end} (duration: {duration})") # 3. Poll for SMS messages on the awakened number print(f"Polling SMS messages for number {rental.number}...") @@ -260,3 +253,119 @@ Proper error handling for production use: Note that all API requests use exponential backoff for retries, and retries on connection error or ratelimit errors. +Mocking Examples +---------------- + +The mocking feature allows you to create mock objects for testing and development without making actual API calls. + +Basic Verification Workflow with Mock ID +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from textverified import TextVerified, mocking + + # Create a mock verification + mock_verification = mocking.verification() + print(f"Mock verification ID: {mock_verification.id}") + + client = TextVerified(api_key="your_key", api_username="your_username") + + # Use mock ID with real API methods + verification = client.verifications.details(mock_verification.id) + print(f"Verification number: {verification.number}") + + # Poll for SMS messages + messages = client.sms.incoming(verification, timeout=10) + for message in messages: + print(f"Received code: {message.parsed_code}") + break + +Verification Mock Purchase +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from textverified import TextVerified, mocking, ReservationCapability, NewVerificationRequest + + # Create a mock service name + mock_service = mocking.target() + print(f"Mock service: {mock_service}") + + client = TextVerified(api_key="your_key", api_username="your_username") + + # Create verification request with mock service + request = NewVerificationRequest( + service_name=mock_service, + capability=ReservationCapability.SMS + ) + + # Get pricing and create verification + price_snapshot = client.verifications.pricing(request) + assert price_snapshot.price == 0.0 # will be 0.0 for mock + verification = client.verifications.create(request) + print(f"Created verification: {verification.id}") + +Testing Error Handling with Random Failures +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from textverified import TextVerified, mocking, MockBehavior + + # Create mock that fails randomly (helps test error handling) + mock_verification = mocking.verification(behavior=MockBehavior.FailsRandomly) + + client = TextVerified(api_key="your_key", api_username="your_username") + + # API calls will fail ~ 33% of the time, use this to test your error handling + try: + verification = client.verifications.details(mock_verification.id) + print("Verification succeeded") + except Exception as e: + print(f"Verification failed: {e}") + +Rental Workflow with Mock ID +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from textverified import TextVerified, mocking + + # Create a mock rental + mock_rental = mocking.rental() + print(f"Mock rental ID: {mock_rental.id}") + + client = TextVerified(api_key="your_key", api_username="your_username") + + # Get rental details + rental = client.reservations.details(mock_rental.id) + print(f"Rental number: {rental.number}") + + # List SMS messages + messages = client.sms.list() + print(f"Found {len(messages)} messages") + +Wake Rental Workflow with Mock ID +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from textverified import TextVerified, mocking + + # Create mock rental for wake testing + mock_rental = mocking.rental() + + client = TextVerified(api_key="your_key", api_username="your_username") + + # Get rental and create wake request + rental = client.reservations.details(mock_rental.id) + wake_request = client.wake_requests.create(rental) + + # Poll for messages during wake window + wake_request.wait_for_wake_request() # This will block for mocks + messages = client.sms.incoming(rental, timeout=10) + for message in messages: + print(f"Received: {message.sms_content}") + break + diff --git a/examples/credential_set.py b/examples/credential_set.py index ac6d049..2e630a3 100644 --- a/examples/credential_set.py +++ b/examples/credential_set.py @@ -11,6 +11,11 @@ textverified.configure(api_key="your_api_key", api_username="your_username") +# Now we can call any API method as usual. + +# The order of imports is not important - +# this import could have been before .configure + from textverified import account as tv_account # Get account details diff --git a/examples/mocking.py b/examples/mocking.py new file mode 100644 index 0000000..9b732e6 --- /dev/null +++ b/examples/mocking.py @@ -0,0 +1,185 @@ +""" +Mocking Examples for TextVerified Python Client + +This example demonstrates how to use mock verification and rental IDs +in real API workflows for testing and development. +""" + +from textverified import TextVerified, mocking, MockBehavior, MockReceivePolicy, ReservationCapability, NewVerificationRequest +import time + +client = TextVerified(api_key="YOUR_KEY", api_username="YOUR_EMAIL") + +def mock_verification_workflow(): + """Complete verification workflow using a mock verification ID.""" + print("Mock Verification Workflow") + print("=" * 30) + + # Create a mock verification + mock_verification = mocking.verification(receive_policy=MockReceivePolicy.IncomingText) # default behavior, but explicitly specified + print(f"Created mock verification: {mock_verification.id}") + + try: + # Use the real client for verification details and state + verification = client.verifications.details(mock_verification.id) + print(f"Verification number: {verification.number}") + print(f"Verification state: {verification.state}") + + # Poll for SMS messages with base + print("Polling for SMS messages...") + messages = client.sms.incoming(verification, timeout=10) + for message in messages: + print(f"Received SMS: {message.sms_content}") + print(f"Parsed code: {message.parsed_code}") + + except Exception as e: + print(f"API call failed (expected with mock credentials): {type(e).__name__}") + + print() + + +def mock_verification_purchase_workflow(): + """Verification purchase workflow using a mock service name.""" + print("Mock Verification Purchase Workflow") + print("=" * 30) + + # Create a mock service name (target) + # You can also use mocking.service(...) as an alias + mock_service = mocking.target(behavior=MockBehavior.Succeeds) + print(f"Created mock service: {mock_service}") + + try: + # Create a verification request using the mock service name + request = NewVerificationRequest( + service_name=mock_service, + capability=ReservationCapability.SMS + ) + + # Get pricing for the mock service + price = client.verifications.pricing(request) + print(f"Pricing for mock service: ${price.price}") + + # Create the verification + verification = client.verifications.create(request) + print(f"Verification created: {verification.id}") + print(f"Verification number: {verification.number}") + + # Poll for SMS messages + print("Polling for verification code...") + for message in client.sms.incoming(verification, timeout=10): + print(f"Received SMS: {message.sms_content}") + print(f"Parsed verification code: {message.parsed_code}") + break + + except Exception as e: + print(f"API call failed (expected with mock credentials): {type(e).__name__}") + + print() + + +def mock_verification_with_random_failures(): + """Verification workflow that may fail randomly to test error handling.""" + print("Mock Verification with Random Failures") + print("=" * 30) + + # Create a mock verification that fails randomly (1/3 of the time) + # This helps test unexpected behavior and error handling + mock_verification = mocking.verification( + behavior=MockBehavior.FailsRandomly, + receive_policy=MockReceivePolicy.IncomingText + ) + print(f"Created mock verification: {mock_verification.id}") + + try: + verification = client.verifications.details(mock_verification.id) + print(f"Verification succeeded: {verification.number}") + + # This might succeed or fail, testing your error handling + for message in client.sms.incoming(verification, timeout=10): + print(f"SMS received: {message.parsed_code}") + + except Exception as e: + print(f"Verification failed (this may happen randomly): {type(e).__name__}") + + print() + + +def mock_rental_workflow(): + """Rental workflow using a mock rental ID.""" + print("Mock Rental Workflow") + print("=" * 30) + + # Create a mock rental + mock_rental = mocking.rental(behavior=MockBehavior.Succeeds) + print(f"Created mock rental: {mock_rental.id}") + + client = TextVerified(api_key="mock", api_username="mock") + + try: + # Get rental details + rental = client.reservations.details(mock_rental.id) + print(f"Rental number: {rental.number}") + print(f"Rental state: {rental.state}") + + # List SMS messages for the rental + messages = client.sms.list() + print(f"Found {len(messages)} SMS messages") + + except Exception as e: + print(f"API call failed (expected with mock credentials): {type(e).__name__}") + + print() + + +def mock_wake_rental_workflow(): + """Wake rental workflow using a mock rental ID.""" + print("Mock Wake Rental Workflow") + print("=" * 30) + + # Create a mock rental for wake testing + mock_rental = mocking.rental( + behavior=MockBehavior.Succeeds, + receive_policy=MockReceivePolicy.IncomingTextAndCall + ) + print(f"Created mock rental: {mock_rental.id}") + + try: + # Get rental details + rental = client.reservations.details(mock_rental.id) + print(f"Rental number: {rental.number}") + + # Create a wake request for the rental + wake_request = client.wake_requests.create(rental) + print(f"Wake request created: {wake_request.id}") + print(f"Active from {wake_request.usage_window_start} to {wake_request.usage_window_end}") + + # Poll for messages during the wake window + duration = wake_request.usage_window_end - wake_request.usage_window_start + print("Polling for messages during wake window...") + messages = client.sms.incoming(rental, timeout=duration.total_seconds()) + + for message in messages: + print(f"Received: {message.sms_content}") + break # Just show first message + + except Exception as e: + print(f"API call failed (expected with mock credentials): {type(e).__name__}") + + print() + + +if __name__ == "__main__": + print("TextVerified Mocking Examples") + print("=" * 30) + print() + + # Run all examples + mock_verification_workflow() + mock_verification_purchase_workflow() + mock_verification_with_random_failures() + mock_rental_workflow() + mock_wake_rental_workflow() + + print("Examples completed.") + print("Note: API calls use mock credentials and may fail - this demonstrates") + print("how mock IDs integrate with real API workflows for testing.") diff --git a/examples/wake_rental.py b/examples/wake_rental.py index 6871236..3b9926f 100644 --- a/examples/wake_rental.py +++ b/examples/wake_rental.py @@ -19,15 +19,18 @@ wake_request = wake_requests.create(rental) duration = wake_request.usage_window_end - wake_request.usage_window_start print( - f"Number {rental.number} is active from {wake_request.usage_window_start}" + f"Number {rental.number} is estimated to be active from {wake_request.usage_window_start}" f" to {wake_request.usage_window_end} (duration: {duration})" ) # 3. Wait for the wake request to complete time_until_start = wake_request.usage_window_start - datetime.datetime.now(datetime.timezone.utc) -print(f"Waiting for the number to become active... ({time_until_start})") +print(f"Waiting for the number to become active... (estimate: {time_until_start})") wake_response = wake_requests.wait_for_wake_request(wake_request) +# Refresh duration based on actual active window +duration = wake_response.usage_window_end - wake_response.usage_window_start +print(f"Number {rental.number} is now active until {wake_response.usage_window_end} (duration: {duration})") # 3. Poll for SMS messages on the awakened number print(f"Polling SMS messages for number {rental.number}...") diff --git a/pyproject.toml b/pyproject.toml index 3368355..ed286c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ extend-exclude = ''' | \.venv | build | dist + | examples | textverified/data )/ ''' diff --git a/tests/fixtures.py b/tests/fixtures.py index c7b600e..c17a012 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -272,7 +272,7 @@ def _find_mock_file(request_path: str, file_list: List[Path]) -> Optional[Dict]: file_name = file_name.replace(".", "\\.") # Escape dots for regex path_param_keys = re.findall(r"\{([^}/?&]+)\}", str(file_name)) # 1 group per key of path parameters file_path_pattern = ( - "^" + re.sub(r"\{([^}/?&]+)\}", r"[^\/}?&.]+", str(file_name)) + "$" + "^" + re.sub(r"\{([^}/?&]+)\}", r"([^\/}?&.]+)", str(file_name)) + "$" ) # file path with 1 group for each path parameter if re.fullmatch(file_path_pattern, request_filename): @@ -301,6 +301,19 @@ def _load_mock_data(mock_file_path, method): return mock_data[method_lower] +def _replace_placeholders(obj, replacements): + if isinstance(obj, dict): + for key in obj: + obj[key] = _replace_placeholders(obj[key], replacements) + elif isinstance(obj, list): + for i in range(len(obj)): + obj[i] = _replace_placeholders(obj[i], replacements) + elif isinstance(obj, str): + for placeholder, value in replacements.items(): + obj = obj.replace(placeholder, value) + return obj + + @pytest.fixture def mock_http_from_disk(): """ @@ -336,6 +349,11 @@ def mock_request(method, url, **kwargs): # Load mock data response_data = _load_mock_data(mock_file_data.get("path"), method) + # Replace parameters in response + if "response" in response_data: + replacements = {f"{{{k}}}": v for k, v in mock_file_data.get("path_params", {}).items()} + response_data["response"] = _replace_placeholders(response_data["response"], replacements) + # Apply hooks if any for hook in mock.hooks: response_data["response"] = hook(response_data["response"], method, url, **kwargs) diff --git a/tests/mock_endpoints/api.pub.v2.reservations.rental.nonrenewable.{id}.json b/tests/mock_endpoints/api.pub.v2.reservations.rental.nonrenewable.{id}.json new file mode 100644 index 0000000..a8952bb --- /dev/null +++ b/tests/mock_endpoints/api.pub.v2.reservations.rental.nonrenewable.{id}.json @@ -0,0 +1,35 @@ +{ + "get": { + "path_params": { + "id": "string" + }, + "response": { + "sms": { + "method": "GET", + "href": "/api/pub/v2/sms" + }, + "createdAt": "1970-01-01T00:00:00+00:00", + "endsAt": "1970-01-01T00:00:00+00:00", + "id": "string", + "refund": { + "canRefund": false, + "link": { + "method": "POST", + "href": "/api/pub/v2/reservations/rental/nonrenewable/{id}/refund" + }, + "refundableUntil": "1970-01-01T00:00:00+00:00" + }, + "saleId": "string", + "serviceName": "string", + "state": "verificationPending", + "number": "string", + "alwaysOn": false + } + }, + "post": { + "path_params": { + "id": "string" + }, + "response": {} + } +} \ No newline at end of file diff --git a/tests/mock_endpoints/api.pub.v2.reservations.rental.renewable.{id}.json b/tests/mock_endpoints/api.pub.v2.reservations.rental.renewable.{id}.json new file mode 100644 index 0000000..a81eff4 --- /dev/null +++ b/tests/mock_endpoints/api.pub.v2.reservations.rental.renewable.{id}.json @@ -0,0 +1,36 @@ +{ + "get": { + "path_params": { + "id": "string" + }, + "response": { + "billingCycle": { + "method": "GET", + "href": "/api/pub/v2/billing-cycles/{id}" + }, + "createdAt": "1970-01-01T00:00:00+00:00", + "id": "string", + "refund": { + "canRefund": false, + "link": { + "method": "POST", + "href": "/api/pub/v2/reservations/rental/renewable/{id}/refund" + }, + "refundableUntil": "1970-01-01T00:00:00+00:00" + }, + "saleId": "string", + "serviceName": "string", + "state": "verificationPending", + "billingCycleId": "string", + "isIncludedForNextRenewal": false, + "number": "2223334444", + "alwaysOn": false + } + }, + "post": { + "path_params": { + "id": "string" + }, + "response": {} + } +} \ No newline at end of file diff --git a/tests/mock_endpoints/api.pub.v2.reservations.{id}.json b/tests/mock_endpoints/api.pub.v2.reservations.{id}.json new file mode 100644 index 0000000..3a7ffeb --- /dev/null +++ b/tests/mock_endpoints/api.pub.v2.reservations.{id}.json @@ -0,0 +1,14 @@ +{ + "get": { + "path_params": { + "id": "string" + }, + "query_params": { + "type": "renewable" + }, + "response": { + "method": "GET", + "href": "/api/pub/v2/reservations/rental/nonrenewable/{id}" + } + } +} \ No newline at end of file diff --git a/tests/test_mocking.py b/tests/test_mocking.py new file mode 100644 index 0000000..47f73bc --- /dev/null +++ b/tests/test_mocking.py @@ -0,0 +1,166 @@ +import pytest +from .fixtures import tv, mock_http_from_disk +from textverified.mocking import Mocking, MockBehavior, MockReceivePolicy, MockObject +from textverified.data import ( + VerificationExpanded, + ReservationSaleExpanded, + BillingCycleExpanded, + WakeResponse, + RenewableRentalExpanded, + NonrenewableRentalExpanded, + BackOrderReservationExpanded, +) + + +def test_target_returns_string_with_separator(tv): + """Test that target() returns a string containing '_$_'""" + result = tv.mocking.target() + assert isinstance(result, str) + assert "_$_" in result + + +def test_service_returns_string_with_separator(tv): + """Test that service() returns a string containing '_$_'""" + result = tv.mocking.service() + assert isinstance(result, str) + assert "_$_" in result + + +def test_extension_returns_mock_object_with_separator(tv): + """Test that extension() returns a MockObject containing '_$_'""" + mock_obj = tv.mocking.extension() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + +def test_verification_returns_mock_object(tv, mock_http_from_disk): + """Test that verification() returns a MockObject that can get a VerificationExpanded""" + mock_obj = tv.mocking.verification() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the verification details + verification = mock_obj.get() + assert isinstance(verification, VerificationExpanded) + + +def test_sale_returns_mock_object(tv, mock_http_from_disk): + """Test that sale() returns a MockObject that can get a ReservationSaleExpanded""" + mock_obj = tv.mocking.sale() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the sale details + sale = mock_obj.get() + assert isinstance(sale, ReservationSaleExpanded) + + +def test_billing_cycle_returns_mock_object(tv, mock_http_from_disk): + """Test that billing_cycle() returns a MockObject that can get a BillingCycleExpanded""" + mock_obj = tv.mocking.billing_cycle() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the billing cycle details + billing_cycle = mock_obj.get() + assert isinstance(billing_cycle, BillingCycleExpanded) + + +def test_wake_request_returns_mock_object(tv, mock_http_from_disk): + """Test that wake_request() returns a MockObject that can get a WakeResponse""" + mock_obj = tv.mocking.wake_request() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the wake request details + wake_response = mock_obj.get() + assert isinstance(wake_response, WakeResponse) + + +def test_reservation_returns_mock_object(tv, mock_http_from_disk): + """Test that reservation() returns a MockObject that can get rental details""" + mock_obj = tv.mocking.reservation() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the reservation details + reservation = mock_obj.get() + assert isinstance(reservation, (RenewableRentalExpanded, NonrenewableRentalExpanded)) + + +def test_rental_returns_mock_object(tv, mock_http_from_disk): + """Test that rental() returns a MockObject that can get rental details""" + mock_obj = tv.mocking.rental() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the rental details + rental = mock_obj.get() + assert isinstance(rental, (RenewableRentalExpanded, NonrenewableRentalExpanded)) + + +def test_backorder_sale_returns_mock_object(tv, mock_http_from_disk): + """Test that backorder_sale() returns a MockObject that can get backorder details""" + mock_obj = tv.mocking.backorder_sale() + assert isinstance(mock_obj, MockObject) + assert "_$_" in mock_obj.id + + # Get the backorder details + backorder = mock_obj.get() + assert isinstance(backorder, BackOrderReservationExpanded) + + +def test_mock_object_equality(): + """Test that MockObject equality works based on id""" + obj1 = MockObject(id="test_id", _get_function=lambda: "data") + obj2 = MockObject(id="test_id", _get_function=lambda: "other_data") + obj3 = MockObject(id="different_id", _get_function=lambda: "data") + + assert obj1 == obj2 + assert obj1 != obj3 + assert obj2 != obj3 + + +def test_mock_object_hash(): + """Test that MockObject hash works based on id""" + obj1 = MockObject(id="test_id", _get_function=lambda: "data") + obj2 = MockObject(id="test_id", _get_function=lambda: "other_data") + obj3 = MockObject(id="different_id", _get_function=lambda: "data") + + assert hash(obj1) == hash(obj2) + assert hash(obj1) != hash(obj3) + assert hash(obj2) != hash(obj3) + + +def test_mock_object_name_property(): + """Test that MockObject name property returns the id""" + obj = MockObject(id="test_id", _get_function=lambda: "data") + assert obj.name == "test_id" + + +def test_all_mock_ids_contain_separator(tv): + """Test that all mock ID generation methods produce IDs with '_$_' separator""" + methods_returning_strings = [tv.mocking.target, tv.mocking.service] + + methods_returning_mock_objects = [ + tv.mocking.verification, + tv.mocking.sale, + tv.mocking.billing_cycle, + tv.mocking.wake_request, + tv.mocking.reservation, + tv.mocking.rental, + tv.mocking.backorder_sale, + tv.mocking.extension, + ] + + # Test string-returning methods + for method in methods_returning_strings: + result = method() + assert isinstance(result, str), f"{method.__name__} should return a string" + assert "_$_" in result, f"{method.__name__} should contain '_$_' in the result" + + # Test MockObject-returning methods + for method in methods_returning_mock_objects: + mock_obj = method() + assert isinstance(mock_obj, MockObject), f"{method.__name__} should return a MockObject" + assert "_$_" in mock_obj.id, f"{method.__name__} should have '_$_' in the id" diff --git a/textverified/__init__.py b/textverified/__init__.py index 936bd53..ee0efce 100644 --- a/textverified/__init__.py +++ b/textverified/__init__.py @@ -277,6 +277,26 @@ def __call__(self, *args, **kwargs): """, ) +mocking = _LazyAPI( + "mocking", + """ +Static access to mocking functionality for testing. + +Provides methods for creating mock identifiers and objects for testing purposes. +This is a static wrapper around the Mocking class that uses the globally +configured TextVerified instance. + +Example: + from textverified import mocking + + # Create a mock verification + mock_verification = mocking.verification() + + # Get verification details + details = mock_verification.get() +""", +) + # Available for import: __all__ = [ # Main classes @@ -296,6 +316,7 @@ def __call__(self, *args, **kwargs): "wake_requests", "sms", "calls", + "mocking", # API classes (for direct instantiation if needed) "AccountAPI", "BillingCycleAPI", diff --git a/textverified/mocking.py b/textverified/mocking.py new file mode 100644 index 0000000..774fcc7 --- /dev/null +++ b/textverified/mocking.py @@ -0,0 +1,328 @@ +from typing import Union, Generic, TypeVar, Callable +from enum import Enum +from dataclasses import dataclass +from .data import ( + LineReservationType, + ReservationType, + NonrenewableRentalExpanded, + RenewableRentalExpanded, + VerificationExpanded, + ReservationSaleExpanded, + BillingCycleExpanded, + WakeResponse, + BackOrderReservationExpanded, + RentalExtensionRequest, + RentalDuration, +) +from .verifications_api import VerificationsAPI +from .reservations_api import ReservationsAPI +from .billing_cycle_api import BillingCycleAPI +from .sales_api import SalesAPI +from .wake_api import WakeAPI + + +class MockIdentifierType(Enum): + """Mock identifier types""" + + MockWakeRequest = 1 + MockSale = 2 + MockBackorderReservation = 3 + # MockReservationRequest = 4, -- not needed for api calls + MockBillingCycle = 5 + MockReservation = 6 + MockVerification = 7 + MockRental = 8 + MockTarget = 9 + MockExtension = 10 + + +class MockReceivePolicy(Enum): + """MockReceivePolicy: determines what should be returned when polling for messages or calls""" + + NoIncoming = 0 + IncomingText = 1 + IncomingCall = 2 + IncomingTextAndCall = 3 + + +class MockBehavior(Enum): + """MockBehavior: determines how the mock should behave. If set to FailRandomly, will fail 1/3 of the time.""" + + Succeeds = 0 + AlwaysFails = 1 + FailsRandomly = 2 + + +def _construct_mock(*args): + assert len(args) > 0 + return "_$_".join(str(arg) for arg in args) + + +T = TypeVar("T") + + +@dataclass(frozen=True) +class MockObject(Generic[T]): + """Base class for mock objects.""" + + id: str + _get_function: Callable[[], T] + + @property + def name(self) -> str: + return self.id + + def get(self) -> T: + return self._get_function() + + def __eq__(self, value): + if not isinstance(value, MockObject): + return NotImplemented + return self.id == value.id + + def __hash__(self): + return hash(self.id) + + +class Mocking: + """Constructs mock identifiers for use with the TextVerified API.""" + + def __init__( + self, + verifications_api: VerificationsAPI, + reservations_api: ReservationsAPI, + billing_cycle_api: BillingCycleAPI, + sales_api: SalesAPI, + wake_api: WakeAPI, + ): + self.verifications_api = verifications_api + self.reservations_api = reservations_api + self.billing_cycle_api = billing_cycle_api + self.sales_api = sales_api + self.wake_api = wake_api + + def target( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + ) -> str: + """Constructs a mock target identifier. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + + Returns: + str: The constructed mock target identifier. + """ + return _construct_mock(MockIdentifierType.MockTarget.name, behavior.name, receive_policy.name) + + def service( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + ) -> str: + """Constructs a mock service name identifier. Identical to Mocking.target. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + + Returns: + str: The constructed mock service name identifier. + """ + return self.target(behavior, receive_policy) + + def reservation( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + reservation_type: ReservationType = ReservationType.NONRENEWABLE, + ) -> MockObject[Union[RenewableRentalExpanded, NonrenewableRentalExpanded]]: + line_type = ( + LineReservationType.VERIFICATION + if reservation_type == ReservationType.VERIFICATION + else LineReservationType.RENTAL + ) + mock_id = _construct_mock( + MockIdentifierType.MockReservation.name, + behavior.name, + line_type.name, + reservation_type.name, + receive_policy.name, + ) + return MockObject[Union[RenewableRentalExpanded, NonrenewableRentalExpanded]]( + id=mock_id, _get_function=lambda: self.reservations_api.details(mock_id) + ) + + def rental( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + reservation_type: ReservationType = ReservationType.NONRENEWABLE, + ) -> MockObject[Union[RenewableRentalExpanded, NonrenewableRentalExpanded]]: + """Constructs a mock rental identifier that returns rental details when get() is called. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + reservation_type (ReservationType, optional): The type of rental. Defaults to ReservationType.NONRENEWABLE. + + Returns: + MockObject[Union[RenewableRentalExpanded, NonrenewableRentalExpanded]]: A mock object that can retrieve rental details. + """ + line_type = ( + LineReservationType.VERIFICATION + if reservation_type == ReservationType.VERIFICATION + else LineReservationType.RENTAL + ) + mock_id = _construct_mock( + MockIdentifierType.MockRental.name, + behavior.name, + line_type.name, + reservation_type.name, + receive_policy.name, + ) + return MockObject[Union[RenewableRentalExpanded, NonrenewableRentalExpanded]]( + id=mock_id, _get_function=lambda: self.reservations_api.details(mock_id) + ) + + def verification( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + ) -> MockObject[VerificationExpanded]: + """Constructs a mock verification identifier that returns verification details when get() is called. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + + Returns: + MockObject[VerificationExpanded]: A mock object that can retrieve verification details. + """ + mock_id = _construct_mock( + MockIdentifierType.MockVerification.name, + behavior.name, + LineReservationType.VERIFICATION.name, + ReservationType.VERIFICATION.name, + receive_policy.name, + ) + return MockObject[VerificationExpanded]( + id=mock_id, _get_function=lambda: self.verifications_api.details(mock_id) + ) + + def sale( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + reservation_type: ReservationType = ReservationType.NONRENEWABLE, + ) -> MockObject[ReservationSaleExpanded]: + """Constructs a mock sale identifier that returns sale details when get() is called. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + reservation_type (ReservationType, optional): The type of reservation for the sale. Defaults to ReservationType.NONRENEWABLE. + + Returns: + MockObject[ReservationSaleExpanded]: A mock object that can retrieve sale details. + """ + line_type = ( + LineReservationType.VERIFICATION + if reservation_type == ReservationType.VERIFICATION + else LineReservationType.RENTAL + ) + mock_id = _construct_mock( + MockIdentifierType.MockSale.name, behavior.name, line_type.name, reservation_type.name, receive_policy.name + ) + return MockObject[ReservationSaleExpanded](id=mock_id, _get_function=lambda: self.sales_api.get(mock_id)) + + def backorder_sale( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + reservation_type: ReservationType = ReservationType.NONRENEWABLE, + ) -> MockObject[BackOrderReservationExpanded]: + """Constructs a mock backorder sale identifier that returns backorder details when get() is called. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + reservation_type (ReservationType, optional): The type of reservation for the backorder. Defaults to ReservationType.NONRENEWABLE. + + Returns: + MockObject[BackOrderReservationExpanded]: A mock object that can retrieve backorder details. + """ + line_type = ( + LineReservationType.VERIFICATION + if reservation_type == ReservationType.VERIFICATION + else LineReservationType.RENTAL + ) + mock_id = _construct_mock( + MockIdentifierType.MockBackorderReservation.name, + behavior.name, + line_type.name, + reservation_type.name, + receive_policy.name, + ) + return MockObject[BackOrderReservationExpanded]( + id=mock_id, _get_function=lambda: self.reservations_api.backorder(mock_id) + ) + + def billing_cycle(self, behavior: MockBehavior = MockBehavior.Succeeds) -> MockObject[BillingCycleExpanded]: + """Constructs a mock billing cycle identifier that returns billing cycle details when get() is called. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + + Returns: + MockObject[BillingCycleExpanded]: A mock object that can retrieve billing cycle details. + """ + mock_id = _construct_mock(MockIdentifierType.MockBillingCycle.name, behavior.name) + return MockObject[BillingCycleExpanded](id=mock_id, _get_function=lambda: self.billing_cycle_api.get(mock_id)) + + def extension(self, behavior: MockBehavior = MockBehavior.Succeeds) -> MockObject[RentalExtensionRequest]: + """Constructs a mock extension identifier. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + + Returns: + MockObject[RentalExtensionRequest]: A mock object that can retrieve a rental extension request. + """ + mock_id = _construct_mock(MockIdentifierType.MockExtension.name, behavior.name) + return MockObject[RentalExtensionRequest]( + id=mock_id, + _get_function=lambda: RentalExtensionRequest(mock_id, extension_duration=RentalDuration.THIRTY_DAY), + ) + + def wake_request( + self, + behavior: MockBehavior = MockBehavior.Succeeds, + receive_policy: MockReceivePolicy = MockReceivePolicy.IncomingTextAndCall, + reservation_type: ReservationType = ReservationType.NONRENEWABLE, + ) -> MockObject[WakeResponse]: + """Constructs a mock wake request identifier that returns wake request details when get() is called. + + Args: + behavior (MockBehavior, optional): Determines how the mock should behave. Defaults to MockBehavior.Succeeds. + receive_policy (MockReceivePolicy, optional): Determines what should be returned when polling for messages or calls. Defaults to MockReceivePolicy.IncomingTextAndCall. + reservation_type (ReservationType, optional): The type of reservation for the wake request. Defaults to ReservationType.NONRENEWABLE. + + Returns: + MockObject[WakeResponse]: A mock object that can retrieve wake request details. + """ + line_type = ( + LineReservationType.VERIFICATION + if reservation_type == ReservationType.VERIFICATION + else LineReservationType.RENTAL + ) + mock_id = _construct_mock( + MockIdentifierType.MockWakeRequest.name, + behavior.name, + line_type.name, + reservation_type.name, + receive_policy.name, + ) + return MockObject[WakeResponse](id=mock_id, _get_function=lambda: self.wake_api.get(mock_id)) diff --git a/textverified/reservations_api.py b/textverified/reservations_api.py index 54d0c2c..6d032e9 100644 --- a/textverified/reservations_api.py +++ b/textverified/reservations_api.py @@ -305,6 +305,8 @@ def details( elif "reservations/rental/renewable/" in action.href: return RenewableRentalExpanded.from_api(response.data) + raise ValueError("Unexpected reservation type in response.") + def list_renewable(self) -> PaginatedList[RenewableRentalCompact]: """Get a paginated list of all renewable reservations associated with this account. diff --git a/textverified/textverified.py b/textverified/textverified.py index 1947f63..668108e 100644 --- a/textverified/textverified.py +++ b/textverified/textverified.py @@ -11,6 +11,7 @@ from .verifications_api import VerificationsAPI from .wake_api import WakeAPI from .call_api import CallAPI +from .mocking import Mocking import requests import datetime from requests.adapters import HTTPAdapter @@ -80,6 +81,16 @@ def sms(self) -> SMSApi: def calls(self) -> CallAPI: return CallAPI(self) + @property + def mocking(self) -> Mocking: + return Mocking( + verifications_api=self.verifications, + reservations_api=self.reservations, + billing_cycle_api=self.billing_cycles, + sales_api=self.sales, + wake_api=self.wake_requests, + ) + def __post_init__(self): self.bearer = None self.base_url = self.base_url.rstrip("/") @@ -128,7 +139,7 @@ def _perform_action(self, action: _Action, **kwargs) -> _ActionResponse: return self.__perform_action_internal(action.method, href, **kwargs) def __perform_action_internal(self, method: str, href: str, **kwargs) -> _ActionResponse: - """Internal action performance with authorization""" + """Internal action (to localhost or base_url) with authorization""" # Check if bearer token is set and valid self.refresh_bearer() @@ -144,7 +155,7 @@ def __perform_action_internal(self, method: str, href: str, **kwargs) -> _Action return _ActionResponse(data=response.json() if response.text else {}, headers=response.headers) def __perform_action_external(self, method: str, href: str, **kwargs) -> _ActionResponse: - """External action performance without authorization""" + """External action (to unknown domain) without authorization""" # Allow unverified certificates for localhost verify = not href.startswith("http://localhost") and not href.startswith("https://localhost")