diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e9d5fcf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Normalize line endings to LF on checkout + commit regardless of host platform. +* text=auto eol=lf +*.py text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..13220ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: [master, develop] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install -r requirements-dev.txt + - run: pytest --cov=buckaroo --cov-report=term-missing diff --git a/Dockerfile b/Dockerfile index 071f651..b7c94c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app # Copy requirements and install Python packages -COPY requirements.txt . +COPY requirements.txt requirements-dev.txt ./ -RUN pip install --root-user-action=ignore -r requirements.txt +RUN pip install --root-user-action=ignore -r requirements-dev.txt CMD ["tail", "-f", "/dev/null"] diff --git a/buckaroo/_buckaroo_client.py b/buckaroo/_buckaroo_client.py index 8930fef..4d41051 100644 --- a/buckaroo/_buckaroo_client.py +++ b/buckaroo/_buckaroo_client.py @@ -1,5 +1,4 @@ - -from typing import Optional, Union +from typing import Optional from .exceptions._authentication_error import AuthenticationError from .config.buckaroo_config import BuckarooConfig, create_config_from_mode from .http.client import BuckarooHttpClient @@ -8,10 +7,10 @@ class BuckarooClient(object): """ Buckaroo Payment Gateway Client. - + This is the main client class for interacting with the Buckaroo payment gateway. It provides access to payment services and manages authentication and configuration. - + Args: store_key (str): Your Buckaroo store key. secret_key (str): Your Buckaroo secret key. @@ -21,34 +20,34 @@ class BuckarooClient(object): a default configuration will be created based on the mode parameter. http_strategy (str, optional): HTTP strategy to use ('requests' or 'curl'). If not provided, will auto-select the best available strategy. - + Example: Basic usage with mode: >>> client = BuckarooClient("store_key", "secret_key", mode="test") - + Advanced usage with configuration: >>> from buckaroo.config.buckaroo_config import BuckarooConfig, Environment >>> config = BuckarooConfig(environment=Environment.LIVE, timeout=60) >>> client = BuckarooClient("store_key", "secret_key", config=config) - + Usage with specific HTTP strategy: >>> client = BuckarooClient("store_key", "secret_key", http_strategy="curl") """ def __init__( - self, - store_key: str, - secret_key: str, + self, + store_key: str, + secret_key: str, mode: str = "test", config: Optional[BuckarooConfig] = None, - http_strategy: Optional[str] = None + http_strategy: Optional[str] = None, ) -> None: """ Initialize the Buckaroo Client class. - + Args: store_key (str): Your Buckaroo store key - secret_key (str): Your Buckaroo secret key + secret_key (str): Your Buckaroo secret key mode (str): Environment mode ('test' or 'live'). Deprecated, use config instead config (BuckarooConfig, optional): Configuration object http_strategy (str, optional): HTTP strategy to use ('requests' or 'curl') @@ -57,78 +56,75 @@ def __init__( if store_key is None or not store_key.strip(): raise AuthenticationError("Store key must be provided") - + if secret_key is None or not secret_key.strip(): raise AuthenticationError("Secret key must be provided") - + self.store_key = store_key.strip() self.secret_key = secret_key.strip() self.http_strategy = http_strategy - + # Handle configuration if config is not None: self.config = config else: # Create config from mode for backward compatibility self.config = create_config_from_mode(mode) - + # Initialize HTTP client with strategy self.http_client = BuckarooHttpClient( - self.store_key, - self.secret_key, - self.config, - self.http_strategy + self.store_key, self.secret_key, self.config, self.http_strategy ) - + @property def is_test_environment(self) -> bool: """ Check if client is configured for test environment. - + Returns: bool: True if in test environment, False if live. """ return self.config.is_test_environment - + @property def is_live_environment(self) -> bool: """ Check if client is configured for live environment. - + Returns: bool: True if in live environment, False if test. """ return self.config.is_live_environment - + @property def api_endpoint(self) -> str: """ Get the API endpoint URL. - + Returns: str: The API endpoint URL. """ return self.config.api_endpoint - + def confirm_credential(self) -> bool: """ Verify that the configured store key and secret key are valid. - + Calls the Transaction Specification endpoint which requires HMAC authentication. - + Returns: bool: True if credentials are valid, False otherwise. """ try: - response = self.http_client.get('/json/Transaction/Specification/ideal') + response = self.http_client.get("/json/Transaction/Specification/ideal") return response.success except Exception: return False - + def get_config_info(self) -> dict: """ Get configuration information. - + Returns: dict: Configuration information (safe for logging). """ diff --git a/buckaroo/app.py b/buckaroo/app.py index 37aaec9..3ab57c2 100644 --- a/buckaroo/app.py +++ b/buckaroo/app.py @@ -7,55 +7,61 @@ """ import os -from typing import Optional, Dict, Any, Union +from typing import Optional, Dict, Any from dataclasses import dataclass from .services.payment_service import PaymentService from .services.solution_service import SolutionService from buckaroo._buckaroo_client import BuckarooClient from buckaroo.observers import ( - BuckarooLoggingObserver, - create_logger, - create_logger_from_env, - LogLevel, + BuckarooLoggingObserver, + LogLevel, LogDestination, - LogConfig + LogConfig, ) -from buckaroo.config.buckaroo_config import BuckarooConfig from buckaroo.exceptions._authentication_error import AuthenticationError @dataclass class BuckarooConfig: """Configuration for Buckaroo Application.""" + # API Configuration store_key: Optional[str] = None secret_key: Optional[str] = None mode: str = "test" # test or live - - # Logging Configuration + + # Logging Configuration enable_logging: bool = True log_level: LogLevel = LogLevel.INFO log_destination: LogDestination = LogDestination.STDOUT log_file: str = "buckaroo_app.log" mask_sensitive_data: bool = True - + # SDK Configuration timeout: int = 30 retry_attempts: int = 3 - + @classmethod - def from_env(cls) -> 'BuckarooConfig': + def from_env(cls) -> "BuckarooConfig": """Create configuration from environment variables.""" # Get log level from env log_level_str = os.getenv("BUCKAROO_LOG_LEVEL", "INFO").upper() - log_level = LogLevel(log_level_str) if log_level_str in [l.value for l in LogLevel] else LogLevel.INFO - + log_level = ( + LogLevel(log_level_str) + if log_level_str in [lv.value for lv in LogLevel] + else LogLevel.INFO + ) + # Get log destination from env log_dest_str = os.getenv("BUCKAROO_LOG_DESTINATION", "stdout").lower() - log_destination = LogDestination(log_dest_str) if log_dest_str in [d.value for d in LogDestination] else LogDestination.STDOUT - + log_destination = ( + LogDestination(log_dest_str) + if log_dest_str in [d.value for d in LogDestination] + else LogDestination.STDOUT + ) + return cls( store_key=os.getenv("BUCKAROO_STORE_KEY"), secret_key=os.getenv("BUCKAROO_SECRET_KEY"), @@ -65,55 +71,56 @@ def from_env(cls) -> 'BuckarooConfig': log_file=os.getenv("BUCKAROO_LOG_FILE", "buckaroo_app.log"), mask_sensitive_data=os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true", timeout=int(os.getenv("BUCKAROO_TIMEOUT", "30")), - retry_attempts=int(os.getenv("BUCKAROO_RETRY_ATTEMPTS", "3")) + retry_attempts=int(os.getenv("BUCKAROO_RETRY_ATTEMPTS", "3")), ) class Buckaroo: """ High-level Buckaroo SDK Application wrapper. - + This class provides a convenient interface for working with the Buckaroo SDK, including automatic logging setup, configuration management, and common operations. - + Example: >>> app = Buckaroo.from_env() >>> payment = app.create_ideal_payment(amount=25.50, currency="EUR") >>> response = app.execute_payment(payment) """ - + def __init__(self, config: Optional[BuckarooConfig] = None): """ Initialize Buckaroo Application. - + Args: config: Application configuration. If None, uses environment variables. """ self.config = config or BuckarooConfig.from_env() self.logger: Optional[BuckarooLoggingObserver] = None self.client: Optional[BuckarooClient] = None - + # Initialize components self._setup_logging() self._setup_client() - + @classmethod - def from_env(cls) -> 'Buckaroo': + def from_env(cls) -> "Buckaroo": """Create Buckaroo app from environment variables.""" return cls(BuckarooConfig.from_env()) - + @classmethod - def quick_setup(cls, store_key: str, secret_key: str, mode: str = "test", - log_to_stdout: bool = True) -> 'Buckaroo': + def quick_setup( + cls, store_key: str, secret_key: str, mode: str = "test", log_to_stdout: bool = True + ) -> "Buckaroo": """ Quick setup for Buckaroo app with minimal configuration. - + Args: store_key: Buckaroo store key secret_key: Buckaroo secret key mode: API mode ("test" or "live") log_to_stdout: Whether to log to stdout (True) or file (False) - + Returns: Configured Buckaroo app """ @@ -121,55 +128,59 @@ def quick_setup(cls, store_key: str, secret_key: str, mode: str = "test", store_key=store_key, secret_key=secret_key, mode=mode, - log_destination=LogDestination.STDOUT if log_to_stdout else LogDestination.FILE + log_destination=LogDestination.STDOUT if log_to_stdout else LogDestination.FILE, ) return cls(config) - + def _setup_logging(self): """Setup logging based on configuration.""" if not self.config.enable_logging: return - + log_config = LogConfig( level=self.config.log_level, destination=self.config.log_destination, log_file=self.config.log_file, - mask_sensitive_data=self.config.mask_sensitive_data + mask_sensitive_data=self.config.mask_sensitive_data, ) - + self.logger = BuckarooLoggingObserver(log_config) - self.logger.log_info("Buckaroo application initialized", - mode=self.config.mode, - log_level=self.config.log_level.value, - log_destination=self.config.log_destination.value) - + self.logger.log_info( + "Buckaroo application initialized", + mode=self.config.mode, + log_level=self.config.log_level.value, + log_destination=self.config.log_destination.value, + ) + def _setup_client(self): """Setup Buckaroo client.""" if not self.config.store_key or not self.config.secret_key: error_msg = "Store key and secret key are required" if self.logger: - self.logger.log_error(error_msg, - store_key_provided=bool(self.config.store_key), - secret_key_provided=bool(self.config.secret_key)) + self.logger.log_error( + error_msg, + store_key_provided=bool(self.config.store_key), + secret_key_provided=bool(self.config.secret_key), + ) raise AuthenticationError(error_msg) - + try: self.client = BuckarooClient( - self.config.store_key, - self.config.secret_key, - mode=self.config.mode + self.config.store_key, self.config.secret_key, mode=self.config.mode ) - + # Expose payments service directly on app for cleaner API self.payments = PaymentService(self.client) self.solutions = SolutionService(self.client) if self.logger: - self.logger.log_info("Buckaroo client initialized successfully", - store_key_length=len(self.config.store_key), - mode=self.config.mode) - + self.logger.log_info( + "Buckaroo client initialized successfully", + store_key_length=len(self.config.store_key), + mode=self.config.mode, + ) + except Exception as e: if self.logger: self.logger.log_exception(e, context={"operation": "client_setup"}) @@ -179,53 +190,53 @@ def log_info(self, message: str, **kwargs): """Log info message if logging is enabled.""" if self.logger: self.logger.log_info(message, **kwargs) - + def log_debug(self, message: str, **kwargs): """Log debug message if logging is enabled.""" if self.logger: self.logger.log_debug(message, **kwargs) - + def log_warning(self, message: str, **kwargs): """Log warning message if logging is enabled.""" if self.logger: self.logger.log_warning(message, **kwargs) - + def log_error(self, message: str, **kwargs): """Log error message if logging is enabled.""" if self.logger: self.logger.log_error(message, **kwargs) - + def log_exception(self, exception: Exception, **kwargs): """Log exception if logging is enabled.""" if self.logger: self.logger.log_exception(exception, **kwargs) - + def get_client(self) -> BuckarooClient: """Get the underlying Buckaroo client.""" if not self.client: raise RuntimeError("Client not initialized") return self.client - + def get_logger(self) -> Optional[BuckarooLoggingObserver]: """Get the logger instance.""" return self.logger - + def create_child_logger(self, context: Dict[str, Any]): """Create a child logger with additional context.""" if not self.logger: return None return self.logger.create_child_observer(context) - + def __enter__(self): """Context manager entry.""" if self.logger: self.logger.log_debug("Entering Buckaroo app context") return self - + def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" if self.logger: if exc_type: self.logger.log_exception(exc_val, context={"context_manager": "exit"}) else: - self.logger.log_debug("Exiting Buckaroo app context successfully") \ No newline at end of file + self.logger.log_debug("Exiting Buckaroo app context successfully") diff --git a/buckaroo/builders/__init__.py b/buckaroo/builders/__init__.py index 9b4dfbc..490feb1 100644 --- a/buckaroo/builders/__init__.py +++ b/buckaroo/builders/__init__.py @@ -1 +1 @@ -# Builders module \ No newline at end of file +# Builders module diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py index f713ac5..dd5140d 100644 --- a/buckaroo/builders/base_builder.py +++ b/buckaroo/builders/base_builder.py @@ -7,7 +7,7 @@ class BaseBuilder(ABC): """Abstract base class for all builders (payments and solutions).""" - + def __init__(self, client): """Initialize with client instance.""" self._client = client @@ -26,70 +26,72 @@ def __init__(self, client): self._service_parameters: List[Parameter] = [] self._payload: Dict[str, Any] = {} # Store original payload self._validator = ServiceParameterValidator(self) - - def currency(self, currency: str) -> 'BaseBuilder': + + def currency(self, currency: str) -> "BaseBuilder": """Set the currency for the payment.""" self._currency = currency return self - - def amount(self, amount: float) -> 'BaseBuilder': + + def amount(self, amount: float) -> "BaseBuilder": """Set the amount for the payment.""" self._amount_debit = amount return self - - def description(self, description: str) -> 'BaseBuilder': + + def description(self, description: str) -> "BaseBuilder": """Set the description for the payment.""" self._description = description return self - - def invoice(self, invoice: str) -> 'BaseBuilder': + + def invoice(self, invoice: str) -> "BaseBuilder": """Set the invoice number for the payment.""" self._invoice = invoice return self - - def return_url(self, url: str) -> 'BaseBuilder': + + def return_url(self, url: str) -> "BaseBuilder": """Set the return URL for successful payment.""" self._return_url = url return self - - def return_url_cancel(self, url: str) -> 'BaseBuilder': + + def return_url_cancel(self, url: str) -> "BaseBuilder": """Set the return URL for cancelled payment.""" self._return_url_cancel = url return self - - def return_url_error(self, url: str) -> 'BaseBuilder': + + def return_url_error(self, url: str) -> "BaseBuilder": """Set the return URL for payment error.""" self._return_url_error = url return self - - def return_url_reject(self, url: str) -> 'BaseBuilder': + + def return_url_reject(self, url: str) -> "BaseBuilder": """Set the return URL for rejected payment.""" self._return_url_reject = url return self - - def continue_on_incomplete(self, continue_incomplete: str) -> 'BaseBuilder': + + def continue_on_incomplete(self, continue_incomplete: str) -> "BaseBuilder": """Set whether to continue on incomplete payment.""" self._continue_on_incomplete = continue_incomplete return self - - def push_url(self, url: str) -> 'BaseBuilder': + + def push_url(self, url: str) -> "BaseBuilder": """Set the Push (webhook) URL.""" self._push_url = url return self - def push_url_failure(self, url: str) -> 'BaseBuilder': + def push_url_failure(self, url: str) -> "BaseBuilder": """Set the Push URL for failure notifications.""" self._push_url_failure = url return self - def client_ip(self, ip_address: str, ip_type: int = 0) -> 'BaseBuilder': + def client_ip(self, ip_address: str, ip_type: int = 0) -> "BaseBuilder": """Set the client IP information.""" self._client_ip = ClientIP(type=ip_type, address=ip_address) return self - - def add_parameter(self, key: str, value: Any, group_type: str = "", group_id: str = "") -> 'BaseBuilder': + + def add_parameter( + self, key: str, value: Any, group_type: str = "", group_id: str = "" + ) -> "BaseBuilder": """Add a custom parameter to the service. - + Args: key: Parameter name value: Parameter value (will be converted to string unless it's a list/dict) @@ -102,52 +104,58 @@ def add_parameter(self, key: str, value: Any, group_type: str = "", group_id: st if isinstance(item, dict): # Each item in the list becomes a group for item_key, item_value in item.items(): - str_value = str(item_value).lower() if isinstance(item_value, bool) else str(item_value) + str_value = ( + str(item_value).lower() + if isinstance(item_value, bool) + else str(item_value) + ) parameter = Parameter( name=item_key.capitalize(), value=str_value, group_type=key.capitalize(), # e.g., "articles" - group_id=str(index + 1) # 1-based index + group_id=str(index + 1), # 1-based index ) self._service_parameters.append(parameter) return self - + # Handle regular parameters # Convert value to string for API compatibility str_value = str(value).lower() if isinstance(value, bool) else str(value) parameter = Parameter( - name=key.capitalize(), - value=str_value, - group_type=group_type.capitalize(), - group_id=group_id + name=key.capitalize(), + value=str_value, + group_type=group_type.capitalize(), + group_id=group_id, ) self._service_parameters.append(parameter) return self - + # Validation convenience methods def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: """Check if a parameter is allowed for the given action.""" return self._validator.is_parameter_allowed(param_name, action) - + def get_parameter_info(self, action: str = "Pay") -> Dict[str, Any]: """Get information about allowed parameters for an action.""" return self._validator.get_parameter_info(action) - + def get_normalized_parameter_name(self, param_name: str, action: str = "Pay") -> str: """Get the official parameter name that matches the input.""" return self._validator.get_normalized_parameter_name(param_name, action) - - def _validate_and_filter_service_parameters(self, action: str = "Pay", strict: bool = False) -> None: + + def _validate_and_filter_service_parameters( + self, action: str = "Pay", strict: bool = False + ) -> None: """ Validate and filter service parameters just before building. - + Args: action (str): The action being performed strict (bool): If True, throws exceptions for missing required parameters. If False, filters invalid parameters and only warns. - + Raises: RequiredParameterMissingError: If required parameters are missing (when strict=True) ParameterValidationError: If parameters are invalid (when strict=True) @@ -155,17 +163,17 @@ def _validate_and_filter_service_parameters(self, action: str = "Pay", strict: b self._service_parameters = self._validator.validate_all_parameters( self._service_parameters, action, strict=strict ) - - def from_dict(self, data: Dict[str, Any]) -> 'BaseBuilder': + + def from_dict(self, data: Dict[str, Any]) -> "BaseBuilder": """ Populate the builder from a dictionary of parameters. - + Args: data (Dict[str, Any]): Dictionary containing payment parameters - + Returns: BaseBuilder: Self for method chaining - + Supported keys: - currency: Payment currency (e.g., 'EUR', 'USD') - amount: Payment amount (float) @@ -180,143 +188,147 @@ def from_dict(self, data: Dict[str, Any]) -> 'BaseBuilder': - service_parameters: Additional service-specific parameters (dict) """ # Map dictionary keys to builder methods - if 'currency' in data: - self.currency(data['currency']) - - if 'amount' in data: - self.amount(data['amount']) - - if 'description' in data: - self.description(data['description']) - - if 'invoice' in data: - self.invoice(data['invoice']) - - if 'return_url' in data: - self.return_url(data['return_url']) - - if 'return_url_cancel' in data: - self.return_url_cancel(data['return_url_cancel']) - - if 'return_url_error' in data: - self.return_url_error(data['return_url_error']) - - if 'return_url_reject' in data: - self.return_url_reject(data['return_url_reject']) - - if 'continue_on_incomplete' in data: - self.continue_on_incomplete(data['continue_on_incomplete']) - - if 'push_url' in data: - self.push_url(data['push_url']) - if 'push_url_failure' in data: - self.push_url_failure(data['push_url_failure']) - - if 'client_ip' in data: - client_ip_data = data['client_ip'] + if "currency" in data: + self.currency(data["currency"]) + + if "amount" in data: + self.amount(data["amount"]) + + if "description" in data: + self.description(data["description"]) + + if "invoice" in data: + self.invoice(data["invoice"]) + + if "return_url" in data: + self.return_url(data["return_url"]) + + if "return_url_cancel" in data: + self.return_url_cancel(data["return_url_cancel"]) + + if "return_url_error" in data: + self.return_url_error(data["return_url_error"]) + + if "return_url_reject" in data: + self.return_url_reject(data["return_url_reject"]) + + if "continue_on_incomplete" in data: + self.continue_on_incomplete(data["continue_on_incomplete"]) + + if "push_url" in data: + self.push_url(data["push_url"]) + if "push_url_failure" in data: + self.push_url_failure(data["push_url_failure"]) + + if "client_ip" in data: + client_ip_data = data["client_ip"] if isinstance(client_ip_data, str): self.client_ip(client_ip_data) elif isinstance(client_ip_data, dict): - address = client_ip_data.get('address', '0.0.0.0') - ip_type = client_ip_data.get('type', 0) + address = client_ip_data.get("address", "0.0.0.0") + ip_type = client_ip_data.get("type", 0) self.client_ip(address, ip_type) - - if 'service_parameters' in data: - service_params = data['service_parameters'] - + + if "service_parameters" in data: + service_params = data["service_parameters"] + for key, value in service_params.items(): - if isinstance(value, dict): + if isinstance(value, dict): for sub_key, sub_value in value.items(): self.add_parameter(sub_key, sub_value, key) else: self.add_parameter(key, value) - + # Store the original payload for later use self._payload = data.copy() - + return self - + @abstractmethod def get_service_name(self) -> str: """Get the service name for this payment method.""" - pass - + raise NotImplementedError + @abstractmethod def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """ Get the allowed service parameters for this payment method and action. - + Args: action (str): The action being performed (Pay, Authorize, Refund, etc.) - + Returns: Dict[str, Any]: Dictionary where keys are parameter names and values are parameter metadata (type, required, etc.) """ - pass - + raise NotImplementedError + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: """ Get the required fields for this payment method and action. Can be overridden by specific payment builders to customize required fields based on action. - + Args: action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) - + Returns: Dict[str, Any]: Dictionary mapping field names to their current values """ return { - 'currency': self._currency, - 'amount_debit': self._amount_debit, - 'description': self._description, - 'invoice': self._invoice, - 'return_url': self._return_url, - 'return_url_cancel': self._return_url_cancel, - 'return_url_error': self._return_url_error, - 'return_url_reject': self._return_url_reject, + "currency": self._currency, + "amount_debit": self._amount_debit, + "description": self._description, + "invoice": self._invoice, + "return_url": self._return_url, + "return_url_cancel": self._return_url_cancel, + "return_url_error": self._return_url_error, + "return_url_reject": self._return_url_reject, } - + def _validate_required_fields(self, action: str = "Pay") -> None: """Validate that all required fields are set. - + Args: action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) """ - missing_fields = [field for field, value in self.required_fields(action).items() if value is None] + missing_fields = [ + field for field, value in self.required_fields(action).items() if value is None + ] if missing_fields: raise ValueError(f"Missing required fields: {', '.join(missing_fields)}") - - def build(self, action: str = "Pay", validate: bool = True, strict_validation: bool = False) -> PaymentRequest: + + def build( + self, action: str = "Pay", validate: bool = True, strict_validation: bool = False + ) -> PaymentRequest: """Build the payment request. - + Args: action (str): The action to perform (Pay, Authorize, Refund, etc.) validate (bool): Whether to validate and filter service parameters strict_validation (bool): If True, throws exceptions for missing required parameters. If False, filters invalid parameters and only warns. - + Raises: ValueError: If required payment fields are missing RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) ParameterValidationError: If service parameters are invalid (when strict_validation=True) """ self._validate_required_fields(action) - + # Validate and filter service parameters if enabled if validate: self._validate_and_filter_service_parameters(action, strict=strict_validation) - + # Create service with parameters service = Service( name=self.get_service_name(), action=action, - parameters=self._service_parameters if self._service_parameters else None + parameters=self._service_parameters if self._service_parameters else None, ) - + # Create service list service_list = ServiceList(services=[service]) - + # Build payment request payment_request = PaymentRequest( currency=self._currency, @@ -331,22 +343,22 @@ def build(self, action: str = "Pay", validate: bool = True, strict_validation: b push_url=self._push_url, push_url_failure=self._push_url_failure, client_ip=self._client_ip, - services=service_list + services=service_list, ) - + return payment_request def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: """ Execute the payment operation. - + Args: validate (bool): Whether to validate service parameters before building strict_validation (bool): If True, throws exceptions for missing required parameters - + Returns: PaymentResponse: Structured payment response object - + Raises: ValueError: If required fields are missing RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) @@ -356,180 +368,223 @@ def pay(self, validate: bool = True, strict_validation: bool = False) -> Payment """ # Build the payment request payment_request = self.build("Pay", validate=validate, strict_validation=strict_validation) - + # Convert to dictionary for API request_data = payment_request.to_dict() return self._post_transaction(request_data) - + def refund(self, validate: bool = True) -> PaymentResponse: """ Execute a refund transaction. - + Args: validate (bool): Whether to validate service parameters before building - + Returns: PaymentResponse: The refund response - + Raises: ValueError: If required fields are missing """ # Get original_transaction_key from parameter or payload - txn_key = self._payload.get('originalTransactionKey') + txn_key = self._payload.get("original_transaction_key") if not txn_key: - raise ValueError("Original transaction key is required for refunds (provide as parameter or in payload)") - + raise ValueError( + "Original transaction key is required for refunds (provide as parameter or in payload)" + ) + # Get amount from parameter or payload - refund_amount = self._payload.get('refund_amount') - + refund_amount = self._payload.get("refund_amount") + # Build refund request with original transaction reference - payment_request = self.build('Refund', validate=validate) - + payment_request = self.build("Refund", validate=validate) + # Convert to dictionary and modify for refund request_data = payment_request.to_dict() - request_data['OriginalTransactionKey'] = txn_key - + request_data["OriginalTransactionKey"] = txn_key + # Set refund amount if specified, otherwise use original amount if refund_amount is not None: - request_data['AmountCredit'] = refund_amount - # Remove debit amount for refunds - if 'AmountDebit' in request_data: - del request_data['AmountDebit'] + request_data["AmountCredit"] = refund_amount + # PaymentRequest.to_dict always writes AmountDebit; strip it for refunds + del request_data["AmountDebit"] else: # Full refund - swap debit to credit - if 'AmountDebit' in request_data: - request_data['AmountCredit'] = request_data['AmountDebit'] - del request_data['AmountDebit'] - + request_data["AmountCredit"] = request_data["AmountDebit"] + del request_data["AmountDebit"] + return self._post_transaction(request_data) - - def capture(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None, validate: bool = True) -> PaymentResponse: + + def capture( + self, + original_transaction_key: Optional[str] = None, + amount: Optional[float] = None, + validate: bool = True, + ) -> PaymentResponse: """ Capture a previously authorized payment. - + Args: original_transaction_key (str, optional): The transaction key of the authorization. If None, will try to get from payload. amount (float, optional): Amount to capture. If None, will try to get from payload or capture the full authorized amount. validate (bool): Whether to validate service parameters before building - + Returns: PaymentResponse: The capture response """ # Get authorization key from parameter or payload - auth_key = original_transaction_key or self._payload.get('authorization_key') or self._payload.get('original_transaction_key') + auth_key = ( + original_transaction_key + or self._payload.get("authorization_key") + or self._payload.get("original_transaction_key") + ) if not auth_key: - raise ValueError("Authorization key is required for captures (provide as parameter or in payload)") - + raise ValueError( + "Authorization key is required for captures (provide as parameter or in payload)" + ) + # Get capture amount from parameter or payload - capture_amount = amount or self._payload.get('capture_amount') - + capture_amount = amount or self._payload.get("capture_amount") + # Build capture request - payment_request = self.build('Capture', validate=validate) + payment_request = self.build("Capture", validate=validate) request_data = payment_request.to_dict() - + # Set capture-specific parameters - request_data['OriginalTransactionKey'] = auth_key - + request_data["OriginalTransactionKey"] = auth_key + # Set capture amount if specified if capture_amount is not None: - request_data['AmountDebit'] = capture_amount - + request_data["AmountDebit"] = capture_amount + return self._post_transaction(request_data) - + def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: """ Cancel a pending or authorized transaction. - + Args: original_transaction_key (str, optional): The transaction key to cancel. If None, will try to get from payload. - + Returns: PaymentResponse: The cancellation response """ # Get transaction key from parameter or payload - txn_key = original_transaction_key or self._payload.get('cancel_key') or self._payload.get('original_transaction_key') + txn_key = ( + original_transaction_key + or self._payload.get("cancel_key") + or self._payload.get("original_transaction_key") + ) if not txn_key: - raise ValueError("Transaction key is required for cancellations (provide as parameter or in payload)") - - # Build cancel request - payment_request = self.build() + raise ValueError( + "Transaction key is required for cancellations (provide as parameter or in payload)" + ) + + # Build cancel request; validate=False because cancel only needs + # OriginalTransactionKey, not the full Pay required-field set. + payment_request = self.build("Cancel", validate=False) request_data = payment_request.to_dict() - + # Set cancellation parameters - request_data['OriginalTransactionKey'] = txn_key + request_data["OriginalTransactionKey"] = txn_key # Remove amounts for cancellation - request_data.pop('AmountDebit', None) - request_data.pop('AmountCredit', None) - + request_data.pop("AmountDebit", None) + request_data.pop("AmountCredit", None) + return self._post_transaction(request_data) - - def partial_refund(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None) -> PaymentResponse: + + def partial_refund( + self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None + ) -> PaymentResponse: """ Execute a partial refund transaction. - + Args: original_transaction_key (str, optional): The transaction key of the original payment. If None, will try to get from payload. amount (float, optional): Amount to refund. If None, will try to get from payload. - + Returns: PaymentResponse: The partial refund response - + Raises: ValueError: If amount is not provided or invalid """ - # Get amount from parameter or payload - refund_amount = amount or self._payload.get('refund_amount') or self._payload.get('partial_refund_amount') + refund_amount = ( + amount + or self._payload.get("refund_amount") + or self._payload.get("partial_refund_amount") + ) if not refund_amount or refund_amount <= 0: - raise ValueError("Partial refund amount must be greater than 0 (provide as parameter or in payload)") - - return self.refund(original_transaction_key, refund_amount) + raise ValueError( + "Partial refund amount must be greater than 0 (provide as parameter or in payload)" + ) + + _MISSING = object() + prev_key = self._payload.get("original_transaction_key", _MISSING) + prev_amount = self._payload.get("refund_amount", _MISSING) + try: + if original_transaction_key: + self._payload["original_transaction_key"] = original_transaction_key + self._payload["refund_amount"] = refund_amount + return self.refund() + finally: + if prev_key is _MISSING: + self._payload.pop("original_transaction_key", None) + else: + self._payload["original_transaction_key"] = prev_key + if prev_amount is _MISSING: + self._payload.pop("refund_amount", None) + else: + self._payload["refund_amount"] = prev_amount def _post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: """Helper method to post data request and handle response.""" # Send to Buckaroo API - response = self._client.http_client.post('/json/DataRequest', request_data) - + response = self._client.http_client.post("/json/DataRequest", request_data) + # Check if response is valid and convert to dict if response is None: # Return a PaymentResponse with empty data for None responses return PaymentResponse({}) - + # Return structured response object return PaymentResponse(response.to_dict()) - + def _post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: """Helper method to post transaction and handle response.""" # Send to Buckaroo API - response = self._client.http_client.post('/json/transaction', request_data) - + response = self._client.http_client.post("/json/transaction", request_data) + # Check if response is valid and convert to dict if response is None: # Return a PaymentResponse with empty data for None responses return PaymentResponse({}) - + # Return structured response object return PaymentResponse(response.to_dict()) - - def execute_action(self, action: str, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: + + def execute_action( + self, action: str, validate: bool = True, strict_validation: bool = False + ) -> PaymentResponse: """ Execute a custom action for the payment method. - + This is a generic method that can be used for any action supported by the payment method (instantRefund, payFastCheckout, etc.). - + Args: action (str): The action to execute validate (bool): Whether to validate service parameters before building strict_validation (bool): If True, throws exceptions for missing required parameters - + Returns: PaymentResponse: The action response - + Raises: RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) ParameterValidationError: If service parameters are invalid (when strict_validation=True) diff --git a/buckaroo/builders/payments/__init__.py b/buckaroo/builders/payments/__init__.py index 3423c54..28f053f 100644 --- a/buckaroo/builders/payments/__init__.py +++ b/buckaroo/builders/payments/__init__.py @@ -15,13 +15,13 @@ from .payconiq_builder import PayconiqBuilder __all__ = [ - 'PaymentBuilder', - 'AuthorizeCaptureCapable', - 'InstantRefundCapable', - 'FastCheckoutCapable', - 'BankTransferCapabilities', - 'IdealBuilder', - 'CreditcardBuilder', - 'SofortBuilder', - 'PayconiqBuilder' -] \ No newline at end of file + "PaymentBuilder", + "AuthorizeCaptureCapable", + "InstantRefundCapable", + "FastCheckoutCapable", + "BankTransferCapabilities", + "IdealBuilder", + "CreditcardBuilder", + "SofortBuilder", + "PayconiqBuilder", +] diff --git a/buckaroo/builders/payments/alipay_builder.py b/buckaroo/builders/payments/alipay_builder.py index 6465274..d352516 100644 --- a/buckaroo/builders/payments/alipay_builder.py +++ b/buckaroo/builders/payments/alipay_builder.py @@ -1,22 +1,25 @@ - from typing import Dict, Any from .payment_builder import PaymentBuilder + class AlipayBuilder(PaymentBuilder): """Builder for Alipay payments.""" - + def get_service_name(self) -> str: """Get the service name for Alipay payments.""" return "Alipay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Alipay payments based on action.""" - + if action.lower() in ["pay"]: return { - "UseMobileView": {"type": (str, bool), "required": True, "description": "Use mobile view for Alipay"} + "UseMobileView": { + "type": (str, bool), + "required": True, + "description": "Use mobile view for Alipay", + } } # Default to Pay action parameters - return { - } \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/apple_pay_builder.py b/buckaroo/builders/payments/apple_pay_builder.py index 674c6ac..32654b4 100644 --- a/buckaroo/builders/payments/apple_pay_builder.py +++ b/buckaroo/builders/payments/apple_pay_builder.py @@ -1,23 +1,30 @@ - from typing import Dict, Any from .payment_builder import PaymentBuilder + class ApplePayBuilder(PaymentBuilder): """Builder for Apple Pay payments.""" def get_service_name(self) -> str: """Get the service name for apple pay payments.""" return "applepay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Apple Pay payments based on action.""" - + if action.lower() in ["pay"]: return { - "PaymentData": {"type": str, "required": True, "description": "Apple Pay payment data"}, - "CustomerCardName": {"type": str, "required": False, "description": "Customer card name"}, + "PaymentData": { + "type": str, + "required": True, + "description": "Apple Pay payment data", + }, + "CustomerCardName": { + "type": str, + "required": False, + "description": "Customer card name", + }, } # Default to Pay action parameters - return { - } \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/bancontact_builder.py b/buckaroo/builders/payments/bancontact_builder.py index a352fe1..a7ab516 100644 --- a/buckaroo/builders/payments/bancontact_builder.py +++ b/buckaroo/builders/payments/bancontact_builder.py @@ -1,32 +1,37 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class BancontactBuilder(PaymentBuilder): """Builder for Bancontact payments.""" def get_service_name(self) -> str: """Get the service name for bancontactmrcash payments.""" return "bancontactmrcash" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Bancontact payments based on action.""" - + if action.lower() in ["pay", "authenticate"]: return { - "savetoken": {"type": str, "required": False, "description": "Save payment token for future use"}, + "savetoken": { + "type": str, + "required": False, + "description": "Save payment token for future use", + }, } - - if action.lower() in ["payEncrypted", "completePayment"]: + + if action.lower() in ["payencrypted", "completepayment"]: return { - "encryptedCardData": {"type": str, "required": True, "description": "Encrypted card data for payment"}, + "encryptedCardData": { + "type": str, + "required": True, + "description": "Encrypted card data for payment", + }, } - if action.lower() in ["refund", "capture", "cancel"]: # These actions typically don't require additional parameters return {} - + return {} diff --git a/buckaroo/builders/payments/belfius_builder.py b/buckaroo/builders/payments/belfius_builder.py index af3f2ad..6d67017 100644 --- a/buckaroo/builders/payments/belfius_builder.py +++ b/buckaroo/builders/payments/belfius_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class BelfiusBuilder(PaymentBuilder): """Builder for Belfius payments.""" def get_service_name(self) -> str: """Get the service name for belfius payments.""" return "belfius" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Belfius payments based on action.""" diff --git a/buckaroo/builders/payments/billink_builder.py b/buckaroo/builders/payments/billink_builder.py index defe238..4e86fd7 100644 --- a/buckaroo/builders/payments/billink_builder.py +++ b/buckaroo/builders/payments/billink_builder.py @@ -1,6 +1,7 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class BillinkBuilder(PaymentBuilder): """Builder for Billink payments with buy-now-pay-later capabilities.""" @@ -13,8 +14,16 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: if action.lower() in ["pay"]: return { - "billingCustomer": {"type": list, "required": True, "description": "Billing customer information"}, - "shippingCustomer": {"type": list, "required": True, "description": "Shipping customer information"}, + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, "article": {"type": list, "required": True, "description": "Billink articles"}, } diff --git a/buckaroo/builders/payments/bizum_builder.py b/buckaroo/builders/payments/bizum_builder.py index 0161bd3..f1758a2 100644 --- a/buckaroo/builders/payments/bizum_builder.py +++ b/buckaroo/builders/payments/bizum_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class BizumBuilder(PaymentBuilder): """Builder for Bizum payments.""" def get_service_name(self) -> str: """Get the service name for bizum payments.""" return "Bizum" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Bizum payments based on action.""" diff --git a/buckaroo/builders/payments/blik_builder.py b/buckaroo/builders/payments/blik_builder.py index 86fddc7..e2cf505 100644 --- a/buckaroo/builders/payments/blik_builder.py +++ b/buckaroo/builders/payments/blik_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class BlikBuilder(PaymentBuilder): """Builder for Blik payments.""" def get_service_name(self) -> str: """Get the service name for Blik payments.""" return "Blik" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Blik payments based on action.""" diff --git a/buckaroo/builders/payments/buckaroo_voucher_builder.py b/buckaroo/builders/payments/buckaroo_voucher_builder.py index 2ed1e13..31e6931 100644 --- a/buckaroo/builders/payments/buckaroo_voucher_builder.py +++ b/buckaroo/builders/payments/buckaroo_voucher_builder.py @@ -1,31 +1,53 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class BuckarooVoucherBuilder(PaymentBuilder): """Builder for Buckaroo Voucher payments.""" def get_service_name(self) -> str: """Get the service name for Buckaroo Voucher payments.""" return "Buckaroo Voucher" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Buckaroo Voucher payments based on action.""" if action.lower() in ["pay", "getbalance", "deactivatevoucher"]: return { - "VoucherCode": {"type": str, "required": True, "description": "The voucher code to use for the payment"}, + "VoucherCode": { + "type": str, + "required": True, + "description": "The voucher code to use for the payment", + }, } - + if action.lower() in ["createapplication"]: return { - "GroupReference": {"type": str, "required": False, "description": "The group reference for the application"}, - "UsageType": {"type": str, "required": True, "description": "The usage type for the voucher application"}, - "ValidFrom": {"type": str, "required": True, "description": "The start date of voucher validity"}, - "ValidUntil": {"type": str, "required": False, "description": "The end date of voucher validity"}, - "CreationBalance": {"type": float, "required": True, "description": "The initial balance of the voucher"}, + "GroupReference": { + "type": str, + "required": False, + "description": "The group reference for the application", + }, + "UsageType": { + "type": str, + "required": True, + "description": "The usage type for the voucher application", + }, + "ValidFrom": { + "type": str, + "required": True, + "description": "The start date of voucher validity", + }, + "ValidUntil": { + "type": str, + "required": False, + "description": "The end date of voucher validity", + }, + "CreationBalance": { + "type": float, + "required": True, + "description": "The initial balance of the voucher", + }, } - + return {} diff --git a/buckaroo/builders/payments/capabilities/__init__.py b/buckaroo/builders/payments/capabilities/__init__.py index efa34e7..9854f82 100644 --- a/buckaroo/builders/payments/capabilities/__init__.py +++ b/buckaroo/builders/payments/capabilities/__init__.py @@ -10,8 +10,8 @@ from .bank_transfer_capabilities import BankTransferCapabilities __all__ = [ - 'AuthorizeCaptureCapable', - 'InstantRefundCapable', - 'FastCheckoutCapable', - 'BankTransferCapabilities' -] \ No newline at end of file + "AuthorizeCaptureCapable", + "InstantRefundCapable", + "FastCheckoutCapable", + "BankTransferCapabilities", +] diff --git a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py index 37a9ed6..39c5313 100644 --- a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py +++ b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ Payment capability mixins for specific payment features. @@ -7,50 +5,58 @@ based on their actual capabilities, rather than giving all methods to all builders. """ +from __future__ import annotations + from typing import Optional, TYPE_CHECKING + from ....models.payment_response import PaymentResponse if TYPE_CHECKING: from ..payment_builder import PaymentBuilder + class AuthorizeCaptureCapable: """Mixin for payment methods that support authorization (Credit Card).""" - - def authorize(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def authorize(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Authorize a payment without capturing it. - + Available for: Credit Card Not available for: iDEAL, Sofort, PayConiq (immediate transfer) - + Args: validate (bool): Whether to validate service parameters before building - + Returns: PaymentResponse: The authorization response """ payment_request = self.build("Authorize", validate=validate) request_data = payment_request.to_dict() return self._post_transaction(request_data) - - def authorizeEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def authorizeEncrypted(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Authorize a payment without capturing it. - + Available for: Credit Card Not available for: iDEAL, Sofort, PayConiq (immediate transfer) - + Args: validate (bool): Whether to validate service parameters before building - + Returns: PaymentResponse: The authorization response """ payment_request = self.build("AuthorizeEncrypted", validate=validate) request_data = payment_request.to_dict() return self._post_transaction(request_data) - - def cancelAuthorize(self: 'PaymentBuilder', original_transaction_key: Optional[str] = None, validate: bool = True) -> PaymentResponse: + + def cancelAuthorize( + self: "PaymentBuilder", + original_transaction_key: Optional[str] = None, + validate: bool = True, + ) -> PaymentResponse: """ Cancel a previously authorized payment. @@ -58,8 +64,8 @@ def cancelAuthorize(self: 'PaymentBuilder', original_transaction_key: Optional[s """ txn_key = ( original_transaction_key - or self._payload.get('original_transaction_key') - or self._payload.get('authorization_key') + or self._payload.get("original_transaction_key") + or self._payload.get("authorization_key") ) if not txn_key: raise ValueError( @@ -70,18 +76,18 @@ def cancelAuthorize(self: 'PaymentBuilder', original_transaction_key: Optional[s payment_request = self.build("CancelAuthorize", validate=validate) request_data = payment_request.to_dict() - request_data['OriginalTransactionKey'] = txn_key + request_data["OriginalTransactionKey"] = txn_key - # Buckaroo API requires AmountCredit for cancel-authorize, not AmountDebit - if 'AmountDebit' in request_data: - request_data['AmountCredit'] = request_data.pop('AmountDebit') + # PaymentRequest.to_dict always writes AmountDebit; swap to AmountCredit + # since Buckaroo expects AmountCredit for cancel-authorize. + request_data["AmountCredit"] = request_data.pop("AmountDebit") return self._post_transaction(request_data) - def capture(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def capture(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Capture a previously authorized payment. - + Args: validate (bool): Whether to validate service parameters before building @@ -92,4 +98,3 @@ def capture(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: payment_request = self.build("Capture", validate=validate) request_data = payment_request.to_dict() return self._post_transaction(request_data) - \ No newline at end of file diff --git a/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py b/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py index 20c6620..a92221d 100644 --- a/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py +++ b/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ Payment capability mixins for specific payment features. @@ -7,15 +5,13 @@ based on their actual capabilities, rather than giving all methods to all builders. """ -from typing import TYPE_CHECKING -from ....models.payment_response import PaymentResponse -from .instant_refund_capable import InstantRefundCapable -from .fast_checkout_capable import FastCheckoutCapable +from __future__ import annotations -if TYPE_CHECKING: - from ..payment_builder import PaymentBuilder +from .fast_checkout_capable import FastCheckoutCapable +from .instant_refund_capable import InstantRefundCapable class BankTransferCapabilities(InstantRefundCapable, FastCheckoutCapable): """Combined capabilities for bank transfer payment methods.""" - pass \ No newline at end of file + + pass diff --git a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py index 2bb539d..d9c0d19 100644 --- a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py +++ b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ Payment capability mixins for specific payment features. @@ -7,16 +5,20 @@ based on their actual capabilities, rather than giving all methods to all builders. """ +from __future__ import annotations + from typing import TYPE_CHECKING + from ....models.payment_response import PaymentResponse if TYPE_CHECKING: from ..payment_builder import PaymentBuilder + class EncryptedPayCapable: """Mixin for payment methods that support encryption (Credit Card).""" - def payEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def payEncrypted(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Process a payment with encryption. @@ -31,4 +33,4 @@ def payEncrypted(self: 'PaymentBuilder', validate: bool = True) -> PaymentRespon """ payment_request = self.build("PayEncrypted", validate=validate) request_data = payment_request.to_dict() - return self._post_transaction(request_data) \ No newline at end of file + return self._post_transaction(request_data) diff --git a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py index be1999a..cd44cf3 100644 --- a/buckaroo/builders/payments/capabilities/fast_checkout_capable.py +++ b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ Payment capability mixins for specific payment features. @@ -7,7 +5,10 @@ based on their actual capabilities, rather than giving all methods to all builders. """ +from __future__ import annotations + from typing import TYPE_CHECKING + from ....models.payment_response import PaymentResponse if TYPE_CHECKING: @@ -16,23 +17,22 @@ class FastCheckoutCapable: """Mixin for payment methods that support fast checkout (iDEAL, Sofort, PayConiq).""" - - def payFastCheckout(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def payFastCheckout(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Enable PayFast Checkout. - + Available for: iDEAL, Sofort, PayConiq Not available for: Credit Card, PayPal - + Args: validate (bool): Whether to validate service parameters before building - + Returns: PaymentResponse: The fast checkout response """ payment_request = self.build("payFastCheckout", validate=validate) - + request_data = payment_request.to_dict() return self._post_transaction(request_data) - diff --git a/buckaroo/builders/payments/capabilities/instant_refund_capable.py b/buckaroo/builders/payments/capabilities/instant_refund_capable.py index 59d9b5a..019f78a 100644 --- a/buckaroo/builders/payments/capabilities/instant_refund_capable.py +++ b/buckaroo/builders/payments/capabilities/instant_refund_capable.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ Payment capability mixins for specific payment features. @@ -7,7 +5,10 @@ based on their actual capabilities, rather than giving all methods to all builders. """ +from __future__ import annotations + from typing import TYPE_CHECKING + from ....models.payment_response import PaymentResponse if TYPE_CHECKING: @@ -16,17 +17,17 @@ class InstantRefundCapable: """Mixin for payment methods that support instant refunds (iDEAL, Sofort, PayConiq).""" - - def instantRefund(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def instantRefund(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Initiate an instant refund. - + Available for: iDEAL, Sofort, PayConiq Not available for: Credit Card, PayPal (use regular refund instead) - + Args: validate (bool): Whether to validate service parameters before building - + Returns: PaymentResponse: The instant refund response """ diff --git a/buckaroo/builders/payments/click_to_pay_builder.py b/buckaroo/builders/payments/click_to_pay_builder.py index f1a9579..b6b64a0 100644 --- a/buckaroo/builders/payments/click_to_pay_builder.py +++ b/buckaroo/builders/payments/click_to_pay_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class ClickToPayBuilder(PaymentBuilder): """Builder for Click to Pay payments.""" def get_service_name(self) -> str: """Get the service name for Click to Pay payments.""" return "ClickToPay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Click to Pay payments based on action.""" diff --git a/buckaroo/builders/payments/credit_card_builder.py b/buckaroo/builders/payments/credit_card_builder.py index 45d9d44..65885d5 100644 --- a/buckaroo/builders/payments/credit_card_builder.py +++ b/buckaroo/builders/payments/credit_card_builder.py @@ -6,14 +6,15 @@ from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable from ...models.payment_response import PaymentResponse + class CreditcardBuilder(PaymentBuilder, EncryptedPayCapable, AuthorizeCaptureCapable): """Builder for Credit Card payments with authorization capabilities.""" - + _serviceName = "creditcard" - + def get_service_name(self) -> str: """Get the service name for Creditcard payments.""" - return self._payload.get('brand', 'CreditCard') + return self._payload.get("brand", "CreditCard") def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Credit Card payments based on action.""" @@ -21,33 +22,49 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: if action.lower() == "payencrypted": # Encrypted payment uses encrypted data instead of raw card details return { - "encryptedcarddata": {"type": str, "required": True, "description": "Encrypted card data"}, + "encryptedcarddata": { + "type": str, + "required": True, + "description": "Encrypted card data", + }, } if action.lower() == "paywithsecuritycode": # Payment with security code uses encrypted data instead of raw card details return { - "encryptedsecuritycode": {"type": str, "required": True, "description": "Encrypted security code"}, + "encryptedsecuritycode": { + "type": str, + "required": True, + "description": "Encrypted security code", + }, } if action.lower() == "paywithtoken": # Hosted Fields inline payment: token from submitSession() return { - "sessionid": {"type": str, "required": True, "description": "Session ID token from Hosted Fields submitSession()"}, + "sessionid": { + "type": str, + "required": True, + "description": "Session ID token from Hosted Fields submitSession()", + }, } if action.lower() == "authorizewithtoken": # Hosted Fields inline authorize: token from submitSession() return { - "sessionid": {"type": str, "required": True, "description": "Session ID token from Hosted Fields submitSession()"}, + "sessionid": { + "type": str, + "required": True, + "description": "Session ID token from Hosted Fields submitSession()", + }, } return {} - - def payWithSecurityCode(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def payWithSecurityCode(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Process a payment with a security code. - + Args: validate (bool): Whether to validate service parameters before building @@ -57,8 +74,8 @@ def payWithSecurityCode(self: 'PaymentBuilder', validate: bool = True) -> Paymen payment_request = self.build("PayWithSecurityCode", validate=validate) request_data = payment_request.to_dict() return self._post_transaction(request_data) - - def payWithToken(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + + def payWithToken(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Process a payment using a Hosted Fields session token. @@ -72,7 +89,7 @@ def payWithToken(self: 'PaymentBuilder', validate: bool = True) -> PaymentRespon request_data = payment_request.to_dict() return self._post_transaction(request_data) - def authorizeWithToken(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def authorizeWithToken(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ Authorize a payment using a Hosted Fields session token. @@ -86,17 +103,17 @@ def authorizeWithToken(self: 'PaymentBuilder', validate: bool = True) -> Payment request_data = payment_request.to_dict() return self._post_transaction(request_data) - def payRecurrent(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: + def payRecurrent(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: """ PayRecurrent a previously authorized payment. - + Args: validate (bool): Whether to validate service parameters before building Returns: PaymentResponse: The payment response """ - + payment_request = self.build("PayRecurrent", validate=validate) request_data = payment_request.to_dict() - return self._post_transaction(request_data) \ No newline at end of file + return self._post_transaction(request_data) diff --git a/buckaroo/builders/payments/default_builder.py b/buckaroo/builders/payments/default_builder.py index 040b188..6859d8d 100644 --- a/buckaroo/builders/payments/default_builder.py +++ b/buckaroo/builders/payments/default_builder.py @@ -1,17 +1,15 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class DefaultBuilder(PaymentBuilder): """Builder for Default payments.""" def get_service_name(self) -> str: """Get the service name for Default payments.""" # Try to get method from payload, fallback to 'Unknown' if not available - return self._payload.get('method', 'Unknown') - + return self._payload.get("method", "Unknown") + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Default payments based on action.""" diff --git a/buckaroo/builders/payments/eps_builder.py b/buckaroo/builders/payments/eps_builder.py index fb446b5..7d3adc7 100644 --- a/buckaroo/builders/payments/eps_builder.py +++ b/buckaroo/builders/payments/eps_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class EpsBuilder(PaymentBuilder): """Builder for EPS payments.""" def get_service_name(self) -> str: """Get the service name for EPS payments.""" return "EPS" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for EPS payments based on action.""" diff --git a/buckaroo/builders/payments/external_payment_builder.py b/buckaroo/builders/payments/external_payment_builder.py index d2f321d..8f7c235 100644 --- a/buckaroo/builders/payments/external_payment_builder.py +++ b/buckaroo/builders/payments/external_payment_builder.py @@ -1,8 +1,15 @@ +from typing import Any, Dict + from .payment_builder import PaymentBuilder + class ExternalPaymentBuilder(PaymentBuilder): """Builder for External payments.""" def get_service_name(self) -> str: """Get the service name for External payments.""" return "ExternalPayment" + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + """External payments declare no service-level parameters.""" + return {} diff --git a/buckaroo/builders/payments/giftcards_builder.py b/buckaroo/builders/payments/giftcards_builder.py index 59f62ba..fdceba8 100644 --- a/buckaroo/builders/payments/giftcards_builder.py +++ b/buckaroo/builders/payments/giftcards_builder.py @@ -1,38 +1,44 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class GiftcardsBuilder(PaymentBuilder): """Builder for Giftcards payments.""" def get_service_name(self) -> str: """Get the service name for Giftcards payments.""" - return self._payload.get('giftcard_name', 'Giftcards') - + return self._payload.get("giftcard_name", "Giftcards") + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Giftcards payments based on action.""" if action.lower() in ["pay"]: - if self._payload.get('giftcard_name').lower() == 'fashioncheque': + if self._payload.get("giftcard_name", "").lower() == "fashioncheque": return { - "FashionChequeCardNumber": {"type": str, "required": True, "description": "Save payment token for future use"}, - "FashionChequePIN": {"type": str, "required": True, "description": "Save payment token for future use"}, + "FashionChequeCardNumber": { + "type": str, + "required": True, + "description": "Save payment token for future use", + }, + "FashionChequePIN": { + "type": str, + "required": True, + "description": "Save payment token for future use", + }, } - - if self._payload.get('giftcard_name').lower() == 'intersolve': + + if self._payload.get("giftcard_name", "").lower() == "intersolve": return { "IntersolveCardnumber": {"type": str, "required": True, "description": ""}, "IntersolvePIN": {"type": str, "required": True, "description": ""}, } - - if self._payload.get('giftcard_name').lower() == 'tcs': + + if self._payload.get("giftcard_name", "").lower() == "tcs": return { "TCSCardnumber": {"type": str, "required": True, "description": ""}, "TCSValidationCode": {"type": str, "required": True, "description": ""}, } - + return { "Cardnumber": {"type": str, "required": True, "description": ""}, "PIN": {"type": str, "required": True, "description": ""}, @@ -40,5 +46,4 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: "Email": {"type": str, "required": False, "description": ""}, } - return {} diff --git a/buckaroo/builders/payments/google_pay_builder.py b/buckaroo/builders/payments/google_pay_builder.py index e1fe74f..7831779 100644 --- a/buckaroo/builders/payments/google_pay_builder.py +++ b/buckaroo/builders/payments/google_pay_builder.py @@ -1,24 +1,21 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class GooglePayBuilder(PaymentBuilder): """Builder for Giftcards payments.""" def get_service_name(self) -> str: """Get the service name for Google Pay payments.""" return "GooglePay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Google Pay payments based on action.""" if action.lower() in ["pay"]: return { "PaymentData": {"type": str, "required": True, "description": ""}, - "CustomerCardName": {"type": str, "required": False, "description": ""} + "CustomerCardName": {"type": str, "required": False, "description": ""}, } - return {} diff --git a/buckaroo/builders/payments/ideal_builder.py b/buckaroo/builders/payments/ideal_builder.py index f57d0ed..472655e 100644 --- a/buckaroo/builders/payments/ideal_builder.py +++ b/buckaroo/builders/payments/ideal_builder.py @@ -2,21 +2,21 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder from .capabilities.bank_transfer_capabilities import BankTransferCapabilities -from ...models.payment_response import PaymentResponse + class IdealBuilder(PaymentBuilder, BankTransferCapabilities): """Builder for iDEAL payments with bank transfer capabilities.""" - + def get_service_name(self) -> str: """Get the service name for iDEAL payments.""" return "ideal" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for iDEAL payments based on action.""" - + if action.lower() in ["pay", "payfastcheckout"]: return { "issuer": {"type": str, "required": False, "description": "iDEAL bank issuer code"}, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/ideal_qr_builder.py b/buckaroo/builders/payments/ideal_qr_builder.py index da39bd3..1922b89 100644 --- a/buckaroo/builders/payments/ideal_qr_builder.py +++ b/buckaroo/builders/payments/ideal_qr_builder.py @@ -1,57 +1,95 @@ from __future__ import annotations from typing import Dict, Any +from ...models.payment_response import PaymentResponse from .payment_builder import PaymentBuilder + class IdealQrBuilder(PaymentBuilder): """Builder for iDEAL QR payments with bank transfer capabilities.""" - - @property - def required_fields(self) -> Dict[str, Any]: + + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: """ Get the required fields for this payment method. - Can be overridden by specific payment builders to customize required fields. - + Returns: Dict[str, Any]: Dictionary mapping field names to their current values """ return { - 'currency': self._currency, - 'description': self._description, - 'invoice': self._invoice, - 'return_url': self._return_url, - 'return_url_cancel': self._return_url_cancel, - 'return_url_error': self._return_url_error, - 'return_url_reject': self._return_url_reject, + "currency": self._currency, + "description": self._description, + "invoice": self._invoice, + "return_url": self._return_url, + "return_url_cancel": self._return_url_cancel, + "return_url_error": self._return_url_error, + "return_url_reject": self._return_url_reject, } - + def get_service_name(self) -> str: """Get the service name for iDEAL QR payments.""" return "IdealQr" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for iDEAL QR payments based on action.""" - + if action.lower() in ["generate"]: return { "amount": {"type": str, "required": True, "description": "iDEAL QR payment amount"}, - "amountIsChangeable": {"type": bool, "required": True, "description": "Indicates if the amount can be changed"}, - "purchaseId": {"type": str, "required": True, "description": "Unique purchase identifier"}, - "description": {"type": str, "required": True, "description": "Description of the payment"}, - "isOneOff": {"type": bool, "required": True, "description": "Indicates if the payment is a one-off"}, - "expiration": {"type": str, "required": True, "description": "Expiration time for the QR code"}, - "imageSize": {"type": str, "required": True, "description": "Size of the QR code image"}, - "isProcessing": {"type": bool, "required": False, "description": "Indicates if the payment is processing"}, - "minAmount": {"type": str, "required": False, "description": "Minimum amount allowed for the payment"}, - "maxAmount": {"type": str, "required": False, "description": "Maximum amount allowed for the payment"}, + "amountIsChangeable": { + "type": bool, + "required": True, + "description": "Indicates if the amount can be changed", + }, + "purchaseId": { + "type": str, + "required": True, + "description": "Unique purchase identifier", + }, + "description": { + "type": str, + "required": True, + "description": "Description of the payment", + }, + "isOneOff": { + "type": bool, + "required": True, + "description": "Indicates if the payment is a one-off", + }, + "expiration": { + "type": str, + "required": True, + "description": "Expiration time for the QR code", + }, + "imageSize": { + "type": str, + "required": True, + "description": "Size of the QR code image", + }, + "isProcessing": { + "type": bool, + "required": False, + "description": "Indicates if the payment is processing", + }, + "minAmount": { + "type": str, + "required": False, + "description": "Minimum amount allowed for the payment", + }, + "maxAmount": { + "type": str, + "required": False, + "description": "Maximum amount allowed for the payment", + }, } return {} - + def generate(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: # Build the payment request - payment_request = self.build("Generate", validate=validate, strict_validation=strict_validation) - + payment_request = self.build( + "Generate", validate=validate, strict_validation=strict_validation + ) + # Convert to dictionary for API request_data = payment_request.to_dict() - return self._post_data_request(request_data) \ No newline at end of file + return self._post_data_request(request_data) diff --git a/buckaroo/builders/payments/in3_builder.py b/buckaroo/builders/payments/in3_builder.py index 557a407..71e5dab 100644 --- a/buckaroo/builders/payments/in3_builder.py +++ b/buckaroo/builders/payments/in3_builder.py @@ -1,21 +1,30 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class In3Builder(PaymentBuilder): """Builder for IN3 payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for IN3 payments.""" return "in3" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for IN3 payments based on action.""" - + if action.lower() in ["pay"]: return { - "billingCustomer": {"type": list, "required": True, "description": "Billing customer information"}, - "shippingCustomer": {"type": list, "required": True, "description": "Shipping customer information"}, + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, "article": {"type": list, "required": True, "description": "IN3 articles"}, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/kbc_builder.py b/buckaroo/builders/payments/kbc_builder.py index 2a3568f..b78debb 100644 --- a/buckaroo/builders/payments/kbc_builder.py +++ b/buckaroo/builders/payments/kbc_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class KBCBuilder(PaymentBuilder): """Builder for KBC payments.""" def get_service_name(self) -> str: """Get the service name for KBC payments.""" return "KBCPaymentButton" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for KBC payments based on action.""" diff --git a/buckaroo/builders/payments/klarna_builder.py b/buckaroo/builders/payments/klarna_builder.py index cd65c50..8a7adab 100644 --- a/buckaroo/builders/payments/klarna_builder.py +++ b/buckaroo/builders/payments/klarna_builder.py @@ -1,21 +1,30 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class KlarnaBuilder(PaymentBuilder): """Builder for Klarna payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Klarna payments.""" return "klarna" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Klarna payments based on action.""" - + if action.lower() in ["pay"]: return { - "billingCustomer": {"type": list, "required": True, "description": "Billing customer information"}, - "shippingCustomer": {"type": list, "required": True, "description": "Shipping customer information"}, - "article": {"type": list, "required": True, "description": "Riverty articles"}, + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, + "article": {"type": list, "required": True, "description": "Klarna articles"}, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/klarnakp_builder.py b/buckaroo/builders/payments/klarnakp_builder.py index b61af7b..cb9bfb4 100644 --- a/buckaroo/builders/payments/klarnakp_builder.py +++ b/buckaroo/builders/payments/klarnakp_builder.py @@ -4,6 +4,7 @@ from buckaroo.models.payment_response import PaymentResponse from .payment_builder import PaymentBuilder + class KlarnaKPBuilder(PaymentBuilder): """Builder for Klarna KP payments with bank transfer capabilities.""" @@ -11,95 +12,110 @@ def required_fields(self, action: str = "Pay") -> Dict[str, Any]: """ Get the required fields for this payment method and action. Can be overridden by specific payment builders to customize required fields. - + Args: action (str): The action being performed (Pay, Reserve, etc.) - + Returns: Dict[str, Any]: Dictionary mapping field names to their current values """ if action.lower() == "reserve": return { - 'currency': self._currency, - 'invoice': self._invoice, + "currency": self._currency, + "invoice": self._invoice, } - - return { - } + + return {} def get_service_name(self) -> str: """Get the service name for Klarna KP payments.""" return "klarnakp" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Klarna KP payments based on action.""" - - if action.lower() in ["pay", "cancelreservation"]: + + if action.lower() in ["pay", "cancelreservation", "extendreservation"]: return { - "reservationNumber": {"type": str, "required": True, "description": "Klarna KP reservation number"}, + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, } - + if action.lower() == "reserve": return { - # "reservationNumber": {"type": str, "required": True, "description": "Klarna KP reservation number"}, - # "billingCustomer": {"type": list, "required": True, "description": "Billing customer information"}, - # "shippingCustomer": {"type": list, "required": True, "description": "Shipping customer information"}, - "operatingCountry": {"type": str, "required": True, "description": "Operating country code"}, + "operatingCountry": { + "type": str, + "required": True, + "description": "Operating country code", + }, "article": {"type": list, "required": True, "description": "Klarna KP articles"}, } - if action.lower() in ["pay", "cancelreservation", "extendreservation"]: - return { - "reservationNumber": {"type": str, "required": True, "description": "Klarna KP reservation number"}, - } - if action.lower() == "updatereservation": return { - "reservationNumber": {"type": str, "required": True, "description": "Klarna KP reservation number"}, + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, "article": {"type": list, "required": True, "description": "Klarna KP articles"}, } - + if action.lower() == "addshippinginfo": return { - "originalTransactionKey": {"type": str, "required": True, "description": "Original transaction key"}, - "shippingMethod": {"type": str, "required": False, "description": "Shipping method"}, + "originalTransactionKey": { + "type": str, + "required": True, + "description": "Original transaction key", + }, + "shippingMethod": { + "type": str, + "required": False, + "description": "Shipping method", + }, "company": {"type": str, "required": False, "description": "Shipping company name"}, - "trackingNumber": {"type": str, "required": False, "description": "Shipping tracking number"} + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, } - + return {} - - def reserve(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + + def reserve(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("Reserve", validate=validate) request_data = payment_request.to_dict() - + return self._post_data_request(request_data) - - def cancelReservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + + def cancelReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("CancelReservation", validate=validate) request_data = payment_request.to_dict() - + return self._post_data_request(request_data) - - def updateReservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + + def updateReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("UpdateReservation", validate=validate) request_data = payment_request.to_dict() - + return self._post_data_request(request_data) - - def extendReservation(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + + def extendReservation(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("ExtendReservation", validate=validate) request_data = payment_request.to_dict() - + return self._post_data_request(request_data) - - def addShippingInfo(self: 'PaymentBuilder', validate: bool = True) -> PaymentResponse: - + + def addShippingInfo(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + payment_request = self.build("AddShippingInfo", validate=validate) request_data = payment_request.to_dict() - - return self._post_data_request(request_data) \ No newline at end of file + + return self._post_data_request(request_data) diff --git a/buckaroo/builders/payments/knaken_builder.py b/buckaroo/builders/payments/knaken_builder.py index d5c8a83..7a4ce97 100644 --- a/buckaroo/builders/payments/knaken_builder.py +++ b/buckaroo/builders/payments/knaken_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class KnakenBuilder(PaymentBuilder): """Builder for Knaken payments.""" def get_service_name(self) -> str: """Get the service name for Knaken payments.""" return "Knaken" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Knaken payments based on action.""" diff --git a/buckaroo/builders/payments/mbway_builder.py b/buckaroo/builders/payments/mbway_builder.py index d37a120..d9b3d7f 100644 --- a/buckaroo/builders/payments/mbway_builder.py +++ b/buckaroo/builders/payments/mbway_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class MBWayBuilder(PaymentBuilder): """Builder for MBWay payments.""" def get_service_name(self) -> str: """Get the service name for MBWay payments.""" return "MBWay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for MBWay payments based on action.""" diff --git a/buckaroo/builders/payments/multibanco_builder.py b/buckaroo/builders/payments/multibanco_builder.py index ce89749..b0163a8 100644 --- a/buckaroo/builders/payments/multibanco_builder.py +++ b/buckaroo/builders/payments/multibanco_builder.py @@ -1,16 +1,14 @@ - - - from typing import Dict, Any from .payment_builder import PaymentBuilder + class MultibancoBuilder(PaymentBuilder): """Builder for Multibanco payments.""" def get_service_name(self) -> str: """Get the service name for Multibanco payments.""" return "Multibanco" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Multibanco payments based on action.""" diff --git a/buckaroo/builders/payments/paybybank_builder.py b/buckaroo/builders/payments/paybybank_builder.py index a5bebe4..d12efd6 100644 --- a/buckaroo/builders/payments/paybybank_builder.py +++ b/buckaroo/builders/payments/paybybank_builder.py @@ -2,21 +2,25 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder from .capabilities.bank_transfer_capabilities import BankTransferCapabilities -from ...models.payment_response import PaymentResponse + class PayByBankBuilder(PaymentBuilder, BankTransferCapabilities): """Builder for PayByBank payments with bank transfer capabilities.""" - + def get_service_name(self) -> str: """Get the service name for PayByBank payments.""" return "PayByBank" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for PayByBank payments based on action.""" - + if action.lower() in ["pay"]: return { - "issuer": {"type": str, "required": True, "description": "PayByBank bank issuer code"}, + "issuer": { + "type": str, + "required": True, + "description": "PayByBank bank issuer code", + }, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/payconiq_builder.py b/buckaroo/builders/payments/payconiq_builder.py index 2459cb8..b997363 100644 --- a/buckaroo/builders/payments/payconiq_builder.py +++ b/buckaroo/builders/payments/payconiq_builder.py @@ -2,23 +2,35 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder from .capabilities.bank_transfer_capabilities import BankTransferCapabilities -from ...models.payment_response import PaymentResponse + class PayconiqBuilder(PaymentBuilder, BankTransferCapabilities): """Builder for Payconiq payments with bank transfer capabilities.""" - + def get_service_name(self) -> str: """Get the service name for Payconiq payments.""" return "payconiq" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Payconiq payments based on action.""" - + if action.lower() in ["pay", "payfastcheckout"]: return { - "mobilenumber": {"type": str, "required": False, "description": "Mobile number for Payconiq"}, - "savetoken": {"type": (str, bool), "required": False, "description": "Save payment token for future use"}, - "isrecurring": {"type": (str, bool), "required": False, "description": "Recurring payment flag"}, + "mobilenumber": { + "type": str, + "required": False, + "description": "Mobile number for Payconiq", + }, + "savetoken": { + "type": (str, bool), + "required": False, + "description": "Save payment token for future use", + }, + "isrecurring": { + "type": (str, bool), + "required": False, + "description": "Recurring payment flag", + }, } elif action.lower() == "instantrefund": # Instant refund has different requirements @@ -29,49 +41,52 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: else: # Default to Pay action parameters return { - "mobilenumber": {"type": str, "required": False, "description": "Mobile number for Payconiq"}, - "savetoken": {"type": (str, bool), "required": False, "description": "Save payment token for future use"}, - "isrecurring": {"type": (str, bool), "required": False, "description": "Recurring payment flag"}, + "mobilenumber": { + "type": str, + "required": False, + "description": "Mobile number for Payconiq", + }, + "savetoken": { + "type": (str, bool), + "required": False, + "description": "Save payment token for future use", + }, + "isrecurring": { + "type": (str, bool), + "required": False, + "description": "Recurring payment flag", + }, } - - def mobile_number(self, mobile_number: str) -> 'PayconiqBuilder': + + def mobile_number(self, mobile_number: str) -> "PayconiqBuilder": """Set the mobile number for Payconiq.""" return self.add_parameter("mobilenumber", mobile_number) - - def from_dict(self, data: Dict[str, Any]) -> 'PayconiqBuilder': + + def from_dict(self, data: Dict[str, Any]) -> "PayconiqBuilder": """ Populate the Payconiq builder from a dictionary of parameters. - + Args: data (Dict[str, Any]): Dictionary containing payment parameters - + Returns: PayconiqBuilder: Self for method chaining - + Additional Payconiq-specific keys: - mobile_number: Mobile number for Payconiq (str) """ # Call parent from_dict first super().from_dict(data) - + # Handle Payconiq-specific parameters - if 'mobile_number' in data: - self.mobile_number(data['mobile_number']) - + if "mobile_number" in data: + self.mobile_number(data["mobile_number"]) + return self - + # Bank transfer capabilities (inherited from BankTransferCapabilities): - # - instant_refund() - # - pay_fast_checkout() - # + # - instantRefund() + # - payFastCheckout() + # # Standard methods (inherited from PaymentBuilder): # - pay(), refund(), capture(), cancel(), execute_action() - - # Optional: Create aliases with method names for consistency - def payFastCheckout(self, validate: bool = True) -> PaymentResponse: - """Enable PayFast Checkout for Payconiq payments.""" - return self.pay_fast_checkout(validate=validate) - - def instantRefund(self, validate: bool = True) -> PaymentResponse: - """Initiate an instant refund for Payconiq payments.""" - return self.instant_refund(validate=validate) \ No newline at end of file diff --git a/buckaroo/builders/payments/payment_builder.py b/buckaroo/builders/payments/payment_builder.py index a19b84c..1b287f2 100644 --- a/buckaroo/builders/payments/payment_builder.py +++ b/buckaroo/builders/payments/payment_builder.py @@ -1,495 +1,8 @@ -from typing import Dict, Any, Optional from ..base_builder import BaseBuilder -from ...models.payment_request import ClientIP, Parameter, PaymentRequest, Service, ServiceList -from ...models.payment_response import PaymentResponse class PaymentBuilder(BaseBuilder): - """Abstract base class for payment builders.""" - pass - - def currency(self, currency: str) -> 'PaymentBuilder': - """Set the currency for the payment.""" - self._currency = currency - return self - - def amount(self, amount: float) -> 'PaymentBuilder': - """Set the amount for the payment.""" - self._amount_debit = amount - return self - - def description(self, description: str) -> 'PaymentBuilder': - """Set the description for the payment.""" - self._description = description - return self - - def invoice(self, invoice: str) -> 'PaymentBuilder': - """Set the invoice number for the payment.""" - self._invoice = invoice - return self - - def return_url(self, url: str) -> 'PaymentBuilder': - """Set the return URL for successful payment.""" - self._return_url = url - return self - - def return_url_cancel(self, url: str) -> 'PaymentBuilder': - """Set the return URL for cancelled payment.""" - self._return_url_cancel = url - return self - - def return_url_error(self, url: str) -> 'PaymentBuilder': - """Set the return URL for payment error.""" - self._return_url_error = url - return self - - def return_url_reject(self, url: str) -> 'PaymentBuilder': - """Set the return URL for rejected payment.""" - self._return_url_reject = url - return self - - def continue_on_incomplete(self, continue_incomplete: str) -> 'PaymentBuilder': - """Set whether to continue on incomplete payment.""" - self._continue_on_incomplete = continue_incomplete - return self - - def client_ip(self, ip_address: str, ip_type: int = 0) -> 'PaymentBuilder': - """Set the client IP information.""" - self._client_ip = ClientIP(type=ip_type, address=ip_address) - return self - - def add_parameter(self, key: str, value: Any, group_type: str = "", group_id: str = "") -> 'PaymentBuilder': - """Add a custom parameter to the service. - - Args: - key: Parameter name - value: Parameter value (will be converted to string unless it's a list/dict) - group_type: Optional group type for grouped parameters - group_id: Optional group ID for grouped parameters - """ - # Handle list of dictionaries (e.g., articles) - if isinstance(value, list): - for index, item in enumerate(value): - if isinstance(item, dict): - # Each item in the list becomes a group - for item_key, item_value in item.items(): - - str_value = str(item_value).lower() if isinstance(item_value, bool) else str(item_value) - parameter = Parameter( - name=item_key.capitalize(), - value=str_value, - group_type=key.capitalize(), # e.g., "articles" - group_id=str(index + 1) # 1-based index - ) - self._service_parameters.append(parameter) - return self - - # Handle regular parameters - # Convert value to string for API compatibility - str_value = str(value).lower() if isinstance(value, bool) else str(value) - - parameter = Parameter( - name=key.capitalize(), - value=str_value, - group_type=group_type.capitalize(), - group_id=group_id - ) - - self._service_parameters.append(parameter) - return self - - # Validation convenience methods - def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: - """Check if a parameter is allowed for the given action.""" - return self._validator.is_parameter_allowed(param_name, action) - - def get_parameter_info(self, action: str = "Pay") -> Dict[str, Any]: - """Get information about allowed parameters for an action.""" - return self._validator.get_parameter_info(action) - - def get_normalized_parameter_name(self, param_name: str, action: str = "Pay") -> str: - """Get the official parameter name that matches the input.""" - return self._validator.get_normalized_parameter_name(param_name, action) - - def _validate_and_filter_service_parameters(self, action: str = "Pay", strict: bool = False) -> None: - """ - Validate and filter service parameters just before building. - - Args: - action (str): The action being performed - strict (bool): If True, throws exceptions for missing required parameters. - If False, filters invalid parameters and only warns. - - Raises: - RequiredParameterMissingError: If required parameters are missing (when strict=True) - ParameterValidationError: If parameters are invalid (when strict=True) - """ - self._service_parameters = self._validator.validate_all_parameters( - self._service_parameters, action, strict=strict - ) - - def from_dict(self, data: Dict[str, Any]) -> 'PaymentBuilder': - """ - Populate the builder from a dictionary of parameters. - - Args: - data (Dict[str, Any]): Dictionary containing payment parameters - action (str): The action being performed (Pay, Authorize, Refund, etc.) - - Returns: - PaymentBuilder: Self for method chaining - - Supported keys: - - currency: Payment currency (e.g., 'EUR', 'USD') - - amount: Payment amount (float) - - description: Payment description (str) - - invoice: Invoice number (str) - - return_url: Success return URL (str) - - return_url_cancel: Cancel return URL (str) - - return_url_error: Error return URL (str) - - return_url_reject: Reject return URL (str) - - continue_on_incomplete: Continue on incomplete flag (str) - - client_ip: Client IP address (str or dict with 'address' and 'type') - - service_parameters: Additional service-specific parameters (dict) - """ - # Map dictionary keys to builder methods - if 'currency' in data: - self.currency(data['currency']) - - if 'amount' in data: - self.amount(data['amount']) - - if 'description' in data: - self.description(data['description']) - - if 'invoice' in data: - self.invoice(data['invoice']) - - if 'return_url' in data: - self.return_url(data['return_url']) - - if 'return_url_cancel' in data: - self.return_url_cancel(data['return_url_cancel']) - - if 'return_url_error' in data: - self.return_url_error(data['return_url_error']) - - if 'return_url_reject' in data: - self.return_url_reject(data['return_url_reject']) - - if 'continue_on_incomplete' in data: - self.continue_on_incomplete(data['continue_on_incomplete']) - - if 'push_url' in data: - self.push_url(data['push_url']) - if 'push_url_failure' in data: - self.push_url_failure(data['push_url_failure']) + """Payment-specific builder. All behavior inherited from BaseBuilder; + payment-specific overrides (when they arise) go here.""" - if 'client_ip' in data: - client_ip_data = data['client_ip'] - if isinstance(client_ip_data, str): - self.client_ip(client_ip_data) - elif isinstance(client_ip_data, dict): - address = client_ip_data.get('address', '0.0.0.0') - ip_type = client_ip_data.get('type', 0) - self.client_ip(address, ip_type) - - if 'service_parameters' in data: - service_params = data['service_parameters'] - - for key, value in service_params.items(): - if isinstance(value, dict): - for sub_key, sub_value in value.items(): - self.add_parameter(sub_key, sub_value, key) - else: - self.add_parameter(key, value) - - # Store the original payload for later use - self._payload = data.copy() - - return self - - - def required_fields(self, action: str = "Pay") -> Dict[str, Any]: - """ - Get the required fields for this payment method and action. - Can be overridden by specific payment builders to customize required fields based on action. - - Args: - action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) - - Returns: - Dict[str, Any]: Dictionary mapping field names to their current values - """ - return { - 'currency': self._currency, - 'amount_debit': self._amount_debit, - 'description': self._description, - 'invoice': self._invoice, - 'return_url': self._return_url, - 'return_url_cancel': self._return_url_cancel, - 'return_url_error': self._return_url_error, - 'return_url_reject': self._return_url_reject, - } - - def _validate_required_fields(self, action: str = "Pay") -> None: - """Validate that all required fields are set. - - Args: - action (str): The action being performed (Pay, Authorize, Refund, Capture, etc.) - """ - missing_fields = [field for field, value in self.required_fields(action).items() if value is None] - if missing_fields: - raise ValueError(f"Missing required fields: {', '.join(missing_fields)}") - - def build(self, action: str = "Pay", validate: bool = True, strict_validation: bool = False) -> PaymentRequest: - """Build the payment request. - - Args: - action (str): The action to perform (Pay, Authorize, Refund, etc.) - validate (bool): Whether to validate and filter service parameters - strict_validation (bool): If True, throws exceptions for missing required parameters. - If False, filters invalid parameters and only warns. - - Raises: - ValueError: If required payment fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - """ - self._validate_required_fields(action) - - # Validate and filter service parameters if enabled - if validate: - self._validate_and_filter_service_parameters(action, strict=strict_validation) - - # Create service with parameters - service = Service( - name=self.get_service_name(), - action=action, - parameters=self._service_parameters if self._service_parameters else None - ) - - # Create service list - service_list = ServiceList(services=[service]) - - # Build payment request - payment_request = PaymentRequest( - currency=self._currency, - amount_debit=self._amount_debit, - description=self._description, - invoice=self._invoice, - return_url=self._return_url, - return_url_cancel=self._return_url_cancel, - return_url_error=self._return_url_error, - return_url_reject=self._return_url_reject, - continue_on_incomplete=self._continue_on_incomplete, - push_url=self._push_url, - push_url_failure=self._push_url_failure, - client_ip=self._client_ip, - services=service_list - ) - - return payment_request - - def pay(self, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute the payment operation. - - Args: - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: Structured payment response object - - Raises: - ValueError: If required fields are missing - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - AuthenticationError: If authentication fails - BuckarooApiError: If API returns an error - """ - # Build the payment request - payment_request = self.build("Pay", validate=validate, strict_validation=strict_validation) - - # Convert to dictionary for API - request_data = payment_request.to_dict() - - return self._post_transaction(request_data) - - - def refund(self, validate: bool = True) -> PaymentResponse: - """ - Execute a refund transaction. - - Args: - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The refund response - - Raises: - ValueError: If required fields are missing - """ - # Get original_transaction_key from parameter or payload - txn_key = self._payload.get('originalTransactionKey') - if not txn_key: - raise ValueError("Original transaction key is required for refunds (provide as parameter or in payload)") - - # Get amount from parameter or payload - refund_amount = self._payload.get('refund_amount') - - # Build refund request with original transaction reference - payment_request = self.build('Refund', validate=validate) - - # Convert to dictionary and modify for refund - request_data = payment_request.to_dict() - request_data['OriginalTransactionKey'] = txn_key - - # Set refund amount if specified, otherwise use original amount - if refund_amount is not None: - request_data['AmountCredit'] = refund_amount - # Remove debit amount for refunds - if 'AmountDebit' in request_data: - del request_data['AmountDebit'] - else: - # Full refund - swap debit to credit - if 'AmountDebit' in request_data: - request_data['AmountCredit'] = request_data['AmountDebit'] - del request_data['AmountDebit'] - - return self._post_transaction(request_data) - - def capture(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None, validate: bool = True) -> PaymentResponse: - """ - Capture a previously authorized payment. - - Args: - original_transaction_key (str, optional): The transaction key of the authorization. - If None, will try to get from payload. - amount (float, optional): Amount to capture. If None, will try to get from payload - or capture the full authorized amount. - validate (bool): Whether to validate service parameters before building - - Returns: - PaymentResponse: The capture response - """ - # Get authorization key from parameter or payload - auth_key = original_transaction_key or self._payload.get('authorization_key') or self._payload.get('original_transaction_key') - if not auth_key: - raise ValueError("Authorization key is required for captures (provide as parameter or in payload)") - - # Get capture amount from parameter or payload - capture_amount = amount or self._payload.get('capture_amount') - - # Build capture request - payment_request = self.build('Capture', validate=validate) - request_data = payment_request.to_dict() - - # Set capture-specific parameters - request_data['OriginalTransactionKey'] = auth_key - - # Set capture amount if specified - if capture_amount is not None: - request_data['AmountDebit'] = capture_amount - - return self._post_transaction(request_data) - - def cancel(self, original_transaction_key: Optional[str] = None) -> PaymentResponse: - """ - Cancel a pending or authorized transaction. - - Args: - original_transaction_key (str, optional): The transaction key to cancel. - If None, will try to get from payload. - - Returns: - PaymentResponse: The cancellation response - """ - # Get transaction key from parameter or payload - txn_key = original_transaction_key or self._payload.get('cancel_key') or self._payload.get('original_transaction_key') - if not txn_key: - raise ValueError("Transaction key is required for cancellations (provide as parameter or in payload)") - - # Build cancel request - payment_request = self.build() - request_data = payment_request.to_dict() - - # Set cancellation parameters - request_data['OriginalTransactionKey'] = txn_key - # Remove amounts for cancellation - request_data.pop('AmountDebit', None) - request_data.pop('AmountCredit', None) - - return self._post_transaction(request_data) - - def partial_refund(self, original_transaction_key: Optional[str] = None, amount: Optional[float] = None) -> PaymentResponse: - """ - Execute a partial refund transaction. - - Args: - original_transaction_key (str, optional): The transaction key of the original payment. - If None, will try to get from payload. - amount (float, optional): Amount to refund. If None, will try to get from payload. - - Returns: - PaymentResponse: The partial refund response - - Raises: - ValueError: If amount is not provided or invalid - """ - # Get amount from parameter or payload - refund_amount = amount or self._payload.get('refund_amount') or self._payload.get('partial_refund_amount') - if not refund_amount or refund_amount <= 0: - raise ValueError("Partial refund amount must be greater than 0 (provide as parameter or in payload)") - - return self.refund(original_transaction_key, refund_amount) - - def _post_data_request(self, request_data: Dict[str, Any]) -> PaymentResponse: - """Helper method to post data request and handle response.""" - # Send to Buckaroo API - response = self._client.http_client.post('/json/DataRequest', request_data) - - # Check if response is valid and convert to dict - if response is None: - # Return a PaymentResponse with empty data for None responses - return PaymentResponse({}) - - # Return structured response object - return PaymentResponse(response.to_dict()) - - def _post_transaction(self, request_data: Dict[str, Any]) -> PaymentResponse: - """Helper method to post transaction and handle response.""" - # Send to Buckaroo API - response = self._client.http_client.post('/json/transaction', request_data) - - # Check if response is valid and convert to dict - if response is None: - # Return a PaymentResponse with empty data for None responses - return PaymentResponse({}) - - # Return structured response object - return PaymentResponse(response.to_dict()) - - def execute_action(self, action: str, validate: bool = True, strict_validation: bool = False) -> PaymentResponse: - """ - Execute a custom action for the payment method. - - This is a generic method that can be used for any action supported - by the payment method (instantRefund, payFastCheckout, etc.). - - Args: - action (str): The action to execute - validate (bool): Whether to validate service parameters before building - strict_validation (bool): If True, throws exceptions for missing required parameters - - Returns: - PaymentResponse: The action response - - Raises: - RequiredParameterMissingError: If required service parameters are missing (when strict_validation=True) - ParameterValidationError: If service parameters are invalid (when strict_validation=True) - """ - payment_request = self.build(action, validate=validate, strict_validation=strict_validation) - request_data = payment_request.to_dict() - return self._post_transaction(request_data) \ No newline at end of file + pass diff --git a/buckaroo/builders/payments/paypal_builder.py b/buckaroo/builders/payments/paypal_builder.py index c5ce29a..4c93d92 100644 --- a/buckaroo/builders/payments/paypal_builder.py +++ b/buckaroo/builders/payments/paypal_builder.py @@ -1,24 +1,49 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class PaypalBuilder(PaymentBuilder): """Builder for Paypal payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Paypal payments.""" return "paypal" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Paypal payments based on action.""" - + if action.lower() in ["pay"]: return { - "buyerEmail": {"type": str, "required": False, "description": "Buyer's email address."}, - "productName": {"type": str, "required": False, "description": "Name of the product."}, - "billingAgreementDescription": {"type": str, "required": False, "description": "Description of the billing agreement."}, - "pageStyle": {"type": str, "required": False, "description": "Style of the payment page."}, - "startrecurrent": {"type": str, "required": False, "description": "Start of recurrent payment."}, - "payPalOrderId": {"type": str, "required": False, "description": "PayPal order ID."}, + "buyerEmail": { + "type": str, + "required": False, + "description": "Buyer's email address.", + }, + "productName": { + "type": str, + "required": False, + "description": "Name of the product.", + }, + "billingAgreementDescription": { + "type": str, + "required": False, + "description": "Description of the billing agreement.", + }, + "pageStyle": { + "type": str, + "required": False, + "description": "Style of the payment page.", + }, + "startrecurrent": { + "type": str, + "required": False, + "description": "Start of recurrent payment.", + }, + "payPalOrderId": { + "type": str, + "required": False, + "description": "PayPal order ID.", + }, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/przelewy24_builder.py b/buckaroo/builders/payments/przelewy24_builder.py index b096331..f840ee8 100644 --- a/buckaroo/builders/payments/przelewy24_builder.py +++ b/buckaroo/builders/payments/przelewy24_builder.py @@ -1,21 +1,34 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class Przelewy24Builder(PaymentBuilder): """Builder for Przelewy24 payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Przelewy24 payments.""" return "przelewy24" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Przelewy24 payments based on action.""" - + if action.lower() in ["pay"]: return { - "customerEmail": {"type": str, "required": True, "description": "Customer email address"}, - "customerFirstName": {"type": str, "required": True, "description": "Customer first name"}, - "customerLastName": {"type": str, "required": True, "description": "Customer last name"}, + "customerEmail": { + "type": str, + "required": True, + "description": "Customer email address", + }, + "customerFirstName": { + "type": str, + "required": True, + "description": "Customer first name", + }, + "customerLastName": { + "type": str, + "required": True, + "description": "Customer last name", + }, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/riverty_builder.py b/buckaroo/builders/payments/riverty_builder.py index 2f65070..9e58aa4 100644 --- a/buckaroo/builders/payments/riverty_builder.py +++ b/buckaroo/builders/payments/riverty_builder.py @@ -1,21 +1,30 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class RivertyBuilder(PaymentBuilder): """Builder for Riverty payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Riverty payments.""" return "afterpay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Riverty payments based on action.""" - + if action.lower() in ["pay"]: return { - "billingCustomer": {"type": list, "required": True, "description": "Billing customer information"}, - "shippingCustomer": {"type": list, "required": True, "description": "Shipping customer information"}, + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, "article": {"type": list, "required": True, "description": "Riverty articles"}, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/sepadirectdebit_builder.py b/buckaroo/builders/payments/sepadirectdebit_builder.py index c622181..c99e474 100644 --- a/buckaroo/builders/payments/sepadirectdebit_builder.py +++ b/buckaroo/builders/payments/sepadirectdebit_builder.py @@ -1,26 +1,43 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class SepaDirectDebitBuilder(PaymentBuilder): """Builder for Sepa Direct Debit payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Sepa Direct Debit payments.""" return "SepaDirectDebit" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Sepa Direct Debit payments based on action.""" - + if action.lower() in ["pay"]: return { - "customeraccountname": {"type": str, "required": True, "description": "Customer account name"}, + "customeraccountname": { + "type": str, + "required": True, + "description": "Customer account name", + }, "customeriban": {"type": str, "required": True, "description": "Customer IBAN"}, "customerbic": {"type": str, "required": False, "description": "Customer BIC"}, "collectdate": {"type": str, "required": False, "description": "Collect date"}, - "mandateReference": {"type": str, "required": False, "description": "Mandate reference"}, + "mandateReference": { + "type": str, + "required": False, + "description": "Mandate reference", + }, "mandateDate": {"type": str, "required": False, "description": "Mandate date"}, - "startRecurrent": {"type": str, "required": False, "description": "Start recurrent"}, - "electronicSignature": {"type": str, "required": False, "description": "Electronic signature"}, + "startRecurrent": { + "type": str, + "required": False, + "description": "Start recurrent", + }, + "electronicSignature": { + "type": str, + "required": False, + "description": "Electronic signature", + }, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/sofort_builder.py b/buckaroo/builders/payments/sofort_builder.py index 0b583ce..bd71b5f 100644 --- a/buckaroo/builders/payments/sofort_builder.py +++ b/buckaroo/builders/payments/sofort_builder.py @@ -2,23 +2,35 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder from .capabilities.bank_transfer_capabilities import BankTransferCapabilities -from ...models.payment_response import PaymentResponse + class SofortBuilder(PaymentBuilder, BankTransferCapabilities): """Builder for Sofort payments with bank transfer capabilities.""" - + def get_service_name(self) -> str: """Get the service name for Sofort payments.""" return "sofort" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Sofort payments based on action.""" - + if action.lower() in ["pay", "payfastcheckout"]: return { - "countrycode": {"type": str, "required": False, "description": "Sofort country code"}, - "savetoken": {"type": (str, bool), "required": False, "description": "Save payment token for future use"}, - "isrecurring": {"type": (str, bool), "required": False, "description": "Recurring payment flag"}, + "countrycode": { + "type": str, + "required": False, + "description": "Sofort country code", + }, + "savetoken": { + "type": (str, bool), + "required": False, + "description": "Save payment token for future use", + }, + "isrecurring": { + "type": (str, bool), + "required": False, + "description": "Recurring payment flag", + }, } elif action.lower() == "instantrefund": # Instant refund has different requirements @@ -29,49 +41,52 @@ def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: else: # Default to Pay action parameters return { - "countrycode": {"type": str, "required": False, "description": "Sofort country code"}, - "savetoken": {"type": (str, bool), "required": False, "description": "Save payment token for future use"}, - "isrecurring": {"type": (str, bool), "required": False, "description": "Recurring payment flag"}, + "countrycode": { + "type": str, + "required": False, + "description": "Sofort country code", + }, + "savetoken": { + "type": (str, bool), + "required": False, + "description": "Save payment token for future use", + }, + "isrecurring": { + "type": (str, bool), + "required": False, + "description": "Recurring payment flag", + }, } - - def country_code(self, country_code: str) -> 'SofortBuilder': + + def country_code(self, country_code: str) -> "SofortBuilder": """Set the Sofort country code.""" return self.add_parameter("countrycode", country_code) - - def from_dict(self, data: Dict[str, Any]) -> 'SofortBuilder': + + def from_dict(self, data: Dict[str, Any]) -> "SofortBuilder": """ Populate the Sofort builder from a dictionary of parameters. - + Args: data (Dict[str, Any]): Dictionary containing payment parameters - + Returns: SofortBuilder: Self for method chaining - + Additional Sofort-specific keys: - country_code: Sofort country code (str) """ # Call parent from_dict first super().from_dict(data) - + # Handle Sofort-specific parameters - if 'country_code' in data: - self.country_code(data['country_code']) - + if "country_code" in data: + self.country_code(data["country_code"]) + return self - + # Bank transfer capabilities (inherited from BankTransferCapabilities): - # - instant_refund() - # - pay_fast_checkout() - # + # - instantRefund() + # - payFastCheckout() + # # Standard methods (inherited from PaymentBuilder): # - pay(), refund(), capture(), cancel(), execute_action() - - # Optional: Create aliases with method names for consistency - def payFastCheckout(self, validate: bool = True) -> PaymentResponse: - """Enable PayFast Checkout for Sofort payments.""" - return self.pay_fast_checkout(validate=validate) - - def instantRefund(self, validate: bool = True) -> PaymentResponse: - """Initiate an instant refund for Sofort payments.""" - return self.instant_refund(validate=validate) \ No newline at end of file diff --git a/buckaroo/builders/payments/swish_builder.py b/buckaroo/builders/payments/swish_builder.py index 6681e0d..04355bc 100644 --- a/buckaroo/builders/payments/swish_builder.py +++ b/buckaroo/builders/payments/swish_builder.py @@ -1,19 +1,18 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class SwishBuilder(PaymentBuilder): """Builder for Swish payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Swish payments.""" return "Swish" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Swish payments based on action.""" - - if action.lower() in ["pay"]: - return { - } + if action.lower() in ["pay"]: + return {} - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/transfer_builder.py b/buckaroo/builders/payments/transfer_builder.py index 265836e..4fccfeb 100644 --- a/buckaroo/builders/payments/transfer_builder.py +++ b/buckaroo/builders/payments/transfer_builder.py @@ -1,25 +1,54 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class TransferBuilder(PaymentBuilder): """Builder for Transfer payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Transfer payments.""" return "Transfer" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Transfer payments based on action.""" - + if action.lower() in ["pay"]: return { - "customeremail": {"type": str, "required": True, "description": "Customer email address"}, - "customerfirstname": {"type": str, "required": True, "description": "Customer first name"}, - "customerlastname": {"type": str, "required": True, "description": "Customer last name"}, - "customergender": {"type": str, "required": False, "description": "Customer gender"}, - "sendmail": {"type": bool, "required": False, "description": "Send email to customer"}, - "dateDue": {"type": str, "required": False, "description": "Due date for the transfer"}, - "customerCountry": {"type": str, "required": False, "description": "Customer country code"}, + "customeremail": { + "type": str, + "required": True, + "description": "Customer email address", + }, + "customerfirstname": { + "type": str, + "required": True, + "description": "Customer first name", + }, + "customerlastname": { + "type": str, + "required": True, + "description": "Customer last name", + }, + "customergender": { + "type": str, + "required": False, + "description": "Customer gender", + }, + "sendmail": { + "type": bool, + "required": False, + "description": "Send email to customer", + }, + "dateDue": { + "type": str, + "required": False, + "description": "Due date for the transfer", + }, + "customerCountry": { + "type": str, + "required": False, + "description": "Customer country code", + }, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/trustly_builder.py b/buckaroo/builders/payments/trustly_builder.py index 155d939..b0bec15 100644 --- a/buckaroo/builders/payments/trustly_builder.py +++ b/buckaroo/builders/payments/trustly_builder.py @@ -1,22 +1,35 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class TrustlyBuilder(PaymentBuilder): """Builder for Trustly payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Trustly payments.""" return "Trustly" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Trustly payments based on action.""" - + if action.lower() in ["pay"]: return { - "customerFirstName": {"type": str, "required": True, "description": "Customer first name"}, - "customerLastName": {"type": str, "required": True, "description": "Customer last name"}, - "customerCountryCode": {"type": str, "required": True, "description": "Customer country code"}, - "consumeremail": {"type": str, "required": True, "description": "Customer email"} + "customerFirstName": { + "type": str, + "required": True, + "description": "Customer first name", + }, + "customerLastName": { + "type": str, + "required": True, + "description": "Customer last name", + }, + "customerCountryCode": { + "type": str, + "required": True, + "description": "Customer country code", + }, + "consumeremail": {"type": str, "required": True, "description": "Customer email"}, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/twint_builder.py b/buckaroo/builders/payments/twint_builder.py index 8f11c26..62cf8f6 100644 --- a/buckaroo/builders/payments/twint_builder.py +++ b/buckaroo/builders/payments/twint_builder.py @@ -1,18 +1,18 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class TwintBuilder(PaymentBuilder): """Builder for Twint payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Twint payments.""" return "Twint" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Twint payments based on action.""" - + if action.lower() in ["pay"]: - return { - } + return {} - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/voucher_builder.py b/buckaroo/builders/payments/voucher_builder.py index 7a8a884..cff16ee 100644 --- a/buckaroo/builders/payments/voucher_builder.py +++ b/buckaroo/builders/payments/voucher_builder.py @@ -1,19 +1,20 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class VoucherBuilder(PaymentBuilder): """Builder for Voucher payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Voucher payments.""" - return self._payload.get('voucher_name', 'Vouchers') - + return self._payload.get("voucher_name", "Vouchers") + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Voucher payments based on action.""" - + if action.lower() in ["pay"]: return { "article": {"type": list, "required": True, "description": "Articles"}, } - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/wechatpay_builder.py b/buckaroo/builders/payments/wechatpay_builder.py index 47c0bf7..2d72878 100644 --- a/buckaroo/builders/payments/wechatpay_builder.py +++ b/buckaroo/builders/payments/wechatpay_builder.py @@ -1,18 +1,18 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class WeChatPayBuilder(PaymentBuilder): """Builder for WeChatPay payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for WeChatPay payments.""" return "WeChatPay" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for WeChatPay payments based on action.""" - + if action.lower() in ["pay"]: - return { - } + return {} - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/payments/wero_builder.py b/buckaroo/builders/payments/wero_builder.py index 3cb5ec8..37cc1b0 100644 --- a/buckaroo/builders/payments/wero_builder.py +++ b/buckaroo/builders/payments/wero_builder.py @@ -1,18 +1,18 @@ from typing import Dict, Any from .payment_builder import PaymentBuilder + class WeroBuilder(PaymentBuilder): """Builder for Wero payments with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Wero payments.""" return "Wero" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Wero payments based on action.""" - + if action.lower() in ["pay"]: - return { - } + return {} - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/solutions/default_builder.py b/buckaroo/builders/solutions/default_builder.py index fb88cda..a140011 100644 --- a/buckaroo/builders/solutions/default_builder.py +++ b/buckaroo/builders/solutions/default_builder.py @@ -1,17 +1,15 @@ - - - from typing import Dict, Any from .solution_builder import SolutionBuilder + class DefaultBuilder(SolutionBuilder): """Builder for Default payments.""" def get_service_name(self) -> str: """Get the service name for Default payments.""" # Try to get method from payload, fallback to 'Unknown' if not available - return self._payload.get('method', 'Unknown') - + return self._payload.get("method", "Unknown") + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Default payments based on action.""" diff --git a/buckaroo/builders/solutions/solution_builder.py b/buckaroo/builders/solutions/solution_builder.py index 9b8db2d..3c062a1 100644 --- a/buckaroo/builders/solutions/solution_builder.py +++ b/buckaroo/builders/solutions/solution_builder.py @@ -4,15 +4,15 @@ class SolutionBuilder(BaseBuilder): """Abstract base class for solution builders.""" - + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: """ Override to return empty dict as solutions typically have no required fields. - + Args: action (str): The action being performed - + Returns: Dict[str, Any]: Empty dictionary (no required fields for solutions) """ - return {} \ No newline at end of file + return {} diff --git a/buckaroo/builders/solutions/subscription_builder.py b/buckaroo/builders/solutions/subscription_builder.py index 9f8d363..2d86b6e 100644 --- a/buckaroo/builders/solutions/subscription_builder.py +++ b/buckaroo/builders/solutions/subscription_builder.py @@ -1,26 +1,25 @@ from typing import Dict, Any from .solution_builder import SolutionBuilder + class SubscriptionBuilder(SolutionBuilder): """Builder for Subscription solutions with bank transfer capabilities.""" def get_service_name(self) -> str: """Get the service name for Subscription solutions.""" return "Subscription" - + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: """Get the allowed service parameters for Subscription based on action.""" - + if action.lower() in ["pay"]: - return { - } + return {} return {} - - def createSubscription(self: 'SolutionBuilder', validate: bool = True) -> Any: + def createSubscription(self: "SolutionBuilder", validate: bool = True) -> Any: """Create a subscription.""" payload = self.build("CreateSubscription", validate=validate) request_data = payload.to_dict() - - return self._post_data_request(request_data) \ No newline at end of file + + return self._post_data_request(request_data) diff --git a/buckaroo/config/buckaroo_config.py b/buckaroo/config/buckaroo_config.py index 5999d73..0f03d20 100644 --- a/buckaroo/config/buckaroo_config.py +++ b/buckaroo/config/buckaroo_config.py @@ -12,12 +12,14 @@ class Environment(Enum): """Buckaroo API environment options.""" + TEST = "test" LIVE = "live" class ApiVersion(Enum): """Supported Buckaroo API versions.""" + V1 = "v1" V2 = "v2" @@ -26,10 +28,10 @@ class ApiVersion(Enum): class BuckarooConfig: """ Configuration class for Buckaroo SDK. - + This class manages all configuration settings for the Buckaroo SDK, including API endpoints, timeouts, retry logic, and authentication settings. - + Attributes: environment (Environment): The API environment (test/live). api_version (ApiVersion): The API version to use. @@ -41,7 +43,7 @@ class BuckarooConfig: custom_endpoint (Optional[str]): Custom API endpoint URL. user_agent (str): User agent string for HTTP requests. max_redirects (int): Maximum number of HTTP redirects to follow. - + Example: >>> config = BuckarooConfig( ... environment=Environment.LIVE, @@ -50,7 +52,7 @@ class BuckarooConfig: ... ) >>> client = BuckarooClient("store_key", "secret_key", config=config) """ - + environment: Environment = Environment.TEST api_version: ApiVersion = ApiVersion.V1 timeout: int = 30 @@ -61,70 +63,70 @@ class BuckarooConfig: custom_endpoint: Optional[str] = None user_agent: str = "BuckarooSDK-Python/1.0.0" max_redirects: int = 5 - + def __post_init__(self): """Validate configuration after initialization.""" self._validate_config() - + def _validate_config(self) -> None: """ Validate configuration parameters. - + Raises: ValueError: If configuration parameters are invalid. """ if self.timeout <= 0: raise ValueError("Timeout must be greater than 0") - + if self.retry_attempts < 0: raise ValueError("Retry attempts must be 0 or greater") - + if self.retry_delay < 0: raise ValueError("Retry delay must be 0 or greater") - + if self.max_redirects < 0: raise ValueError("Max redirects must be 0 or greater") - + @property def api_endpoint(self) -> str: """ Get the API endpoint URL based on environment. - + Returns: str: The API endpoint URL. """ if self.custom_endpoint: return self.custom_endpoint - + if self.environment == Environment.TEST: return "https://testcheckout.buckaroo.nl" else: # LIVE return "https://checkout.buckaroo.nl" - + @property def is_test_environment(self) -> bool: """ Check if currently in test environment. - + Returns: bool: True if in test environment, False if live. """ return self.environment == Environment.TEST - + @property def is_live_environment(self) -> bool: """ Check if currently in live environment. - + Returns: bool: True if in live environment, False if test. """ return self.environment == Environment.LIVE - + def get_request_headers(self) -> Dict[str, str]: """ Get default HTTP headers for API requests. - + Returns: Dict[str, str]: Dictionary of HTTP headers. """ @@ -133,11 +135,11 @@ def get_request_headers(self) -> Dict[str, str]: "Accept": "application/json", "User-Agent": self.user_agent, } - + def to_dict(self) -> Dict[str, Any]: """ Convert configuration to dictionary. - + Returns: Dict[str, Any]: Configuration as dictionary. """ @@ -156,15 +158,15 @@ def to_dict(self) -> Dict[str, Any]: "is_test": self.is_test_environment, "is_live": self.is_live_environment, } - + @classmethod - def from_dict(cls, config_dict: Dict[str, Any]) -> 'BuckarooConfig': + def from_dict(cls, config_dict: Dict[str, Any]) -> "BuckarooConfig": """ Create configuration from dictionary. - + Args: config_dict (Dict[str, Any]): Configuration dictionary. - + Returns: BuckarooConfig: New configuration instance. """ @@ -172,31 +174,38 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> 'BuckarooConfig': if "environment" in config_dict: if isinstance(config_dict["environment"], str): config_dict["environment"] = Environment(config_dict["environment"]) - + if "api_version" in config_dict: if isinstance(config_dict["api_version"], str): config_dict["api_version"] = ApiVersion(config_dict["api_version"]) - + # Filter out extra keys valid_keys = { - "environment", "api_version", "timeout", "retry_attempts", - "retry_delay", "logging_enabled", "verify_ssl", "custom_endpoint", - "user_agent", "max_redirects" + "environment", + "api_version", + "timeout", + "retry_attempts", + "retry_delay", + "logging_enabled", + "verify_ssl", + "custom_endpoint", + "user_agent", + "max_redirects", } filtered_dict = {k: v for k, v in config_dict.items() if k in valid_keys} - + return cls(**filtered_dict) - - def copy(self, **changes) -> 'BuckarooConfig': + + def copy(self, **changes) -> "BuckarooConfig": """ Create a copy of the configuration with optional changes. - + Args: **changes: Configuration parameters to change. - + Returns: BuckarooConfig: New configuration instance with changes applied. - + Example: >>> new_config = config.copy(timeout=60, environment=Environment.LIVE) """ @@ -208,56 +217,51 @@ def copy(self, **changes) -> 'BuckarooConfig': class DefaultConfig(BuckarooConfig): """ Default configuration for Buckaroo SDK. - + This class provides sensible defaults for most use cases. """ + pass class TestConfig(BuckarooConfig): - """ - Configuration optimized for testing. - - This configuration uses test environment with more aggressive timeouts - and retries for faster test execution. - """ - - def __init__(self): - super().__init__( - environment=Environment.TEST, + """Test preset: short timeouts, no logging. Environment locked to TEST.""" + + def __init__(self, **overrides): + defaults = dict( timeout=10, retry_attempts=1, retry_delay=0.5, - logging_enabled=False + logging_enabled=False, ) + defaults.update(overrides) + defaults["environment"] = Environment.TEST + super().__init__(**defaults) class ProductionConfig(BuckarooConfig): - """ - Configuration optimized for production use. - - This configuration uses live environment with conservative timeouts - and retry settings for production reliability. - """ - - def __init__(self): - super().__init__( - environment=Environment.LIVE, + """Production preset: conservative timeouts, logging on. Environment locked to LIVE.""" + + def __init__(self, **overrides): + defaults = dict( timeout=60, retry_attempts=5, retry_delay=2.0, logging_enabled=True, - verify_ssl=True + verify_ssl=True, ) + defaults.update(overrides) + defaults["environment"] = Environment.LIVE + super().__init__(**defaults) class ConfigBuilder: """ Builder class for creating Buckaroo configurations. - + This class provides a fluent interface for building configurations with method chaining. - + Example: >>> config = (ConfigBuilder() ... .environment(Environment.LIVE) @@ -266,82 +270,82 @@ class ConfigBuilder: ... .enable_logging() ... .build()) """ - + def __init__(self): self._config_dict = {} - - def environment(self, env: Environment) -> 'ConfigBuilder': + + def environment(self, env: Environment) -> "ConfigBuilder": """Set the environment.""" self._config_dict["environment"] = env return self - - def test_environment(self) -> 'ConfigBuilder': + + def test_environment(self) -> "ConfigBuilder": """Set test environment.""" return self.environment(Environment.TEST) - - def live_environment(self) -> 'ConfigBuilder': + + def live_environment(self) -> "ConfigBuilder": """Set live environment.""" return self.environment(Environment.LIVE) - - def api_version(self, version: ApiVersion) -> 'ConfigBuilder': + + def api_version(self, version: ApiVersion) -> "ConfigBuilder": """Set the API version.""" self._config_dict["api_version"] = version return self - - def timeout(self, seconds: int) -> 'ConfigBuilder': + + def timeout(self, seconds: int) -> "ConfigBuilder": """Set request timeout.""" self._config_dict["timeout"] = seconds return self - - def retry_attempts(self, attempts: int) -> 'ConfigBuilder': + + def retry_attempts(self, attempts: int) -> "ConfigBuilder": """Set retry attempts.""" self._config_dict["retry_attempts"] = attempts return self - - def retry_delay(self, delay: float) -> 'ConfigBuilder': + + def retry_delay(self, delay: float) -> "ConfigBuilder": """Set retry delay.""" self._config_dict["retry_delay"] = delay return self - - def enable_logging(self) -> 'ConfigBuilder': + + def enable_logging(self) -> "ConfigBuilder": """Enable logging.""" self._config_dict["logging_enabled"] = True return self - - def disable_logging(self) -> 'ConfigBuilder': + + def disable_logging(self) -> "ConfigBuilder": """Disable logging.""" self._config_dict["logging_enabled"] = False return self - - def enable_ssl_verification(self) -> 'ConfigBuilder': + + def enable_ssl_verification(self) -> "ConfigBuilder": """Enable SSL verification.""" self._config_dict["verify_ssl"] = True return self - - def disable_ssl_verification(self) -> 'ConfigBuilder': + + def disable_ssl_verification(self) -> "ConfigBuilder": """Disable SSL verification (not recommended for production).""" self._config_dict["verify_ssl"] = False return self - - def custom_endpoint(self, endpoint: str) -> 'ConfigBuilder': + + def custom_endpoint(self, endpoint: str) -> "ConfigBuilder": """Set custom API endpoint.""" self._config_dict["custom_endpoint"] = endpoint return self - - def user_agent(self, agent: str) -> 'ConfigBuilder': + + def user_agent(self, agent: str) -> "ConfigBuilder": """Set custom user agent.""" self._config_dict["user_agent"] = agent return self - - def max_redirects(self, redirects: int) -> 'ConfigBuilder': + + def max_redirects(self, redirects: int) -> "ConfigBuilder": """Set maximum redirects.""" self._config_dict["max_redirects"] = redirects return self - + def build(self) -> BuckarooConfig: """ Build the configuration. - + Returns: BuckarooConfig: The built configuration. """ @@ -352,10 +356,10 @@ def build(self) -> BuckarooConfig: def create_test_config(**kwargs) -> BuckarooConfig: """ Create a test configuration with optional overrides. - + Args: **kwargs: Configuration overrides. - + Returns: BuckarooConfig: Test configuration. """ @@ -368,10 +372,10 @@ def create_test_config(**kwargs) -> BuckarooConfig: def create_production_config(**kwargs) -> BuckarooConfig: """ Create a production configuration with optional overrides. - + Args: **kwargs: Configuration overrides. - + Returns: BuckarooConfig: Production configuration. """ @@ -384,10 +388,10 @@ def create_production_config(**kwargs) -> BuckarooConfig: def create_config_from_mode(mode: str) -> BuckarooConfig: """ Create configuration from mode string (for backward compatibility). - + Args: mode (str): Mode string ("test" or "live"). - + Returns: BuckarooConfig: Configuration for the specified mode. """ @@ -396,4 +400,4 @@ def create_config_from_mode(mode: str) -> BuckarooConfig: elif mode.lower() == "live": return create_production_config() else: - raise ValueError(f"Invalid mode: {mode}. Must be 'test' or 'live'.") \ No newline at end of file + raise ValueError(f"Invalid mode: {mode}. Must be 'test' or 'live'.") diff --git a/buckaroo/exceptions/_authentication_error.py b/buckaroo/exceptions/_authentication_error.py index 522a7b9..1e52d00 100644 --- a/buckaroo/exceptions/_authentication_error.py +++ b/buckaroo/exceptions/_authentication_error.py @@ -1,4 +1,5 @@ from ._buckaroo_error import BuckarooError + class AuthenticationError(BuckarooError): - pass \ No newline at end of file + pass diff --git a/buckaroo/exceptions/_buckaroo_error.py b/buckaroo/exceptions/_buckaroo_error.py index c22cc2b..89dbd36 100644 --- a/buckaroo/exceptions/_buckaroo_error.py +++ b/buckaroo/exceptions/_buckaroo_error.py @@ -1,4 +1,5 @@ -from typing import Dict, Optional +from typing import Any, Dict, Optional + class BuckarooError(Exception): _message: Optional[str] @@ -8,4 +9,4 @@ class BuckarooError(Exception): headers: Optional[Dict[str, str]] code: Optional[str] request_id: Optional[str] - error: Optional["ErrorObject"] + error: Optional[Any] diff --git a/buckaroo/exceptions/_parameter_validation_error.py b/buckaroo/exceptions/_parameter_validation_error.py index ca4a530..8bc2af3 100644 --- a/buckaroo/exceptions/_parameter_validation_error.py +++ b/buckaroo/exceptions/_parameter_validation_error.py @@ -7,12 +7,18 @@ class ParameterValidationError(BuckarooError): """Exception raised when parameter validation fails.""" - - def __init__(self, message: str, parameter_name: str = None, expected_type: str = None, - action: str = None, service_name: str = None): + + def __init__( + self, + message: str, + parameter_name: str = None, + expected_type: str = None, + action: str = None, + service_name: str = None, + ): """ Initialize parameter validation error. - + Args: message (str): Error message parameter_name (str, optional): Name of the parameter that failed validation @@ -26,7 +32,7 @@ def __init__(self, message: str, parameter_name: str = None, expected_type: str self.action = action self.service_name = service_name self._message = message - + def __str__(self): """Return string representation of the error.""" return self._message @@ -34,23 +40,20 @@ def __str__(self): class RequiredParameterMissingError(ParameterValidationError): """Exception raised when a required parameter is missing.""" - + def __init__(self, parameter_name: str, action: str = None, service_name: str = None): """ Initialize required parameter missing error. - + Args: parameter_name (str): Name of the missing required parameter action (str, optional): Action being performed service_name (str, optional): Service name """ - service_info = f" for {service_name}" if service_name else "" - action_info = f" {action} action" if action else "" - message = f"Required parameter '{parameter_name}' is missing{service_info}{action_info}" - + parts = [p for p in (service_name, f"{action} action" if action else None) if p] + qualifier = f" for {' '.join(parts)}" if parts else "" + message = f"Required parameter '{parameter_name}' is missing{qualifier}" + super().__init__( - message=message, - parameter_name=parameter_name, - action=action, - service_name=service_name - ) \ No newline at end of file + message=message, parameter_name=parameter_name, action=action, service_name=service_name + ) diff --git a/buckaroo/factories/__init__.py b/buckaroo/factories/__init__.py index b2b482f..9700531 100644 --- a/buckaroo/factories/__init__.py +++ b/buckaroo/factories/__init__.py @@ -3,4 +3,4 @@ from .solution_method_factory import SolutionMethodFactory from .builder_factory import BuilderFactory -__all__ = ['PaymentMethodFactory', 'SolutionMethodFactory', 'BuilderFactory'] +__all__ = ["PaymentMethodFactory", "SolutionMethodFactory", "BuilderFactory"] diff --git a/buckaroo/factories/builder_factory.py b/buckaroo/factories/builder_factory.py index c198378..627e3da 100644 --- a/buckaroo/factories/builder_factory.py +++ b/buckaroo/factories/builder_factory.py @@ -1,77 +1,77 @@ from abc import ABC, abstractmethod -from typing import Dict, Type, Any +from typing import Dict, Type class BuilderFactory(ABC): """Abstract base class for builder factories.""" - + @classmethod @abstractmethod def create_builder(cls, method: str, client): """ Create a builder for the specified method. - + Args: method (str): The method name (e.g., 'ideal', 'subscription') client: The Buckaroo client instance - + Returns: Builder instance for the specified method - + Raises: ValueError: If the method is not supported """ pass - + @classmethod @abstractmethod def register_method(cls, method: str, builder_class: Type) -> None: """ Register a new method builder. - + Args: method (str): The method name builder_class (Type): The builder class for this method """ pass - + @classmethod @abstractmethod def get_available_methods(cls) -> list: """ Get a list of all available methods. - + Returns: list: List of available method names """ pass - + @classmethod @abstractmethod def is_method_supported(cls, method: str) -> bool: """ Check if a method is supported. - + Args: method (str): The method name - + Returns: bool: True if the method is supported, False otherwise """ pass - + @classmethod @abstractmethod def detect_method_from_payload(cls, payload: Dict) -> str: """ Detect the method from payload parameters. - + Args: payload (Dict): Parameters dictionary - + Returns: str: Detected method name - + Raises: ValueError: If method cannot be determined from payload """ diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index 725352c..c040d52 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -1,4 +1,4 @@ -from typing import Dict, Type, Any +from typing import Dict, Type import logging from .builder_factory import BuilderFactory @@ -42,9 +42,10 @@ from buckaroo.builders.payments.paypal_builder import PaypalBuilder from buckaroo.builders.payments.paybybank_builder import PayByBankBuilder + class PaymentMethodFactory(BuilderFactory): """Factory for creating payment method builders.""" - + # Registry of available payment methods _payment_methods: Dict[str, Type[PaymentBuilder]] = { "alipay": AlipayBuilder, @@ -58,7 +59,7 @@ class PaymentMethodFactory(BuilderFactory): "clicktopay": ClickToPayBuilder, "creditcard": CreditcardBuilder, "default": DefaultBuilder, - "externalPayment": ExternalPaymentBuilder, + "externalpayment": ExternalPaymentBuilder, "eps": EpsBuilder, "giftcards": GiftcardsBuilder, "googlepay": GooglePayBuilder, @@ -86,24 +87,24 @@ class PaymentMethodFactory(BuilderFactory): "wechatpay": WeChatPayBuilder, "wero": WeroBuilder, } - + @classmethod def create_builder(cls, method: str, client) -> PaymentBuilder: """ Create a payment builder for the specified method. - + Args: method (str): The payment method name (e.g., 'ideal', 'creditcard', 'paypal') client: The Buckaroo client instance - + Returns: PaymentBuilder: A builder instance for the specified payment method - + Raises: ValueError: If the payment method is not supported """ method = method.lower() - + if method not in cls._payment_methods: available_methods = ", ".join(cls._payment_methods.keys()) logging.warning( @@ -113,76 +114,76 @@ def create_builder(cls, method: str, client) -> PaymentBuilder: ) # Use DefaultBuilder as fallback return DefaultBuilder(client) - + builder_class = cls._payment_methods[method] return builder_class(client) - + @classmethod def register_method(cls, method: str, builder_class: Type[PaymentBuilder]) -> None: """ Register a new payment method builder. - + Args: method (str): The payment method name builder_class (Type[PaymentBuilder]): The builder class for this method """ cls._payment_methods[method.lower()] = builder_class - + @classmethod def get_available_methods(cls) -> list: """ Get a list of all available payment methods. - + Returns: list: List of available payment method names """ return list(cls._payment_methods.keys()) - + @classmethod def is_method_supported(cls, method: str) -> bool: """ Check if a payment method is supported. - + Args: method (str): The payment method name - + Returns: bool: True if the method is supported, False otherwise """ return method.lower() in cls._payment_methods - + @classmethod def detect_method_from_payload(cls, payload: Dict) -> str: """ Detect the payment method from payload parameters. - + Args: payload (Dict): Payment parameters dictionary - + Returns: str: Detected payment method name - + Raises: ValueError: If payment method cannot be determined from payload """ # Check for explicit payment method in payload - if 'method' in payload: - return payload['method'].lower() - + if "method" in payload: + return payload["method"].lower() + # Check Services.ServiceList for payment method detection - services = payload.get('Services', {}) - service_list = services.get('ServiceList', []) - + services = payload.get("Services", {}) + service_list = services.get("ServiceList", []) + if service_list: for service in service_list: - service_name = service.get('Name', '').lower() + service_name = service.get("Name", "").lower() if service_name in cls._payment_methods: return service_name - + # Default fallback - could be configurable logging.warning( "Cannot determine payment method from payload. " "Please include 'method' or specify service in Services.ServiceList. " "Using 'default' as fallback method." ) - return 'default' \ No newline at end of file + return "default" diff --git a/buckaroo/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py index cd60670..afd0303 100644 --- a/buckaroo/factories/solution_method_factory.py +++ b/buckaroo/factories/solution_method_factory.py @@ -1,4 +1,4 @@ -from typing import Dict, Type, Any +from typing import Dict, Type import logging from .builder_factory import BuilderFactory @@ -6,31 +6,32 @@ from buckaroo.builders.solutions.default_builder import DefaultBuilder from buckaroo.builders.solutions.solution_builder import SolutionBuilder + class SolutionMethodFactory(BuilderFactory): """Factory for creating payment method builders.""" - + # Registry of available solution methods _solution_methods: Dict[str, Type[SolutionBuilder]] = { "subscription": SubscriptionBuilder, } - + @classmethod def create_builder(cls, method: str, client) -> SolutionBuilder: """ Create a payment builder for the specified method. - + Args: method (str): The payment method name (e.g., 'subscriptions', 'creditmanagement') client: The Buckaroo client instance - + Returns: SolutionBuilder: A builder instance for the specified payment method - + Raises: ValueError: If the payment method is not supported """ method = method.lower() - + if method not in cls._solution_methods: available_methods = ", ".join(cls._solution_methods.keys()) logging.warning( @@ -40,60 +41,60 @@ def create_builder(cls, method: str, client) -> SolutionBuilder: ) # Use DefaultBuilder as fallback return DefaultBuilder(client) - + builder_class = cls._solution_methods[method] return builder_class(client) - + @classmethod def register_method(cls, method: str, builder_class: Type[SolutionBuilder]) -> None: """ Register a new solution method builder. - + Args: method (str): The solution method name builder_class (Type[PaymentBuilder]): The builder class for this method """ cls._solution_methods[method.lower()] = builder_class - + @classmethod def get_available_methods(cls) -> list: """ Get a list of all available solution methods. - + Returns: list: List of available solution method names """ return list(cls._solution_methods.keys()) - + @classmethod def is_method_supported(cls, method: str) -> bool: """ Check if a solution method is supported. - + Args: method (str): The solution method name - + Returns: bool: True if the method is supported, False otherwise """ return method.lower() in cls._solution_methods - + @classmethod def detect_method_from_payload(cls, payload: Dict) -> str: """ Detect the payment method from payload parameters. - + Args: payload (Dict): Payment parameters dictionary - + Returns: str: Detected payment method name - + Raises: ValueError: If payment method cannot be determined from payload """ # Check for explicit payment method in payload - if 'method' in payload: - return payload['method'].lower() + if "method" in payload: + return payload["method"].lower() - return 'default' \ No newline at end of file + return "default" diff --git a/buckaroo/http/__init__.py b/buckaroo/http/__init__.py index 89a7bbd..3607e6b 100644 --- a/buckaroo/http/__init__.py +++ b/buckaroo/http/__init__.py @@ -6,8 +6,4 @@ from .client import BuckarooHttpClient, BuckarooResponse, BuckarooApiError -__all__ = [ - 'BuckarooHttpClient', - 'BuckarooResponse', - 'BuckarooApiError' -] \ No newline at end of file +__all__ = ["BuckarooHttpClient", "BuckarooResponse", "BuckarooApiError"] diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py index 8faae7a..3c2073e 100644 --- a/buckaroo/http/client.py +++ b/buckaroo/http/client.py @@ -22,220 +22,216 @@ class BuckarooHttpClient: """ HTTP client for Buckaroo API communication. - + This class handles all HTTP communication with the Buckaroo API, including: - HMAC authentication - Request/response handling - Retry logic - Error handling - + Uses a strategy pattern to support different HTTP implementations (requests library, curl command, etc.). """ - + def __init__( - self, - store_key: str, - secret_key: str, + self, + store_key: str, + secret_key: str, config: BuckarooConfig, - http_strategy: Optional[str] = None + http_strategy: Optional[str] = None, ): self.store_key = store_key self.secret_key = secret_key self.config = config - + # Create HTTP strategy self.http_strategy = HttpStrategyFactory.create_strategy(http_strategy) self._configure_strategy() - + def _configure_strategy(self) -> None: """Configure the HTTP strategy with Buckaroo-specific settings.""" strategy_config = { - 'timeout': self.config.timeout, - 'verify_ssl': self.config.verify_ssl, - 'retry_attempts': self.config.retry_attempts, - 'retry_delay': self.config.retry_delay, - 'default_headers': self.config.get_request_headers() + "timeout": self.config.timeout, + "verify_ssl": self.config.verify_ssl, + "retry_attempts": self.config.retry_attempts, + "retry_delay": self.config.retry_delay, + "default_headers": self.config.get_request_headers(), } - + self.http_strategy.configure(**strategy_config) - + def _generate_hmac_signature( - self, - method: str, - url: str, - content: str = "", - timestamp: Optional[str] = None + self, method: str, url: str, content: str = "", timestamp: Optional[str] = None ) -> Dict[str, str]: """ Generate HMAC authentication headers for Buckaroo API. """ if timestamp is None: timestamp = str(int(time.time())) - + nonce = str(uuid.uuid4()) - + # Process content following C# implementation pattern if content: # Convert content to bytes and compute MD5 hash - content_bytes = content.encode('utf-8') + content_bytes = content.encode("utf-8") md5_hash = hashlib.md5(content_bytes).digest() - content_b64 = base64.b64encode(md5_hash).decode('utf-8') + content_b64 = base64.b64encode(md5_hash).decode("utf-8") else: - content_b64 = '' - + content_b64 = "" + # Remove protocol from URL and encode for HMAC signature url_without_protocol = url - if url.startswith('https://'): + if url.startswith("https://"): url_without_protocol = url[8:] - elif url.startswith('http://'): + elif url.startswith("http://"): url_without_protocol = url[7:] - + # URL encode and convert to lowercase (matching C# behavior) - encoded_url = quote(url_without_protocol, safe='').lower() + encoded_url = quote(url_without_protocol, safe="").lower() # Create HMAC signature string string_to_sign = f"{self.store_key}{method}{encoded_url}{timestamp}{nonce}{content_b64}" # Generate HMAC-SHA256 signature - secret_key_bytes = self.secret_key.encode('utf-8') - signature_data_bytes = string_to_sign.encode('utf-8') + secret_key_bytes = self.secret_key.encode("utf-8") + signature_data_bytes = string_to_sign.encode("utf-8") signature = hmac.new(secret_key_bytes, signature_data_bytes, hashlib.sha256).digest() - encoded_signature = base64.b64encode(signature).decode('utf-8') + encoded_signature = base64.b64encode(signature).decode("utf-8") return { "Authorization": f"hmac {self.store_key}:{encoded_signature}:{nonce}:{timestamp}", "X-Buckaroo-Timestamp": timestamp, - "X-Buckaroo-Store-Key": self.store_key + "X-Buckaroo-Store-Key": self.store_key, } - + def post( - self, - endpoint: str, + self, + endpoint: str, data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None - ) -> 'BuckarooResponse': + params: Optional[Dict[str, Any]] = None, + ) -> "BuckarooResponse": """Send a POST request to the Buckaroo API.""" return self._make_request("POST", endpoint, data, params) - - def get( - self, - endpoint: str, - params: Optional[Dict[str, Any]] = None - ) -> 'BuckarooResponse': + + def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> "BuckarooResponse": """Send a GET request to the Buckaroo API.""" return self._make_request("GET", endpoint, None, params) - + def _make_request( self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None - ) -> 'BuckarooResponse': + params: Optional[Dict[str, Any]] = None, + ) -> "BuckarooResponse": """Make an HTTP request to the Buckaroo API.""" # Build full URL base_url = self.config.api_endpoint - if not endpoint.startswith('/'): - endpoint = '/' + endpoint + if not endpoint.startswith("/"): + endpoint = "/" + endpoint url = f"{base_url}{endpoint}" - + # Add URL parameters if params: - url += '?' + urlencode(params) - + url += "?" + urlencode(params) + # Prepare request body content = "" if data: - content = json.dumps(data, separators=(',', ':')) - + content = json.dumps(data, separators=(",", ":")) + # Generate authentication headers auth_headers = self._generate_hmac_signature(method, url, content) - + try: - # Make the request using strategy http_response = self.http_strategy.request( method=method, url=url, headers=auth_headers, data=content if content else None, timeout=self.config.timeout, - verify_ssl=self.config.verify_ssl + verify_ssl=self.config.verify_ssl, ) - - # Create Buckaroo response object - buckaroo_response = BuckarooResponse(http_response) - - # Handle authentication errors - if http_response.status_code == 401: - raise AuthenticationError("Authentication failed - check your store key and secret key") - elif http_response.status_code == 403: - raise AuthenticationError("Access forbidden - check your API permissions") - - return buckaroo_response - + except (AuthenticationError, BuckarooApiError): + raise except Exception as e: - # Convert strategy exceptions to BuckarooApiError - if "timeout" in str(e).lower(): - raise BuckarooApiError(str(e)) - elif "connection" in str(e).lower(): - raise BuckarooApiError(str(e)) - else: - raise BuckarooApiError(f"Request failed: {str(e)}") + raise BuckarooApiError(f"Request failed: {e}") from e + + if http_response.status_code == 401: + raise AuthenticationError("Authentication failed - check your store key and secret key") + if http_response.status_code == 403: + raise AuthenticationError("Access forbidden - check your API permissions") + + if not (200 <= http_response.status_code < 300): + response = BuckarooResponse.__new__(BuckarooResponse) + response._response = http_response + response._data = {} + raise BuckarooApiError( + f"Buckaroo API returned status {http_response.status_code}", + response, + ) + + return BuckarooResponse(http_response) class BuckarooResponse: """Wrapper for Buckaroo API responses.""" - + def __init__(self, response: HttpResponse): self._response = response self._data = None self._parse_response() - + def _parse_response(self): - """Parse the response content.""" + """Parse the response content. Raises BuckarooApiError on malformed JSON.""" + text = self._response.text + if not text or not text.strip(): + self._data = {} + return try: - if self._response.text: - self._data = json.loads(self._response.text) - else: - self._data = {} - except json.JSONDecodeError: - self._data = {"raw_content": self._response.text} - + self._data = json.loads(text) + except json.JSONDecodeError as e: + self._data = {} + raise BuckarooApiError( + f"Failed to parse Buckaroo response JSON: {e}", + self, + ) from e + @property def status_code(self) -> int: """Get the HTTP status code.""" return self._response.status_code - + @property def success(self) -> bool: """Check if the request was successful.""" return 200 <= self.status_code < 300 - + @property def data(self) -> Dict[str, Any]: """Get the response data.""" return self._data or {} - + @property def headers(self) -> Dict[str, str]: """Get the response headers.""" return self._response.headers - + @property def text(self) -> str: """Get the raw response text.""" return self._response.text - + def json(self) -> Dict[str, Any]: """Get the response as JSON.""" return self.data - + def is_successful_payment(self) -> bool: """Check if the payment was successful based on Buckaroo response.""" if not self.success: return False - + # Check Buckaroo-specific success indicators if self._data and "Status" in self._data: # Buckaroo status codes for successful payments @@ -250,17 +246,17 @@ def is_successful_payment(self) -> bool: actual_code = code else: actual_code = None - + return actual_code in success_statuses if actual_code is not None else False - + return self.success - + def get_payment_key(self) -> Optional[str]: """Get the payment key from the response.""" if not self._data: return None return self._data.get("Key") - + def get_transaction_key(self) -> Optional[str]: """Get the transaction key from the response.""" if not self._data: @@ -273,38 +269,38 @@ def get_transaction_key(self) -> Optional[str]: if service_list: return service_list[0].get("TransactionKey") return None - + def get_status_code(self) -> Optional[int]: """Get the Buckaroo status code.""" if not self._data: return None - + status = self._data.get("Status", {}) if not status: return None - + code = status.get("Code") if code is None: return None - + # Handle nested Code structure: {"Code": 490, "Description": "Failed"} if isinstance(code, dict): return code.get("Code") # Handle simple integer code elif isinstance(code, int): return code - + return None - + def get_status_message(self) -> Optional[str]: """Get the Buckaroo status message.""" if not self._data: return "" - + status = self._data.get("Status", {}) if not status: return "" - + # Handle SubCode being None sub_code = status.get("SubCode") if sub_code is None: @@ -313,13 +309,13 @@ def get_status_message(self) -> Optional[str]: if isinstance(code, dict) and "Description" in code: return code.get("Description", "") return "" - + # Handle SubCode being a dict if isinstance(sub_code, dict): return sub_code.get("Description", "") - + return "" - + def get_redirect_url(self) -> Optional[str]: """Get the redirect URL for payments that require redirection.""" if not self._data: @@ -328,7 +324,7 @@ def get_redirect_url(self) -> Optional[str]: if required_action and "RedirectURL" in required_action: return required_action["RedirectURL"] return None - + def to_dict(self) -> Dict[str, Any]: """Convert response to dictionary.""" return { @@ -336,27 +332,21 @@ def to_dict(self) -> Dict[str, Any]: "success": self.success, "data": self.data, "headers": self.headers, - # "is_successful_payment": self.is_successful_payment(), - # "payment_key": self.get_payment_key(), - # "transaction_key": self.get_transaction_key(), - # "buckaroo_status_code": self.get_status_code(), - # "buckaroo_status_message": self.get_status_message(), - # "redirect_url": self.get_redirect_url() } class BuckarooApiError(Exception): """Exception raised for Buckaroo API errors.""" - + def __init__(self, message: str, response: Optional[BuckarooResponse] = None): super().__init__(message) self.response = response - + @property def status_code(self) -> Optional[int]: """Get the HTTP status code if available.""" return self.response.status_code if self.response else None - + @property def error_data(self) -> Dict[str, Any]: """Get the error data if available.""" diff --git a/buckaroo/http/strategies/__init__.py b/buckaroo/http/strategies/__init__.py index 5387a7f..eb477d7 100644 --- a/buckaroo/http/strategies/__init__.py +++ b/buckaroo/http/strategies/__init__.py @@ -10,9 +10,9 @@ from .strategy_factory import HttpStrategyFactory __all__ = [ - 'HttpStrategy', - 'HttpResponse', - 'RequestsStrategy', - 'CurlStrategy', - 'HttpStrategyFactory' -] \ No newline at end of file + "HttpStrategy", + "HttpResponse", + "RequestsStrategy", + "CurlStrategy", + "HttpStrategyFactory", +] diff --git a/buckaroo/http/strategies/curl_strategy.py b/buckaroo/http/strategies/curl_strategy.py index 63e28c9..6a113c4 100644 --- a/buckaroo/http/strategies/curl_strategy.py +++ b/buckaroo/http/strategies/curl_strategy.py @@ -5,30 +5,29 @@ """ import subprocess -import json as json_module import shutil -from typing import Dict, Any, Optional, List +from typing import Dict, Optional, List from .http_strategy import HttpStrategy, HttpResponse class CurlStrategy(HttpStrategy): """ HTTP strategy implementation using system curl command. - + This strategy provides HTTP functionality without external Python dependencies, using the curl command available on most systems. """ - + def __init__(self): self._timeout = 30 self._verify_ssl = True self._retry_attempts = 3 self._default_headers = {} - + def configure(self, **kwargs) -> None: """ Configure the curl strategy settings. - + Args: **kwargs: Configuration parameters - timeout: Request timeout in seconds @@ -36,11 +35,11 @@ def configure(self, **kwargs) -> None: - retry_attempts: Number of retry attempts - default_headers: Default headers to include """ - self._timeout = kwargs.get('timeout', 30) - self._verify_ssl = kwargs.get('verify_ssl', True) - self._retry_attempts = kwargs.get('retry_attempts', 3) - self._default_headers = kwargs.get('default_headers', {}) - + self._timeout = kwargs.get("timeout", 30) + self._verify_ssl = kwargs.get("verify_ssl", True) + self._retry_attempts = kwargs.get("retry_attempts", 3) + self._default_headers = kwargs.get("default_headers", {}) + def request( self, method: str, @@ -48,11 +47,11 @@ def request( headers: Optional[Dict[str, str]] = None, data: Optional[str] = None, timeout: Optional[int] = None, - verify_ssl: bool = True + verify_ssl: bool = True, ) -> HttpResponse: """ Make an HTTP request using curl command. - + Args: method: HTTP method (GET, POST, etc.) url: Request URL @@ -60,10 +59,10 @@ def request( data: Request body data timeout: Request timeout in seconds verify_ssl: Whether to verify SSL certificates - + Returns: HttpResponse: Response object - + Raises: Exception: If the request fails """ @@ -74,9 +73,9 @@ def request( headers=headers, data=data, timeout=timeout or self._timeout, - verify_ssl=verify_ssl + verify_ssl=verify_ssl, ) - + # Execute curl with retry logic last_exception = None for attempt in range(self._retry_attempts): @@ -86,13 +85,15 @@ def request( capture_output=True, text=True, timeout=timeout or self._timeout, - check=False # Don't raise on non-zero exit codes + check=False, # Don't raise on non-zero exit codes ) - + return self._parse_curl_output(result) - + except subprocess.TimeoutExpired: - last_exception = Exception(f"Request timeout after {timeout or self._timeout} seconds") + last_exception = Exception( + f"Request timeout after {timeout or self._timeout} seconds" + ) if attempt == self._retry_attempts - 1: raise last_exception except subprocess.SubprocessError as e: @@ -103,10 +104,10 @@ def request( last_exception = Exception(f"Request failed: {str(e)}") if attempt == self._retry_attempts - 1: raise last_exception - + # This should never be reached, but just in case raise last_exception or Exception("Request failed after all retry attempts") - + def _build_curl_command( self, method: str, @@ -114,11 +115,11 @@ def _build_curl_command( headers: Optional[Dict[str, str]] = None, data: Optional[str] = None, timeout: int = 30, - verify_ssl: bool = True + verify_ssl: bool = True, ) -> List[str]: """ Build the curl command arguments. - + Args: method: HTTP method url: Request URL @@ -126,124 +127,119 @@ def _build_curl_command( data: Request body data timeout: Request timeout verify_ssl: Whether to verify SSL - + Returns: List[str]: Curl command arguments """ cmd = [ - 'curl', - '-X', method.upper(), - '--location', # Follow redirects - '--silent', # Silent mode - '--show-error', # Show errors - '--fail-with-body', # Include response body on HTTP errors - '--max-time', str(timeout), - '--include', # Include headers in output + "curl", + "-X", + method.upper(), + "--location", # Follow redirects + "--silent", # Silent mode + "--show-error", # Show errors + "--fail-with-body", # Include response body on HTTP errors + "--max-time", + str(timeout), + "--include", # Include headers in output ] - + # SSL verification if not verify_ssl: - cmd.extend(['--insecure']) - + cmd.extend(["--insecure"]) + # Add headers all_headers = {**self._default_headers} if headers: all_headers.update(headers) - + for key, value in all_headers.items(): - cmd.extend(['-H', f'{key}: {value}']) - + cmd.extend(["-H", f"{key}: {value}"]) + # Add data for POST/PUT requests - if data and method.upper() in ['POST', 'PUT', 'PATCH']: - cmd.extend(['--data', data]) - + if data and method.upper() in ["POST", "PUT", "PATCH"]: + cmd.extend(["--data", data]) + # Add URL last cmd.append(url) - + return cmd - + def _parse_curl_output(self, result: subprocess.CompletedProcess) -> HttpResponse: """ Parse curl output into HttpResponse object. - + Args: result: Completed curl process result - + Returns: HttpResponse: Parsed response """ output = result.stdout - + if not output: # Handle empty response return HttpResponse( - status_code=result.returncode, - headers={}, - text="", - success=result.returncode == 0 + status_code=result.returncode, headers={}, text="", success=result.returncode == 0 ) - + # Split headers and body # curl --include puts headers before the body, separated by \r\n\r\n - if '\r\n\r\n' in output: - header_section, body = output.split('\r\n\r\n', 1) - elif '\n\n' in output: - header_section, body = output.split('\n\n', 1) + if "\r\n\r\n" in output: + header_section, body = output.split("\r\n\r\n", 1) + elif "\n\n" in output: + header_section, body = output.split("\n\n", 1) else: # No clear separation, treat all as body header_section = "" body = output - + # Parse status code and headers status_code = 0 headers = {} - + if header_section: - lines = header_section.split('\n') - if lines: - # First line contains status - status_line = lines[0].strip() - if 'HTTP/' in status_line: - try: - status_code = int(status_line.split()[1]) - except (IndexError, ValueError): - status_code = result.returncode if result.returncode != 0 else 500 - - # Parse headers - for line in lines[1:]: - line = line.strip() - if ':' in line: - key, value = line.split(':', 1) - headers[key.strip()] = value.strip() + lines = header_section.split("\n") + # First line contains status + status_line = lines[0].strip() + if "HTTP/" in status_line: + try: + status_code = int(status_line.split()[1]) + except (IndexError, ValueError): + status_code = result.returncode if result.returncode != 0 else 500 + + # Parse headers + for line in lines[1:]: + line = line.strip() + if ":" in line: + key, value = line.split(":", 1) + headers[key.strip()] = value.strip() else: # No headers section, use return code status_code = result.returncode if result.returncode != 0 else 200 - + # If curl failed but we have output, it might be an error message if result.returncode != 0 and not body.strip(): body = result.stderr or f"Curl failed with exit code {result.returncode}" - + return HttpResponse( - status_code=status_code, - headers=headers, - text=body, - success=200 <= status_code < 300 + status_code=status_code, headers=headers, text=body, success=200 <= status_code < 300 ) - + def is_available(self) -> bool: """ Check if curl command is available on the system. - + Returns: bool: True if curl is available """ - return shutil.which('curl') is not None - + return shutil.which("curl") is not None + def get_name(self) -> str: """ Get the name of this strategy. - + Returns: str: Strategy name """ - return "curl" \ No newline at end of file + return "curl" diff --git a/buckaroo/http/strategies/http_strategy.py b/buckaroo/http/strategies/http_strategy.py index 20c3607..e8de5c4 100644 --- a/buckaroo/http/strategies/http_strategy.py +++ b/buckaroo/http/strategies/http_strategy.py @@ -13,17 +13,19 @@ class HttpResponse: """ Response object returned by HTTP strategies. - + This provides a consistent interface across different HTTP implementations. """ + status_code: int headers: Dict[str, str] text: str success: bool - + def json(self) -> Dict[str, Any]: """Parse response text as JSON.""" import json + try: return json.loads(self.text) if self.text else {} except json.JSONDecodeError: @@ -33,20 +35,19 @@ def json(self) -> Dict[str, Any]: class HttpStrategy(ABC): """ Abstract base class for HTTP client strategies. - + This defines the interface that all HTTP client implementations must follow. """ - + @abstractmethod def configure(self, **kwargs) -> None: """ Configure the HTTP client with settings like timeout, retry, etc. - + Args: **kwargs: Configuration parameters specific to the implementation """ - pass - + @abstractmethod def request( self, @@ -55,11 +56,11 @@ def request( headers: Optional[Dict[str, str]] = None, data: Optional[str] = None, timeout: Optional[int] = None, - verify_ssl: bool = True + verify_ssl: bool = True, ) -> HttpResponse: """ Make an HTTP request. - + Args: method: HTTP method (GET, POST, etc.) url: Request URL @@ -67,31 +68,28 @@ def request( data: Request body data timeout: Request timeout in seconds verify_ssl: Whether to verify SSL certificates - + Returns: HttpResponse: Response object - + Raises: Exception: If the request fails """ - pass - + @abstractmethod def is_available(self) -> bool: """ Check if this HTTP strategy is available on the system. - + Returns: bool: True if the strategy can be used """ - pass - + @abstractmethod def get_name(self) -> str: """ Get the name of this HTTP strategy. - + Returns: str: Strategy name """ - pass \ No newline at end of file diff --git a/buckaroo/http/strategies/requests_strategy.py b/buckaroo/http/strategies/requests_strategy.py index 0d2dfb9..7aa579a 100644 --- a/buckaroo/http/strategies/requests_strategy.py +++ b/buckaroo/http/strategies/requests_strategy.py @@ -4,12 +4,13 @@ This module provides an HTTP strategy implementation using the requests library. """ -from typing import Dict, Any, Optional +from typing import Dict, Optional from .http_strategy import HttpStrategy, HttpResponse try: import requests from requests.adapters import HTTPAdapter + try: from urllib3.util.retry import Retry except ImportError: @@ -17,9 +18,11 @@ REQUESTS_AVAILABLE = True except ImportError: REQUESTS_AVAILABLE = False + # Create dummy classes for type hints when requests is not available class HTTPAdapter: pass + class Retry: pass @@ -27,20 +30,20 @@ class Retry: class RequestsStrategy(HttpStrategy): """ HTTP strategy implementation using the requests library. - + This strategy provides robust HTTP functionality with retry logic, session management, and connection pooling. """ - + def __init__(self): self.session = None self._retry_attempts = 3 self._retry_delay = 1.0 - + def configure(self, **kwargs) -> None: """ Configure the requests session with retry logic and adapters. - + Args: **kwargs: Configuration parameters - retry_attempts: Number of retry attempts @@ -52,22 +55,22 @@ def configure(self, **kwargs) -> None: "The 'requests' library is required for RequestsStrategy. " "Please install it with: pip install requests" ) - - self._retry_attempts = kwargs.get('retry_attempts', 3) - self._retry_delay = kwargs.get('retry_delay', 1.0) - + + self._retry_attempts = kwargs.get("retry_attempts", 3) + self._retry_delay = kwargs.get("retry_delay", 1.0) + # Create session self.session = requests.Session() - + # Configure retry strategy if available try: retry_strategy = Retry( total=self._retry_attempts, backoff_factor=self._retry_delay, status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["POST", "GET", "PUT", "DELETE"] + allowed_methods=["POST", "GET", "PUT", "DELETE"], ) - + adapter = HTTPAdapter(max_retries=retry_strategy) self.session.mount("http://", adapter) self.session.mount("https://", adapter) @@ -76,12 +79,12 @@ def configure(self, **kwargs) -> None: adapter = HTTPAdapter(max_retries=self._retry_attempts) self.session.mount("http://", adapter) self.session.mount("https://", adapter) - + # Set default headers - default_headers = kwargs.get('default_headers', {}) + default_headers = kwargs.get("default_headers", {}) if default_headers: self.session.headers.update(default_headers) - + def request( self, method: str, @@ -89,11 +92,11 @@ def request( headers: Optional[Dict[str, str]] = None, data: Optional[str] = None, timeout: Optional[int] = None, - verify_ssl: bool = True + verify_ssl: bool = True, ) -> HttpResponse: """ Make an HTTP request using requests library. - + Args: method: HTTP method (GET, POST, etc.) url: Request URL @@ -101,58 +104,60 @@ def request( data: Request body data timeout: Request timeout in seconds verify_ssl: Whether to verify SSL certificates - + Returns: HttpResponse: Response object - + Raises: Exception: If the request fails """ if not self.session: self.configure() - + request_kwargs = { - 'method': method, - 'url': url, - 'headers': headers or {}, - 'timeout': timeout or 30, - 'verify': verify_ssl + "method": method, + "url": url, + "headers": headers or {}, + "timeout": timeout or 30, + "verify": verify_ssl, } if data: - request_kwargs['data'] = data - + request_kwargs["data"] = data + try: response = self.session.request(**request_kwargs) - + return HttpResponse( status_code=response.status_code, headers=dict(response.headers), text=response.text, - success=200 <= response.status_code < 300 + success=200 <= response.status_code < 300, ) - + except requests.exceptions.Timeout: - raise Exception(f"Request timeout after {timeout} seconds") + if timeout is not None: + raise Exception(f"Request timeout after {timeout} seconds") + raise Exception("Request timeout") except requests.exceptions.ConnectionError: raise Exception("Connection error - check your internet connection") except requests.exceptions.RequestException as e: raise Exception(f"Request failed: {str(e)}") - + def is_available(self) -> bool: """ Check if requests library is available. - + Returns: bool: True if requests is available """ return REQUESTS_AVAILABLE - + def get_name(self) -> str: """ Get the name of this strategy. - + Returns: str: Strategy name """ - return "requests" \ No newline at end of file + return "requests" diff --git a/buckaroo/http/strategies/strategy_factory.py b/buckaroo/http/strategies/strategy_factory.py index 1570cce..2ec951a 100644 --- a/buckaroo/http/strategies/strategy_factory.py +++ b/buckaroo/http/strategies/strategy_factory.py @@ -13,29 +13,29 @@ class HttpStrategyFactory: """ Factory for creating HTTP strategy instances. - + This factory automatically selects the best available HTTP strategy based on what's available on the system. """ - + # Order of preference for strategies _STRATEGY_CLASSES: List[Type[HttpStrategy]] = [ RequestsStrategy, # Preferred: Full-featured with retry logic - CurlStrategy, # Fallback: No external dependencies + CurlStrategy, # Fallback: No external dependencies ] - + @classmethod def create_strategy(cls, preferred_strategy: Optional[str] = None) -> HttpStrategy: """ Create an HTTP strategy instance. - + Args: preferred_strategy: Name of preferred strategy ('requests' or 'curl') If None, will auto-select the best available strategy - + Returns: HttpStrategy: Strategy instance - + Raises: RuntimeError: If no HTTP strategy is available """ @@ -50,43 +50,43 @@ def create_strategy(cls, preferred_strategy: Optional[str] = None) -> HttpStrate f"Requested HTTP strategy '{preferred_strategy}' is not available. " f"Available strategies: {available_strategies}" ) - + # Auto-select best available strategy for strategy_class in cls._STRATEGY_CLASSES: strategy = strategy_class() if strategy.is_available(): return strategy - + # No strategy available raise RuntimeError( "No HTTP strategy is available. Please install 'requests' library " "or ensure 'curl' command is available on your system." ) - + @classmethod def _create_named_strategy(cls, name: str) -> Optional[HttpStrategy]: """ Create a strategy by name. - + Args: name: Strategy name - + Returns: HttpStrategy instance or None if not found """ strategy_map = { - 'requests': RequestsStrategy, - 'curl': CurlStrategy, + "requests": RequestsStrategy, + "curl": CurlStrategy, } - + strategy_class = strategy_map.get(name.lower()) return strategy_class() if strategy_class else None - + @classmethod def get_available_strategies(cls) -> List[str]: """ Get list of available strategy names. - + Returns: List[str]: Names of available strategies """ @@ -96,17 +96,17 @@ def get_available_strategies(cls) -> List[str]: if strategy.is_available(): available.append(strategy.get_name()) return available - + @classmethod def is_strategy_available(cls, name: str) -> bool: """ Check if a specific strategy is available. - + Args: name: Strategy name - + Returns: bool: True if strategy is available """ strategy = cls._create_named_strategy(name) - return strategy.is_available() if strategy else False \ No newline at end of file + return strategy.is_available() if strategy else False diff --git a/buckaroo/models/__init__.py b/buckaroo/models/__init__.py index 021da0b..6778dee 100644 --- a/buckaroo/models/__init__.py +++ b/buckaroo/models/__init__.py @@ -4,13 +4,22 @@ This package contains all data models and response objects. """ -from .payment_response import PaymentResponse, Status, StatusCode, RequiredAction, Service, ServiceParameter +from .payment_response import ( + BuckarooStatusCode, + PaymentResponse, + RequiredAction, + Service, + ServiceParameter, + Status, + StatusCode, +) __all__ = [ - 'PaymentResponse', - 'Status', - 'StatusCode', - 'RequiredAction', - 'Service', - 'ServiceParameter' -] \ No newline at end of file + "BuckarooStatusCode", + "PaymentResponse", + "RequiredAction", + "Service", + "ServiceParameter", + "Status", + "StatusCode", +] diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py index 1349289..017a95c 100644 --- a/buckaroo/models/payment_request.py +++ b/buckaroo/models/payment_request.py @@ -5,49 +5,46 @@ @dataclass class Parameter: """Model for a service parameter.""" + name: str value: str group_type: str = "" group_id: str = "" - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" return { "Name": self.name, "GroupType": self.group_type, "GroupID": self.group_id, - "Value": self.value + "Value": self.value, } @dataclass class ClientIP: """Model for client IP information.""" + type: int = 0 address: str = "0.0.0.0" - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" - return { - "Type": self.type, - "Address": self.address - } + return {"Type": self.type, "Address": self.address} @dataclass class Service: """Model for a payment service.""" + name: str action: str = "Pay" parameters: Optional[Union[Dict[str, Any], List[Parameter]]] = None - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" - service_dict = { - "Name": self.name, - "Action": self.action - } - + service_dict = {"Name": self.name, "Action": self.action} + if self.parameters: if isinstance(self.parameters, list): # Parameters array format (for methods like IdealQr) @@ -55,25 +52,48 @@ def to_dict(self) -> Dict[str, Any]: elif isinstance(self.parameters, dict): # Simple key-value format (for methods like ideal, creditcard) service_dict.update(self.parameters) - + return service_dict + def add_parameter(self, parameter: Union[Dict[str, Any], Parameter]) -> "Service": + """Append a Parameter or coerced dict; rejects dict-form parameters.""" + if isinstance(self.parameters, dict): + raise TypeError( + "Service uses simple key-value parameters; add_parameter requires list form" + ) + if isinstance(parameter, dict): + parameter = Parameter( + name=parameter.get("Name", parameter.get("name", "")), + value=parameter.get("Value", parameter.get("value", "")), + group_type=parameter.get("GroupType", parameter.get("group_type", "")), + group_id=parameter.get("GroupID", parameter.get("group_id", "")), + ) + if self.parameters is None: + self.parameters = [] + self.parameters.append(parameter) + return self + @dataclass class ServiceList: """Model for list of services.""" + services: List[Service] - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" - return { - "ServiceList": [service.to_dict() for service in self.services] - } + return {"ServiceList": [service.to_dict() for service in self.services]} + + def add(self, service: Service) -> "ServiceList": + """Append a service; returns self for chaining.""" + self.services.append(service) + return self @dataclass class PaymentRequest: """Model for complete payment request.""" + currency: str amount_debit: float description: str @@ -87,12 +107,12 @@ class PaymentRequest: push_url_failure: Optional[str] = None client_ip: Optional[ClientIP] = None services: Optional[ServiceList] = None - + def __post_init__(self): """Set default values after initialization.""" if self.client_ip is None: self.client_ip = ClientIP() - + def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for API request.""" request_dict = { @@ -114,8 +134,8 @@ def to_dict(self) -> Dict[str, Any]: if self.client_ip: request_dict["ClientIP"] = self.client_ip.to_dict() - + if self.services: request_dict["Services"] = self.services.to_dict() - - return request_dict \ No newline at end of file + + return request_dict diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py index 796192e..c875134 100644 --- a/buckaroo/models/payment_response.py +++ b/buckaroo/models/payment_response.py @@ -6,144 +6,177 @@ from typing import Dict, Any, Optional, List from dataclasses import dataclass -from datetime import datetime +from enum import IntEnum + + +class BuckarooStatusCode(IntEnum): + """Canonical Buckaroo transaction status codes.""" + + SUCCESS = 190 + FAILED = 490 + VALIDATION_FAILURE = 491 + TECHNICAL_FAILURE = 492 + REJECTED = 690 + REJECTED_BY_USER = 691 + REJECTED_TECHNICAL = 692 + PENDING_INPUT = 790 + PENDING_PROCESSING = 791 + PENDING_CONSUMER = 792 + AWAITING_TRANSFER = 793 + CANCELLED_BY_USER = 890 + CANCELLED_BY_MERCHANT = 891 + + +_PENDING_CODES = frozenset( + { + BuckarooStatusCode.PENDING_INPUT, + BuckarooStatusCode.PENDING_PROCESSING, + BuckarooStatusCode.PENDING_CONSUMER, + BuckarooStatusCode.AWAITING_TRANSFER, + } +) +_CANCELLED_CODES = frozenset( + { + BuckarooStatusCode.CANCELLED_BY_USER, + BuckarooStatusCode.CANCELLED_BY_MERCHANT, + } +) +_FAILED_CODES = frozenset( + { + BuckarooStatusCode.FAILED, + BuckarooStatusCode.VALIDATION_FAILURE, + BuckarooStatusCode.TECHNICAL_FAILURE, + BuckarooStatusCode.REJECTED, + BuckarooStatusCode.REJECTED_BY_USER, + BuckarooStatusCode.REJECTED_TECHNICAL, + } +) @dataclass class StatusCode: """Represents a Buckaroo status code.""" + code: int description: str - + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'StatusCode': + def from_dict(cls, data: Dict[str, Any]) -> "StatusCode": """Create StatusCode from dictionary.""" if data is None: data = {} - + # Handle nested Code structure: {"Code": 490, "Description": "Failed"} if isinstance(data, dict) and "Code" in data and "Description" in data: - return cls( - code=data.get('Code', 0), - description=data.get('Description', '') - ) + return cls(code=data.get("Code", 0), description=data.get("Description", "")) # Handle simple structure: {"Code": 490} or just integer elif isinstance(data, dict): - return cls( - code=data.get('Code', 0), - description=data.get('Description', '') - ) + return cls(code=data.get("Code", 0), description=data.get("Description", "")) # Handle direct integer elif isinstance(data, int): - return cls( - code=data, - description='' - ) + return cls(code=data, description="") else: - return cls(code=0, description='') + return cls(code=0, description="") @dataclass class Status: """Represents the status of a payment transaction.""" + code: StatusCode sub_code: StatusCode datetime: str - + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Status': + def from_dict(cls, data: Dict[str, Any]) -> "Status": """Create Status from dictionary.""" if data is None: data = {} - + # Handle SubCode being None - sub_code_data = data.get('SubCode') + sub_code_data = data.get("SubCode") if sub_code_data is None: sub_code_data = {} - + return cls( - code=StatusCode.from_dict(data.get('Code', {})), + code=StatusCode.from_dict(data.get("Code", {})), sub_code=StatusCode.from_dict(sub_code_data), - datetime=data.get('DateTime', '') + datetime=data.get("DateTime", ""), ) @dataclass class RequiredAction: """Represents a required action for the payment.""" + redirect_url: Optional[str] requested_information: Optional[Any] pay_remainder_details: Optional[Any] name: str type_deprecated: int - + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'RequiredAction': + def from_dict(cls, data: Dict[str, Any]) -> "RequiredAction": """Create RequiredAction from dictionary.""" if data is None: data = {} return cls( - redirect_url=data.get('RedirectURL'), - requested_information=data.get('RequestedInformation'), - pay_remainder_details=data.get('PayRemainderDetails'), - name=data.get('Name', ''), - type_deprecated=data.get('TypeDeprecated', 0) + redirect_url=data.get("RedirectURL"), + requested_information=data.get("RequestedInformation"), + pay_remainder_details=data.get("PayRemainderDetails"), + name=data.get("Name", ""), + type_deprecated=data.get("TypeDeprecated", 0), ) @dataclass class ServiceParameter: """Represents a service parameter.""" + name: str value: Any - + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ServiceParameter': + def from_dict(cls, data: Dict[str, Any]) -> "ServiceParameter": """Create ServiceParameter from dictionary.""" if data is None: data = {} - return cls( - name=data.get('Name', ''), - value=data.get('Value') - ) + return cls(name=data.get("Name", ""), value=data.get("Value")) @dataclass class Service: """Represents a payment service.""" + name: str action: Optional[str] parameters: List[ServiceParameter] - + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Service': + def from_dict(cls, data: Dict[str, Any]) -> "Service": """Create Service from dictionary.""" if data is None: data = {} - + parameters = [] - if 'Parameters' in data and data['Parameters']: - parameters = [ServiceParameter.from_dict(param) for param in data['Parameters']] - - return cls( - name=data.get('Name', ''), - action=data.get('Action'), - parameters=parameters - ) + if "Parameters" in data and data["Parameters"]: + parameters = [ServiceParameter.from_dict(param) for param in data["Parameters"]] + + return cls(name=data.get("Name", ""), action=data.get("Action"), parameters=parameters) class PaymentResponse: """ Represents a response from the Buckaroo payment API. - + This class provides convenient access to all payment response data and includes helper methods for common operations. """ - + def __init__(self, response_data: Dict[str, Any]): """ Initialize PaymentResponse from response dictionary. - + Args: response_data: Raw response data from BuckarooResponse.to_dict() """ @@ -151,113 +184,113 @@ def __init__(self, response_data: Dict[str, Any]): response_data = {} self._raw_data = response_data self._parse_response() - + def _parse_response(self): """Parse the response data into structured objects.""" - data = self._raw_data.get('data', {}) - + data = self._raw_data.get("data", {}) + # Basic response info - self.status_code = self._raw_data.get('status_code', 0) - self.success = self._raw_data.get('success', False) - self.headers = self._raw_data.get('headers', {}) - + self.status_code = self._raw_data.get("status_code", 0) + self.success = self._raw_data.get("success", False) + self.headers = self._raw_data.get("headers", {}) + # Payment identifiers - self.key = data.get('Key') - self.payment_key = data.get('PaymentKey') - + self.key = data.get("Key") + self.payment_key = data.get("PaymentKey") + # Status information - self.status = Status.from_dict(data.get('Status', {})) if 'Status' in data else None - + self.status = Status.from_dict(data.get("Status", {})) if "Status" in data else None + # Required action (for redirects, etc.) - required_action_data = data.get('RequiredAction') - self.required_action = RequiredAction.from_dict(required_action_data) if required_action_data is not None else None - + required_action_data = data.get("RequiredAction") + self.required_action = ( + RequiredAction.from_dict(required_action_data) + if required_action_data is not None + else None + ) + # Services self.services = [] - if 'Services' in data and data['Services']: - self.services = [Service.from_dict(service) for service in data['Services']] - + if "Services" in data and data["Services"]: + self.services = [Service.from_dict(service) for service in data["Services"]] + # Payment details - self.invoice = data.get('Invoice') - self.service_code = data.get('ServiceCode') - self.is_test = data.get('IsTest', False) - self.currency = data.get('Currency') - self.amount_debit = data.get('AmountDebit') - self.amount_credit = data.get('AmountCredit') # For refunds - self.transaction_type = data.get('TransactionType') - self.mutation_type = data.get('MutationType') - + self.invoice = data.get("Invoice") + self.service_code = data.get("ServiceCode") + self.is_test = data.get("IsTest", False) + self.currency = data.get("Currency") + self.amount_debit = data.get("AmountDebit") + self.amount_credit = data.get("AmountCredit") # For refunds + self.transaction_type = data.get("TransactionType") + self.mutation_type = data.get("MutationType") + # Additional fields - self.custom_parameters = data.get('CustomParameters') - self.additional_parameters = data.get('AdditionalParameters') - self.request_errors = data.get('RequestErrors') - self.related_transactions = data.get('RelatedTransactions') - self.consumer_message = data.get('ConsumerMessage') - self.order = data.get('Order') - self.issuing_country = data.get('IssuingCountry') - self.start_recurrent = data.get('StartRecurrent', False) - self.recurring = data.get('Recurring', False) - self.customer_name = data.get('CustomerName') - self.payer_hash = data.get('PayerHash') - + self.custom_parameters = data.get("CustomParameters") + self.additional_parameters = data.get("AdditionalParameters") + self.request_errors = data.get("RequestErrors") + self.related_transactions = data.get("RelatedTransactions") + self.consumer_message = data.get("ConsumerMessage") + self.order = data.get("Order") + self.issuing_country = data.get("IssuingCountry") + self.start_recurrent = data.get("StartRecurrent", False) + self.recurring = data.get("Recurring", False) + self.customer_name = data.get("CustomerName") + self.payer_hash = data.get("PayerHash") + # Convenience properties from BuckarooResponse - self.is_successful_payment = self._raw_data.get('is_successful_payment', False) - self.transaction_key = self._raw_data.get('transaction_key') - self.buckaroo_status_code = self._raw_data.get('buckaroo_status_code') - self.buckaroo_status_message = self._raw_data.get('buckaroo_status_message') - self.redirect_url = self._raw_data.get('redirect_url') - + self.is_successful_payment = self._raw_data.get("is_successful_payment", False) + self.transaction_key = self._raw_data.get("transaction_key") + self.buckaroo_status_code = self._raw_data.get("buckaroo_status_code") + self.buckaroo_status_message = self._raw_data.get("buckaroo_status_message") + self.redirect_url = self._raw_data.get("redirect_url") + def is_pending(self) -> bool: """Check if the payment is pending.""" if self.status and self.status.code: - # Common pending status codes - pending_codes = [790, 791, 792, 793] - return self.status.code.code in pending_codes + return self.status.code.code in _PENDING_CODES return False - + def is_successful(self) -> bool: """Check if the payment was successful.""" return self.is_successful_payment - + def is_cancelled(self) -> bool: """Check if the payment was cancelled.""" if self.status and self.status.code: - # Common cancelled status codes - cancelled_codes = [890, 891] - return self.status.code.code in cancelled_codes + return self.status.code.code in _CANCELLED_CODES return False - + def is_failed(self) -> bool: """Check if the payment failed.""" if self.status and self.status.code: - # Common failed status codes - failed_codes = [490, 491, 492, 690, 691, 692] - return self.status.code.code in failed_codes + return self.status.code.code in _FAILED_CODES return False - + def requires_action(self) -> bool: """Check if the payment requires additional action (like redirect).""" return self.required_action is not None - + def get_redirect_url(self) -> Optional[str]: - """Get the redirect URL if available.""" + """Get the redirect URL from the required action, if any.""" + if self.required_action is not None: + return self.required_action.redirect_url return self.redirect_url - + def get_transaction_id(self) -> Optional[str]: """Get the transaction ID from service parameters.""" for service in self.services: for param in service.parameters: - if param.name.lower() == 'transactionid': + if param.name.lower() == "transactionid": return param.value return None - + def get_service_parameter(self, parameter_name: str) -> Optional[Any]: """ Get a specific service parameter value. - + Args: parameter_name: Name of the parameter to retrieve - + Returns: Parameter value if found, None otherwise """ @@ -266,19 +299,25 @@ def get_service_parameter(self, parameter_name: str) -> Optional[Any]: if param.name.lower() == parameter_name.lower(): return param.value return None - + def to_dict(self) -> Dict[str, Any]: """Convert the response back to a dictionary.""" return self._raw_data - + def __str__(self) -> str: """String representation of the payment response.""" - status_desc = f"{self.status.code.code} - {self.status.code.description}" if self.status else "Unknown" + status_desc = ( + f"{self.status.code.code} - {self.status.code.description}" + if self.status + else "Unknown" + ) return f"PaymentResponse(key={self.key}, status={status_desc}, amount={self.amount_debit} {self.currency})" - + def __repr__(self) -> str: """Detailed string representation.""" - return (f"PaymentResponse(key={self.key}, payment_key={self.payment_key}, " - f"status_code={self.status_code}, success={self.success}, " - f"is_test={self.is_test}, currency={self.currency}, " - f"amount={self.amount_debit})") \ No newline at end of file + return ( + f"PaymentResponse(key={self.key}, payment_key={self.payment_key}, " + f"status_code={self.status_code}, success={self.success}, " + f"is_test={self.is_test}, currency={self.currency}, " + f"amount={self.amount_debit})" + ) diff --git a/buckaroo/observers/__init__.py b/buckaroo/observers/__init__.py index a6309a8..8b3e6e7 100644 --- a/buckaroo/observers/__init__.py +++ b/buckaroo/observers/__init__.py @@ -12,15 +12,15 @@ LogLevel, LogDestination, create_logger, - create_logger_from_env + create_logger_from_env, ) __all__ = [ - 'BuckarooLoggingObserver', - 'ContextualLoggingObserver', - 'LogConfig', - 'LogLevel', - 'LogDestination', - 'create_logger', - 'create_logger_from_env' -] \ No newline at end of file + "BuckarooLoggingObserver", + "ContextualLoggingObserver", + "LogConfig", + "LogLevel", + "LogDestination", + "create_logger", + "create_logger_from_env", +] diff --git a/buckaroo/observers/logging_observer.py b/buckaroo/observers/logging_observer.py index 9de5c86..767ede9 100644 --- a/buckaroo/observers/logging_observer.py +++ b/buckaroo/observers/logging_observer.py @@ -10,14 +10,14 @@ import json import os import sys -from datetime import datetime -from typing import Optional, Dict, Any, Union +from typing import Optional, Dict, Any from enum import Enum from dataclasses import dataclass class LogLevel(Enum): """Log levels for the observer.""" + DEBUG = "DEBUG" INFO = "INFO" WARNING = "WARNING" @@ -27,6 +27,7 @@ class LogLevel(Enum): class LogDestination(Enum): """Log output destinations.""" + STDOUT = "stdout" FILE = "file" BOTH = "both" @@ -35,6 +36,7 @@ class LogDestination(Enum): @dataclass class LogConfig: """Configuration for logging observer.""" + level: LogLevel = LogLevel.INFO destination: LogDestination = LogDestination.BOTH log_file: str = "buckaroo_sdk.log" @@ -50,77 +52,96 @@ class LogConfig: class BuckarooLoggingObserver: """ Comprehensive logging observer for Buckaroo SDK operations. - + This class provides detailed logging for HTTP requests, responses, exceptions, and other SDK operations with support for multiple output destinations and configurable formatting. """ - + def __init__(self, config: Optional[LogConfig] = None): """ Initialize the logging observer. - + Args: config: Optional logging configuration. Uses defaults if not provided. """ self.config = config or LogConfig() self.logger = self._setup_logger() self._sensitive_fields = { - 'secret_key', 'password', 'token', 'authorization', 'cvv', - 'cardnumber', 'card_number', 'iban', 'account_number' + "secret_key", + "password", + "token", + "authorization", + "cvv", + "cardnumber", + "card_number", + "iban", + "account_number", + "cvc", + "bic", + "pan", + "expirydate", + "encryptedcarddata", } - + def _setup_logger(self) -> logging.Logger: """Set up the logger with configured handlers and formatters.""" logger = logging.getLogger("buckaroo_sdk") logger.setLevel(getattr(logging, self.config.level.value)) - + # Clear existing handlers to avoid duplicates logger.handlers.clear() - - formatter = logging.Formatter( - fmt=self.config.log_format, - datefmt=self.config.date_format - ) - + + formatter = logging.Formatter(fmt=self.config.log_format, datefmt=self.config.date_format) + # Setup stdout handler if self.config.destination in [LogDestination.STDOUT, LogDestination.BOTH]: stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) logger.addHandler(stdout_handler) - + # Setup file handler if self.config.destination in [LogDestination.FILE, LogDestination.BOTH]: from logging.handlers import RotatingFileHandler + file_handler = RotatingFileHandler( filename=self.config.log_file, maxBytes=self.config.max_file_size, - backupCount=self.config.backup_count + backupCount=self.config.backup_count, ) file_handler.setFormatter(formatter) logger.addHandler(file_handler) - + return logger - + + def _is_sensitive_parameter_pair(self, data: dict) -> bool: + """Check if a dict is a Buckaroo Name/Value pair where Name is sensitive.""" + name_val = data.get("Name", "") + if isinstance(name_val, str) and name_val: + return any(s in name_val.lower() for s in self._sensitive_fields) + return False + def _mask_sensitive_data(self, data: Any) -> Any: """ Recursively mask sensitive data in dictionaries and strings. - + Args: data: Data to mask (dict, list, str, or other) - + Returns: Data with sensitive fields masked """ if not self.config.mask_sensitive_data: return data - + if isinstance(data, dict): masked = {} for key, value in data.items(): key_lower = key.lower() if any(sensitive in key_lower for sensitive in self._sensitive_fields): masked[key] = "***MASKED***" + elif key == "Value" and self._is_sensitive_parameter_pair(data): + masked[key] = "***MASKED***" else: masked[key] = self._mask_sensitive_data(value) return masked @@ -133,7 +154,7 @@ def _mask_sensitive_data(self, data: Any) -> Any: return data else: return data - + def _format_json(self, data: Any) -> str: """Format data as pretty JSON string.""" try: @@ -143,17 +164,23 @@ def _format_json(self, data: Any) -> str: data = json.loads(data) except json.JSONDecodeError: return data - + masked_data = self._mask_sensitive_data(data) return json.dumps(masked_data, indent=2, default=str) except Exception: return str(data) - - def log_request(self, method: str, url: str, headers: Optional[Dict[str, str]] = None, - body: Optional[Any] = None, **kwargs): + + def log_request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + body: Optional[Any] = None, + **kwargs, + ): """ Log HTTP request details. - + Args: method: HTTP method (GET, POST, etc.) url: Request URL @@ -161,32 +188,33 @@ def log_request(self, method: str, url: str, headers: Optional[Dict[str, str]] = body: Request body **kwargs: Additional context data """ - request_id = kwargs.get('request_id', self._generate_request_id()) - - log_message = [ - f"HTTP REQUEST [{request_id}]", - f"Method: {method}", - f"URL: {url}" - ] - + request_id = kwargs.get("request_id", self._generate_request_id()) + + log_message = [f"HTTP REQUEST [{request_id}]", f"Method: {method}", f"URL: {url}"] + if headers: masked_headers = self._mask_sensitive_data(headers) log_message.append(f"Headers: {self._format_json(masked_headers)}") - + if body and self.config.include_request_body: log_message.append(f"Body: {self._format_json(body)}") - + if kwargs: log_message.append(f"Context: {self._format_json(kwargs)}") - + self.logger.info("\n".join(log_message)) - - def log_response(self, status_code: int, headers: Optional[Dict[str, str]] = None, - body: Optional[Any] = None, duration_ms: Optional[float] = None, - **kwargs): + + def log_response( + self, + status_code: int, + headers: Optional[Dict[str, str]] = None, + body: Optional[Any] = None, + duration_ms: Optional[float] = None, + **kwargs, + ): """ Log HTTP response details. - + Args: status_code: HTTP status code headers: Response headers @@ -194,26 +222,23 @@ def log_response(self, status_code: int, headers: Optional[Dict[str, str]] = Non duration_ms: Request duration in milliseconds **kwargs: Additional context data """ - request_id = kwargs.get('request_id', 'unknown') - - log_message = [ - f"HTTP RESPONSE [{request_id}]", - f"Status: {status_code}" - ] - + request_id = kwargs.get("request_id", "unknown") + + log_message = [f"HTTP RESPONSE [{request_id}]", f"Status: {status_code}"] + if duration_ms is not None: log_message.append(f"Duration: {duration_ms:.2f}ms") - + if headers: masked_headers = self._mask_sensitive_data(headers) log_message.append(f"Headers: {self._format_json(masked_headers)}") - + if body and self.config.include_response_body: log_message.append(f"Body: {self._format_json(body)}") - + if kwargs: log_message.append(f"Context: {self._format_json(kwargs)}") - + # Log as info for success, warning for client errors, error for server errors if 200 <= status_code < 300: self.logger.info("\n".join(log_message)) @@ -221,44 +246,51 @@ def log_response(self, status_code: int, headers: Optional[Dict[str, str]] = Non self.logger.warning("\n".join(log_message)) else: self.logger.error("\n".join(log_message)) - - def log_exception(self, exception: Exception, context: Optional[Dict[str, Any]] = None, - **kwargs): + + def log_exception( + self, exception: Exception, context: Optional[Dict[str, Any]] = None, **kwargs + ): """ Log exception details with context. - + Args: exception: The exception to log context: Additional context information **kwargs: Additional context data """ - request_id = kwargs.get('request_id', 'unknown') - + request_id = kwargs.get("request_id", "unknown") + log_message = [ f"EXCEPTION [{request_id}]", f"Type: {type(exception).__name__}", - f"Message: {str(exception)}" + f"Message: {str(exception)}", ] - + if context: log_message.append(f"Context: {self._format_json(context)}") - + if kwargs: log_message.append(f"Additional Info: {self._format_json(kwargs)}") - + # Include stack trace for debug level import traceback + if self.logger.isEnabledFor(logging.DEBUG): log_message.append(f"Stack Trace:\n{traceback.format_exc()}") - + self.logger.error("\n".join(log_message)) - - def log_payment_operation(self, operation: str, payment_method: str, - amount: Optional[float] = None, currency: Optional[str] = None, - **kwargs): + + def log_payment_operation( + self, + operation: str, + payment_method: str, + amount: Optional[float] = None, + currency: Optional[str] = None, + **kwargs, + ): """ Log payment-specific operations. - + Args: operation: Operation type (create, execute, validate, etc.) payment_method: Payment method (ideal, creditcard, etc.) @@ -266,30 +298,30 @@ def log_payment_operation(self, operation: str, payment_method: str, currency: Payment currency **kwargs: Additional payment data """ - request_id = kwargs.get('request_id', self._generate_request_id()) - + request_id = kwargs.get("request_id", self._generate_request_id()) + log_message = [ f"PAYMENT OPERATION [{request_id}]", f"Operation: {operation}", - f"Method: {payment_method}" + f"Method: {payment_method}", ] - + if amount is not None: log_message.append(f"Amount: {amount}") - + if currency: log_message.append(f"Currency: {currency}") - + if kwargs: masked_kwargs = self._mask_sensitive_data(kwargs) log_message.append(f"Details: {self._format_json(masked_kwargs)}") - + self.logger.info("\n".join(log_message)) - + def log_config_change(self, config_name: str, old_value: Any, new_value: Any, **kwargs): """ Log configuration changes. - + Args: config_name: Name of the configuration parameter old_value: Previous value @@ -300,54 +332,55 @@ def log_config_change(self, config_name: str, old_value: Any, new_value: Any, ** "CONFIG CHANGE", f"Parameter: {config_name}", f"Old Value: {self._mask_sensitive_data(old_value)}", - f"New Value: {self._mask_sensitive_data(new_value)}" + f"New Value: {self._mask_sensitive_data(new_value)}", ] - + if kwargs: log_message.append(f"Context: {self._format_json(kwargs)}") - + self.logger.info("\n".join(log_message)) - + def log_info(self, message: str, **kwargs): """Log general information message.""" self._log_with_context("INFO", message, **kwargs) - + def log_debug(self, message: str, **kwargs): """Log debug message.""" self._log_with_context("DEBUG", message, **kwargs) - + def log_warning(self, message: str, **kwargs): """Log warning message.""" self._log_with_context("WARNING", message, **kwargs) - + def log_error(self, message: str, **kwargs): """Log error message.""" self._log_with_context("ERROR", message, **kwargs) - + def _log_with_context(self, level: str, message: str, **kwargs): """Log message with context at specified level.""" - request_id = kwargs.get('request_id', '') + request_id = kwargs.get("request_id", "") prefix = f"[{request_id}] " if request_id else "" - + full_message = f"{prefix}{message}" - + if kwargs: full_message += f"\nContext: {self._format_json(kwargs)}" - + getattr(self.logger, level.lower())(full_message) - + def _generate_request_id(self) -> str: """Generate a unique request ID.""" from uuid import uuid4 + return str(uuid4())[:8] - - def create_child_observer(self, context: Dict[str, Any]) -> 'ContextualLoggingObserver': + + def create_child_observer(self, context: Dict[str, Any]) -> "ContextualLoggingObserver": """ Create a child observer with additional context. - + Args: context: Context to be included in all log messages - + Returns: A contextual logging observer """ @@ -358,121 +391,137 @@ class ContextualLoggingObserver: """ A wrapper around BuckarooLoggingObserver that includes context in all log messages. """ - + def __init__(self, parent: BuckarooLoggingObserver, context: Dict[str, Any]): """ Initialize contextual observer. - + Args: parent: Parent logging observer context: Context to include in all messages """ self.parent = parent self.context = context - - def log_request(self, method: str, url: str, headers: Optional[Dict[str, str]] = None, - body: Optional[Any] = None, **kwargs): + + def log_request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + body: Optional[Any] = None, + **kwargs, + ): """Log request with context.""" merged_kwargs = {**self.context, **kwargs} self.parent.log_request(method, url, headers, body, **merged_kwargs) - - def log_response(self, status_code: int, headers: Optional[Dict[str, str]] = None, - body: Optional[Any] = None, duration_ms: Optional[float] = None, - **kwargs): + + def log_response( + self, + status_code: int, + headers: Optional[Dict[str, str]] = None, + body: Optional[Any] = None, + duration_ms: Optional[float] = None, + **kwargs, + ): """Log response with context.""" merged_kwargs = {**self.context, **kwargs} self.parent.log_response(status_code, headers, body, duration_ms, **merged_kwargs) - - def log_exception(self, exception: Exception, context: Optional[Dict[str, Any]] = None, - **kwargs): + + def log_exception( + self, exception: Exception, context: Optional[Dict[str, Any]] = None, **kwargs + ): """Log exception with context.""" merged_context = {**self.context} if context: merged_context.update(context) merged_kwargs = {**kwargs} self.parent.log_exception(exception, merged_context, **merged_kwargs) - - def log_payment_operation(self, operation: str, payment_method: str, - amount: Optional[float] = None, currency: Optional[str] = None, - **kwargs): + + def log_payment_operation( + self, + operation: str, + payment_method: str, + amount: Optional[float] = None, + currency: Optional[str] = None, + **kwargs, + ): """Log payment operation with context.""" merged_kwargs = {**self.context, **kwargs} - self.parent.log_payment_operation(operation, payment_method, amount, currency, - **merged_kwargs) - + self.parent.log_payment_operation( + operation, payment_method, amount, currency, **merged_kwargs + ) + def log_info(self, message: str, **kwargs): """Log info with context.""" merged_kwargs = {**self.context, **kwargs} self.parent.log_info(message, **merged_kwargs) - + def log_debug(self, message: str, **kwargs): """Log debug with context.""" merged_kwargs = {**self.context, **kwargs} self.parent.log_debug(message, **merged_kwargs) - + def log_warning(self, message: str, **kwargs): """Log warning with context.""" merged_kwargs = {**self.context, **kwargs} self.parent.log_warning(message, **merged_kwargs) - + def log_error(self, message: str, **kwargs): """Log error with context.""" merged_kwargs = {**self.context, **kwargs} self.parent.log_error(message, **merged_kwargs) -def create_logger(level: LogLevel = LogLevel.INFO, - destination: LogDestination = LogDestination.BOTH, - log_file: str = "buckaroo.log", - **kwargs) -> BuckarooLoggingObserver: +def create_logger( + level: LogLevel = LogLevel.INFO, + destination: LogDestination = LogDestination.BOTH, + log_file: str = "buckaroo.log", + **kwargs, +) -> BuckarooLoggingObserver: """ Convenience function to create a logging observer. - + Args: level: Log level destination: Where to send logs log_file: Log file name (if file logging enabled) **kwargs: Additional configuration options - + Returns: Configured logging observer """ - config = LogConfig( - level=level, - destination=destination, - log_file=log_file, - **kwargs - ) + config = LogConfig(level=level, destination=destination, log_file=log_file, **kwargs) return BuckarooLoggingObserver(config) def create_logger_from_env() -> BuckarooLoggingObserver: """ Create logger from environment variables. - + Environment variables: BUCKAROO_LOG_LEVEL: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) BUCKAROO_LOG_DESTINATION: Destination (stdout, file, both) BUCKAROO_LOG_FILE: Log file path BUCKAROO_LOG_MASK_SENSITIVE: Whether to mask sensitive data (true/false) - + Returns: Configured logging observer """ level_str = os.getenv("BUCKAROO_LOG_LEVEL", "INFO").upper() - level = LogLevel(level_str) if level_str in [l.value for l in LogLevel] else LogLevel.INFO - + level = LogLevel(level_str) if level_str in [lv.value for lv in LogLevel] else LogLevel.INFO + dest_str = os.getenv("BUCKAROO_LOG_DESTINATION", "both").lower() - destination = LogDestination(dest_str) if dest_str in [d.value for d in LogDestination] else LogDestination.BOTH - + destination = ( + LogDestination(dest_str) + if dest_str in [d.value for d in LogDestination] + else LogDestination.BOTH + ) + log_file = os.getenv("BUCKAROO_LOG_FILE", "buckaroo_sdk.log") mask_sensitive = os.getenv("BUCKAROO_LOG_MASK_SENSITIVE", "true").lower() == "true" - + config = LogConfig( - level=level, - destination=destination, - log_file=log_file, - mask_sensitive_data=mask_sensitive + level=level, destination=destination, log_file=log_file, mask_sensitive_data=mask_sensitive ) - - return BuckarooLoggingObserver(config) \ No newline at end of file + + return BuckarooLoggingObserver(config) diff --git a/buckaroo/services/__init__.py b/buckaroo/services/__init__.py index 8c14598..0557eb6 100644 --- a/buckaroo/services/__init__.py +++ b/buckaroo/services/__init__.py @@ -1 +1 @@ -# Services module \ No newline at end of file +# Services module diff --git a/buckaroo/services/payment_service.py b/buckaroo/services/payment_service.py index 3827e84..c02d67b 100644 --- a/buckaroo/services/payment_service.py +++ b/buckaroo/services/payment_service.py @@ -1,35 +1,34 @@ - -from typing import Dict, Any from ..factories.payment_method_factory import PaymentMethodFactory from ..builders.payments.payment_builder import PaymentBuilder + class PaymentService(object): """Service for handling payment operations.""" - + def __init__(self, client): """ Initialize the PaymentService. - + Args: client: The Buckaroo client instance """ self._client = client self._factory = PaymentMethodFactory() - + def create_payment(self, method: str, parameters: dict = None) -> PaymentBuilder: """ Create a payment builder for the specified method. - + Args: method (str): The payment method name (e.g., 'ideal', 'creditcard', 'paypal') parameters (dict, optional): Dictionary of parameters to pre-populate the builder - + Returns: PaymentBuilder: A builder instance for the specified payment method - + Raises: ValueError: If the payment method is not supported - + Example: >>> # Using fluent interface only >>> payment = client.payments.create_payment("ideal") \\ @@ -37,7 +36,7 @@ def create_payment(self, method: str, parameters: dict = None) -> PaymentBuilder ... .amount(6.0) \\ ... .description("Test payment") \\ ... .execute() - + >>> # Using parameters dictionary for quick setup >>> payment = client.payments.create_payment("ideal", { ... 'currency': 'EUR', @@ -49,7 +48,7 @@ def create_payment(self, method: str, parameters: dict = None) -> PaymentBuilder ... 'return_url_error': 'https://example.com/error', ... 'return_url_reject': 'https://example.com/reject' ... }).execute() - + >>> # Combining both approaches >>> payment = client.payments.create_payment("ideal", { ... 'currency': 'EUR', @@ -61,46 +60,46 @@ def create_payment(self, method: str, parameters: dict = None) -> PaymentBuilder # If parameters are provided, populate the builder if parameters: builder.from_dict(parameters) - + return builder - + def get_available_methods(self) -> list: """ Get a list of all available payment methods. - + Returns: list: List of available payment method names """ return self._factory.get_available_methods() - + def is_method_supported(self, method: str) -> bool: """ Check if a payment method is supported. - + Args: method (str): The payment method name - + Returns: bool: True if the method is supported, False otherwise """ return self._factory.is_method_supported(method) - + def create(self, payload: dict) -> PaymentBuilder: """ Create a payment builder with auto-detected payment method from payload. - + This method analyzes the payload to automatically determine the appropriate payment method and returns the corresponding payment builder. - + Args: payload (dict): Payment parameters dictionary - + Returns: PaymentBuilder: A builder instance for the detected payment method - + Raises: ValueError: If payment method cannot be determined from payload - + Examples: >>> # iDEAL payment (auto-detected by 'issuer' field) >>> payment = app.payments.create({ @@ -111,7 +110,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'return_url': 'https://example.com/success' ... }) >>> response = payment.execute() - + >>> # Credit card payment (auto-detected by card fields) >>> payment = app.payments.create({ ... 'amount': 15.75, @@ -122,7 +121,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'cvv': '123' ... }) >>> response = payment.execute() - + >>> # Refund operation (separate method call) >>> refund_response = payment.refund('TXN_123', 10.00) """ @@ -130,4 +129,4 @@ def create(self, payload: dict) -> PaymentBuilder: method = self._factory.detect_method_from_payload(payload) # Create payment using the detected method - return self.create_payment(method, payload) \ No newline at end of file + return self.create_payment(method, payload) diff --git a/buckaroo/services/service_parameter_validator.py b/buckaroo/services/service_parameter_validator.py index 66e6e75..c1cb431 100644 --- a/buckaroo/services/service_parameter_validator.py +++ b/buckaroo/services/service_parameter_validator.py @@ -3,50 +3,52 @@ """ from typing import Dict, Any, List -from abc import ABC, abstractmethod from buckaroo.models.payment_request import Parameter -from buckaroo.exceptions._parameter_validation_error import ParameterValidationError, RequiredParameterMissingError +from buckaroo.exceptions._parameter_validation_error import ( + ParameterValidationError, + RequiredParameterMissingError, +) class ServiceParameterValidator: """Handles validation and filtering of service parameters for payment methods.""" - + def __init__(self, payment_builder): """ Initialize validator with payment builder reference. - + Args: payment_builder: The payment builder instance to validate for """ self.payment_builder = payment_builder - + def normalize_parameter_name(self, param_name: str) -> str: """Normalize parameter name to lowercase and remove underscores for matching. - + Also extracts the actual parameter name from dot notation like 'service_parameters.issuer' -> 'issuer' """ # Extract parameter name from dot notation (e.g., 'service_parameters.issuer' -> 'issuer') - if '.' in param_name: - param_name = param_name.split('.')[-1] - return param_name.lower().replace('_', '') - + if "." in param_name: + param_name = param_name.split(".")[-1] + return param_name.lower().replace("_", "") + def validate_parameter_type(self, key: str, value: Any, param_config: Dict[str, Any]) -> None: """ Validate parameter value against expected type. - + Args: key (str): Parameter name value (Any): Parameter value param_config (Dict[str, Any]): Parameter configuration with type info - + Raises: ParameterValidationError: If parameter type is invalid """ - if 'type' not in param_config: + if "type" not in param_config: return - - expected_type = param_config['type'] - + + expected_type = param_config["type"] + # Skip validation for grouped parameters (they're already expanded) # Grouped parameters will have their structure validated before expansion if expected_type in (list, dict): @@ -58,13 +60,13 @@ def validate_parameter_type(self, key: str, value: Any, param_config: Dict[str, if not type_valid: # Allow string representations of booleans for bool types if bool in expected_type and isinstance(value, str): - if value.lower() not in ['true', 'false']: + if value.lower() not in ["true", "false"]: type_names = [t.__name__ for t in expected_type] raise ParameterValidationError( f"Parameter '{key}' must be one of types {type_names} or 'true'/'false' string", parameter_name=key, expected_type=str(type_names), - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) else: type_names = [t.__name__ for t in expected_type] @@ -72,36 +74,36 @@ def validate_parameter_type(self, key: str, value: Any, param_config: Dict[str, f"Parameter '{key}' must be one of types {type_names}, got {type(value).__name__}", parameter_name=key, expected_type=str(type_names), - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) else: if not isinstance(value, expected_type): # Allow string representations of booleans - if expected_type == bool and isinstance(value, str): - if value.lower() not in ['true', 'false']: + if expected_type is bool and isinstance(value, str): + if value.lower() not in ["true", "false"]: raise ParameterValidationError( f"Parameter '{key}' must be a boolean or 'true'/'false' string", parameter_name=key, expected_type=expected_type.__name__, - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) else: raise ParameterValidationError( f"Parameter '{key}' must be of type {expected_type.__name__}, got {type(value).__name__}", parameter_name=key, expected_type=expected_type.__name__, - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) - + def validate_single_parameter(self, key: str, value: Any, action: str = "Pay") -> None: """ Validate a single service parameter against allowed parameters for the specified action. - + Args: key (str): Parameter name value (Any): Parameter value action (str): The action being performed - + Raises: ParameterValidationError: If parameter is not allowed or invalid """ @@ -113,39 +115,41 @@ def validate_single_parameter(self, key: str, value: Any, action: str = "Pay") - f"Allowed parameters: {list(allowed_params.keys())}", parameter_name=key, action=action, - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) param_config = allowed_params[key] self.validate_parameter_type(key, value, param_config) - + def normalize_parameter_value(self, value: str) -> Any: """ Convert parameter value back to appropriate type for validation. - + Args: value (str): String parameter value - + Returns: Any: Converted value (bool for 'true'/'false', otherwise string) """ - if value.lower() in ['true', 'false']: - return value.lower() == 'true' + if value.lower() in ["true", "false"]: + return value.lower() == "true" return value - - def validate_required_parameters(self, parameters: List[Parameter], action: str = "Pay") -> None: + + def validate_required_parameters( + self, parameters: List[Parameter], action: str = "Pay" + ) -> None: """ Validate that all required parameters are provided. - + Args: parameters (List[Parameter]): List of provided parameters action (str): The action being performed - + Raises: RequiredParameterMissingError: If any required parameter is missing """ allowed_params = self.payment_builder.get_allowed_service_parameters(action) - + # Create a normalized lookup for provided parameters # Include both regular parameters and group_types provided_params = {} @@ -153,56 +157,62 @@ def validate_required_parameters(self, parameters: List[Parameter], action: str # Add the parameter name normalized_name = self.normalize_parameter_name(param.name) provided_params[normalized_name] = param.name - + # Also add the group_type if it exists if param.group_type: normalized_group = self.normalize_parameter_name(param.group_type) provided_params[normalized_group] = param.group_type - + # Check each allowed parameter to see if it's required and provided missing_required = [] for param_name, param_config in allowed_params.items(): - if param_config.get('required', False): + if param_config.get("required", False): # For dot notation keys (e.g., 'service_parameters.issuer'), extract the actual param name normalized_param = self.normalize_parameter_name(param_name) if normalized_param not in provided_params: # Use just the parameter name (not full dot notation) in error message - missing_required.append(param_name.split('.')[-1] if '.' in param_name else param_name) - + missing_required.append( + param_name.split(".")[-1] if "." in param_name else param_name + ) + # Throw exception if any required parameters are missing if missing_required: if len(missing_required) == 1: raise RequiredParameterMissingError( parameter_name=missing_required[0], action=action, - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) else: # Multiple missing parameters raise ParameterValidationError( f"Required parameters missing for {self.payment_builder.get_service_name()} {action} action: {', '.join(missing_required)}", action=action, - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) - - def validate_and_filter_parameters(self, parameters: List[Parameter], action: str = "Pay") -> List[Parameter]: + + def validate_and_filter_parameters( + self, parameters: List[Parameter], action: str = "Pay" + ) -> List[Parameter]: """ Validate and filter service parameters, removing invalid ones. - + Args: parameters (List[Parameter]): List of parameters to validate action (str): The action being performed - + Returns: List[Parameter]: Filtered list with only valid parameters """ if not parameters: return [] - + allowed_params = self.payment_builder.get_allowed_service_parameters(action) # Create normalized lookup for allowed parameters - normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} + normalized_allowed = { + self.normalize_parameter_name(key): key for key in allowed_params.keys() + } valid_parameters = [] invalid_params = [] @@ -215,7 +225,9 @@ def validate_and_filter_parameters(self, parameters: List[Parameter], action: st # Grouped parameter is valid - no need to validate individual fields valid_parameters.append(param) else: - invalid_params.append(f"{param.name} (group: {param.group_type}): group not allowed for {self.payment_builder.get_service_name()} {action} action") + invalid_params.append( + f"{param.name} (group: {param.group_type}): group not allowed for {self.payment_builder.get_service_name()} {action} action" + ) else: # Regular parameter - validate including source check normalized_param_name = self.normalize_parameter_name(param.name) @@ -225,14 +237,20 @@ def validate_and_filter_parameters(self, parameters: List[Parameter], action: st try: # Use the original allowed parameter name for validation allowed_param_name = normalized_allowed[normalized_param_name] - + # Check if source matches requirement - requires_service_params = allowed_param_name.startswith('service_parameters.') + requires_service_params = allowed_param_name.startswith( + "service_parameters." + ) if requires_service_params and not is_from_service_params: - invalid_params.append(f"{param.name}: must be in service_parameters dict") + invalid_params.append( + f"{param.name}: must be in service_parameters dict" + ) continue elif not requires_service_params and is_from_service_params: - invalid_params.append(f"{param.name}: should be at top-level, not in service_parameters") + invalid_params.append( + f"{param.name}: should be at top-level, not in service_parameters" + ) continue # Convert parameter value for validation @@ -243,25 +261,31 @@ def validate_and_filter_parameters(self, parameters: List[Parameter], action: st except ParameterValidationError as e: invalid_params.append(f"{param.name}: {str(e)}") else: - invalid_params.append(f"{param.name}: not allowed for {self.payment_builder.get_service_name()} {action} action") - + invalid_params.append( + f"{param.name}: not allowed for {self.payment_builder.get_service_name()} {action} action" + ) + if invalid_params: - print(f"Warning: Filtered out invalid service parameters for {action} action: {invalid_params}") - + print( + f"Warning: Filtered out invalid service parameters for {action} action: {invalid_params}" + ) + return valid_parameters - - def validate_all_parameters(self, parameters: List[Parameter], action: str = "Pay", strict: bool = True) -> List[Parameter]: + + def validate_all_parameters( + self, parameters: List[Parameter], action: str = "Pay", strict: bool = True + ) -> List[Parameter]: """ Validate all service parameters including required parameter checks. - + Args: parameters (List[Parameter]): List of parameters to validate action (str): The action being performed strict (bool): If True, throws exceptions for validation errors. If False, filters invalid parameters. - + Returns: List[Parameter]: Validated parameters (if strict=False, invalid ones are filtered out) - + Raises: RequiredParameterMissingError: If required parameters are missing (when strict=True) ParameterValidationError: If parameters are invalid (when strict=True) @@ -269,13 +293,15 @@ def validate_all_parameters(self, parameters: List[Parameter], action: str = "Pa if strict: # First validate that all required parameters are present self.validate_required_parameters(parameters, action) - + # Then validate each parameter individually - strict mode throws exceptions for param in parameters: normalized_param_name = self.normalize_parameter_name(param.name) allowed_params = self.payment_builder.get_allowed_service_parameters(action) - normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} - + normalized_allowed = { + self.normalize_parameter_name(key): key for key in allowed_params.keys() + } + if normalized_param_name in normalized_allowed: allowed_param_name = normalized_allowed[normalized_param_name] param_value = self.normalize_parameter_value(param.value) @@ -285,58 +311,62 @@ def validate_all_parameters(self, parameters: List[Parameter], action: str = "Pa f"Parameter '{param.name}' is not allowed for {self.payment_builder.get_service_name()} {action} action", parameter_name=param.name, action=action, - service_name=self.payment_builder.get_service_name() + service_name=self.payment_builder.get_service_name(), ) - + return parameters else: # Non-strict mode: just filter out invalid parameters and check required ones at the end valid_parameters = self.validate_and_filter_parameters(parameters, action) self.validate_required_parameters(valid_parameters, action) return valid_parameters - + def get_parameter_info(self, action: str = "Pay") -> Dict[str, Any]: """ Get information about allowed parameters for an action. - + Args: action (str): The action to get parameter info for - + Returns: Dict[str, Any]: Parameter information including types and requirements """ return self.payment_builder.get_allowed_service_parameters(action) - + def is_parameter_allowed(self, param_name: str, action: str = "Pay") -> bool: """ Check if a parameter is allowed for the given action. - + Args: param_name (str): Parameter name to check action (str): The action being performed - + Returns: bool: True if parameter is allowed, False otherwise """ allowed_params = self.payment_builder.get_allowed_service_parameters(action) - normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} + normalized_allowed = { + self.normalize_parameter_name(key): key for key in allowed_params.keys() + } normalized_param = self.normalize_parameter_name(param_name) - + return normalized_param in normalized_allowed - + def get_normalized_parameter_name(self, param_name: str, action: str = "Pay") -> str: """ Get the official parameter name that matches the input (after normalization). - + Args: param_name (str): Input parameter name action (str): The action being performed - + Returns: str: Official parameter name, or empty string if not found """ allowed_params = self.payment_builder.get_allowed_service_parameters(action) - normalized_allowed = {self.normalize_parameter_name(key): key for key in allowed_params.keys()} + normalized_allowed = { + self.normalize_parameter_name(key): key for key in allowed_params.keys() + } normalized_param = self.normalize_parameter_name(param_name) - - return normalized_allowed.get(normalized_param, "") \ No newline at end of file + + return normalized_allowed.get(normalized_param, "") diff --git a/buckaroo/services/solution_service.py b/buckaroo/services/solution_service.py index 8bc5322..3834e97 100644 --- a/buckaroo/services/solution_service.py +++ b/buckaroo/services/solution_service.py @@ -1,12 +1,10 @@ - -from typing import Dict, Any from ..factories.solution_method_factory import SolutionMethodFactory from ..builders.payments.payment_builder import PaymentBuilder class SolutionService(object): """Service for handling solution operations.""" - + def __init__(self, client): """ Initialize the SolutionService. @@ -16,21 +14,21 @@ def __init__(self, client): """ self._client = client self._factory = SolutionMethodFactory() - + def create_solution(self, method: str, parameters: dict = None) -> PaymentBuilder: """ Create a payment builder for the specified method. - + Args: method (str): The payment method name (e.g., 'ideal', 'creditcard', 'paypal') parameters (dict, optional): Dictionary of parameters to pre-populate the builder - + Returns: PaymentBuilder: A builder instance for the specified payment method - + Raises: ValueError: If the payment method is not supported - + Example: >>> # Using fluent interface only >>> payment = client.solution.create_payment("ideal") \\ @@ -38,7 +36,7 @@ def create_solution(self, method: str, parameters: dict = None) -> PaymentBuilde ... .amount(6.0) \\ ... .description("Test payment") \\ ... .execute() - + >>> # Using parameters dictionary for quick setup >>> payment = client.solution.create_payment("ideal", { ... 'currency': 'EUR', @@ -50,7 +48,7 @@ def create_solution(self, method: str, parameters: dict = None) -> PaymentBuilde ... 'return_url_error': 'https://example.com/error', ... 'return_url_reject': 'https://example.com/reject' ... }).execute() - + >>> # Combining both approaches >>> payment = client.solution.create_solution("ideal", { ... 'currency': 'EUR', @@ -58,50 +56,50 @@ def create_solution(self, method: str, parameters: dict = None) -> PaymentBuilde ... }).description("Updated description").execute() """ builder = self._factory.create_builder(method, self._client) - + # If parameters are provided, populate the builder if parameters: builder.from_dict(parameters) - + return builder - + def get_available_methods(self) -> list: """ Get a list of all available payment methods. - + Returns: list: List of available payment method names """ return self._factory.get_available_methods() - + def is_method_supported(self, method: str) -> bool: """ Check if a payment method is supported. - + Args: method (str): The payment method name - + Returns: bool: True if the method is supported, False otherwise """ return self._factory.is_method_supported(method) - + def create(self, payload: dict) -> PaymentBuilder: """ Create a payment builder with auto-detected payment method from payload. - + This method analyzes the payload to automatically determine the appropriate payment method and returns the corresponding payment builder. - + Args: payload (dict): Payment parameters dictionary - + Returns: PaymentBuilder: A builder instance for the detected payment method - + Raises: ValueError: If payment method cannot be determined from payload - + Examples: >>> # iDEAL payment (auto-detected by 'issuer' field) >>> payment = app.payments.create({ @@ -112,7 +110,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'return_url': 'https://example.com/success' ... }) >>> response = payment.execute() - + >>> # Credit card payment (auto-detected by card fields) >>> payment = app.payments.create({ ... 'amount': 15.75, @@ -123,7 +121,7 @@ def create(self, payload: dict) -> PaymentBuilder: ... 'cvv': '123' ... }) >>> response = payment.execute() - + >>> # Refund operation (separate method call) >>> refund_response = payment.refund('TXN_123', 10.00) """ @@ -131,4 +129,4 @@ def create(self, payload: dict) -> PaymentBuilder: method = self._factory.detect_method_from_payload(payload) # Create payment using the detected method - return self.create_solution(method, payload) \ No newline at end of file + return self.create_solution(method, payload) diff --git a/docker-compose.yml b/docker-compose.yml index 79a4934..f2abd63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: working_dir: /app env_file: - .env - command: sh -c "pip install --root-user-action=ignore -r requirements.txt && tail -f /dev/null" # Install requirements then keep running + command: sh -c "pip install --root-user-action=ignore -r requirements-dev.txt && tail -f /dev/null" # Install dev requirements then keep running tty: true stdin_open: true networks: diff --git a/examples/demo_app_wrapper.py b/examples/demo_app_wrapper.py index d10137c..ed4e635 100644 --- a/examples/demo_app_wrapper.py +++ b/examples/demo_app_wrapper.py @@ -1,412 +1,189 @@ #!/usr/bin/env python3 -""" -Simplified demo using Buckaroo App wrapper. +"""Demo of the Buckaroo app wrapper. -This demo shows how to use the BuckarooApp wrapper which handles -logging initialization automatically and provides convenient methods. +Shows the four supported ways to construct ``Buckaroo`` and drive a payment +through ``PaymentService`` / ``SolutionService``. All demos are gated on +``BUCKAROO_STORE_KEY`` / ``BUCKAROO_SECRET_KEY`` env vars. """ import os import sys -# Add parent directory to Python path to import buckaroo module -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +# Add parent directory to Python path so the demo can import the SDK in-place. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from buckaroo.app import Buckaroo, BuckarooConfig -from buckaroo.observers import LogLevel, LogDestination - - -def demo_with_app_wrapper(): - """Demonstrate payments using the Buckaroo app wrapper.""" - - print("BUCKAROO APP WRAPPER DEMO") - print("=" * 50) - - # Method 1: Quick setup (minimal configuration) - print("\n1. Quick Setup Demo:") - print("-" * 30) - - store_key = os.getenv("BUCKAROO_STORE_KEY") - secret_key = os.getenv("BUCKAROO_SECRET_KEY") - - if not store_key or not secret_key: - print("⚠️ Please set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY environment variables") - return - - try: - # Quick setup - logger is automatically initialized - app = Buckaroo() +from buckaroo.observers import LogDestination, LogLevel - # Logger is already available, no need to initialize - app.log_info("Quick setup demo started") - - # payment = app.payments.create({ - # "method": "klarnakp", # Payment method - # # "voucher_name": "MonizzeGiftVoucher", - # # "giftcard_name": "Boekenbon", # Giftcard name - # # "brand": "visa", # Card brand - # # "amount": 25.50, - # "currency": "EUR", - # "invoice": "QUICK-001", - # # "description": "Quick setup demo payment", - # # "return_url": "https://www.buckaroo.nl", - # # "return_url_cancel": "https://www.buckaroo.nl/cancel", - # # "return_url_error": "https://www.buckaroo.nl/error", - # # "return_url_reject": "https://www.buckaroo.nl/reject", - # # "original_transaction_key": "TXN_123", - # # "PaymentData": "Lorem", - # # "CustomerCardName": "Ipsum", - # "originalTransactionKey": "d91f5f42-f011-4611-9575-77bb0446d7d2", - # "service_parameters": { - # "originalTransactionKey": "d91f5f42-f011-4611-9575-77bb0446d7d2", - # # "issuer": "ABNANL2A", - # # "amountIsChangeable": False, - # # "purchaseId": "ORDER1002", - # # "description": "Order #1001 payment", - # # "isOneOff": True, - # # "expiration": "2026-12-31", - # # "imageSize": "300", - # # "Consumeremail": "customer@example.com", - # # "customerfirstname": "John", - # # "customerlastname": "Doe", - # # "customeraccountname": "John Doe", - # # "customeriban": "NL91ABNA0417164300", - # # "customerCountryCode": "NL", - # # "billingCustomer": { - # # "category": "Person", - # # "gender": "male", - # # "firstName": "John", - # # "lastName": "Doe", - # # "email": "customer@example.com", - # # "phone": "0612345678", - # # "street": "Main Street", - # # "streetNumber": "12", - # # "city": "Amsterdam", - # # "postalCode": "1234AB", - # # "country": "NL" - # # }, - # # "shippingCustomer": { - # # "firstName": "John", - # # "lastName": "Doe", - # # "street": "Main Street", - # # "email": "customer@example.com", - # # "streetNumber": "12", - # # "city": "Amsterdam", - # # "postalCode": "1234AB", - # # "country": "NL" - # # }, - # # "operatingCountry": "NL", - # "article": [ - # { - # "articleNumber": "12345", - # "articleTitle": "Product 1", - # "articleType": "Article", - # "articlePrice": 10.00 - # }, - # { - # "articleNumber": "67890", - # "articleTitle": "Product 2", - # "articleType": "Article", - # "articlePrice": 5.50 - # } - # ] - # } - # }) - # Create In3 payment using factory pattern - auto-detected by 'issuer' field - # payment = app.payments.create({ - # "method": "przelewy24", # Payment method - # "giftcard_name": "Boekenbon", # Giftcard name - # "brand": "visa", # Card brand - # "amount": 25.50, - # "currency": "EUR", - # "invoice": "QUICK-001", - # "description": "Quick setup demo payment", - # "return_url": "https://www.buckaroo.nl", - # "return_url_cancel": "https://www.buckaroo.nl/cancel", - # "return_url_error": "https://www.buckaroo.nl/error", - # "return_url_reject": "https://www.buckaroo.nl/reject", - # # "original_transaction_key": "TXN_123", - # # "PaymentData": "Lorem", - # # "CustomerCardName": "Ipsum", - # "service_parameters": { - # # "issuer": "ABNANL2A", - # "billingCustomer": { - # "category": "B2C", - # "customerNumber": "CUST-001", - # "lastName": "Doe", - # "email": "customer@example.com", - # "phone": "0612345678", - # "street": "Main Street", - # "streetNumber": "12", - # "city": "Amsterdam", - # "postalCode": "1234AB", - # "countryCode": "NL" - # }, - # "shippingCustomer": { - # "street": "Main Street", - # "streetNumber": "12", - # "city": "Amsterdam", - # "postalCode": "1234AB", - # "countryCode": "NL" - # }, - # "article": [ - # { - # "category": "Books", - # "description": "Product 1", - # "quantity": 1, - # "grossUnitPrice": 10.00 - # }, - # { - # "category": "Toy Cars", - # "description": "Product 2", - # "quantity": 3, - # "grossUnitPrice": 5.50 - # } - # ] - # } - # }) +def _have_credentials() -> bool: + if not os.getenv("BUCKAROO_STORE_KEY") or not os.getenv("BUCKAROO_SECRET_KEY"): + print("⚠️ Set BUCKAROO_STORE_KEY and BUCKAROO_SECRET_KEY to run this demo") + return False + return True - # response = payment.refund(validate=True) # validate=True is default +def demo_quick_setup() -> None: + """Minimal bootstrap: one call, ready to go.""" + print("\n1. Quick setup") + print("-" * 40) + if not _have_credentials(): + return - solution = app.solutions.create({ - "method": "subscription", # Payment method - }) + try: + app = Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + mode="test", + ) + app.log_info("quick setup demo started") - response = solution.createSubscription(validate=True) + # Drive a subscription via the solutions service. SolutionBuilder.createSubscription + # returns a PaymentResponse with the pending-redirect contract. + solution = app.solutions.create({"method": "subscription"}) + response = solution.createSubscription(validate=False) - print(response.to_dict()) - # Execute refund - values from payload (no parameters needed) - # response = payment.refund() # Uses original_transaction_key and refund_amount from payload - # print(response) - # Or override payload values with parameters - # response = payment.refund("DIFFERENT_TXN_123", 10.00) # Override with specific values - - # print(f"✅ Payment builder created: {type(payment).__name__}") - # print(" Methods can use payload values or parameters:") - # print(" - payment.execute() for new payment") - # print(" - payment.refund() uses payload 'original_transaction_key' and 'refund_amount'") - # print(" - payment.refund('TXN_KEY', amount) to override payload values") - # print(" - payment.capture() uses payload 'authorization_key' and 'capture_amount'") - # print(" - payment.cancel() uses payload 'cancel_key' or 'original_transaction_key'") - - # # Show payload values that would be used - # print(f"\n Payload values available:") - # print(f" - original_transaction_key: {payment._payload.get('original_transaction_key')}") - # print(f" - refund_amount: {payment._payload.get('refund_amount')}") - # print(f" - issuer: {payment._payload.get('issuer')}") - - # # Show additional payload examples - # print("\n Additional payload examples:") - - # # Capture example with payload values - # capture_payment = app.payments.create({ - # "amount": 100.00, - # "currency": "EUR", - # "invoice": "CAPTURE-001", - # "description": "Capture demo", - # "return_url": "https://www.buckaroo.nl", - # "return_url_cancel": "https://www.buckaroo.nl/cancel", - # "return_url_error": "https://www.buckaroo.nl/error", - # "return_url_reject": "https://www.buckaroo.nl/reject", - # "authorization_key": "AUTH_456", # For capture operations - # "capture_amount": 75.00, # Partial capture amount - # "card_number": "1234567890123456" # Credit card payment - # }) - # print(" Created capture payment with authorization_key and capture_amount") - # print(f" - Authorization key: {capture_payment._payload.get('authorization_key')}") - # print(f" - Capture amount: {capture_payment._payload.get('capture_amount')}") - # # capture_payment.capture() # Would use AUTH_456 and 75.00 from payload - - # # Cancel example with payload values - # cancel_payment = app.payments.create({ - # "amount": 50.00, - # "currency": "EUR", - # "invoice": "CANCEL-001", - # "description": "Cancel demo", - # "return_url": "https://www.buckaroo.nl", - # "return_url_cancel": "https://www.buckaroo.nl/cancel", - # "return_url_error": "https://www.buckaroo.nl/error", - # "return_url_reject": "https://www.buckaroo.nl/reject", - # "cancel_key": "PENDING_789", # For cancel operations - # "issuer": "ABNANL2A" - # }) - # print(" Created cancel payment with cancel_key") - # print(f" - Cancel key: {cancel_payment._payload.get('cancel_key')}") - # # cancel_payment.cancel() # Would use PENDING_789 from payload - - app.log_info("Quick setup demo completed successfully") - + print(f" status.code={response.status.code.code} key={response.key}") + app.log_info("quick setup demo finished") except Exception as e: - print(f"❌ Error: {e}") - if 'app' in locals(): - app.log_exception(e) + print(f" ❌ {e}") -def demo_with_environment_config(): - """Demonstrate using environment-based configuration.""" - - print("\n2. Environment Configuration Demo:") +def demo_from_env() -> None: + """Construct Buckaroo from the ``BUCKAROO_*`` environment variables.""" + print("\n2. Environment config (Buckaroo.from_env)") print("-" * 40) - + if not _have_credentials(): + return + try: - # Create app from environment variables - # This automatically reads all BUCKAROO_* environment variables app = Buckaroo.from_env() - - app.log_info("Environment-based app started") - - # Create payment using the generic method - payment_data = { - 'currency': 'EUR', - 'amount': 15.75, - 'description': 'Environment config demo', - 'invoice': 'ENV-DEMO-001', - 'return_url': 'https://www.buckaroo.nl', - 'return_url_cancel': 'https://www.buckaroo.nl/cancel', - 'return_url_error': 'https://www.buckaroo.nl/error', - 'return_url_reject': 'https://www.buckaroo.nl/reject', - 'issuer': 'ABNANL2A' - } - - payment = app.create_payment("ideal", payment_data) - response = app.execute_payment(payment) - - print("✅ Environment-based configuration worked!") - app.log_info("Environment demo completed") - + app.log_info("env-config demo started") + + # create_payment(method, params) dispatches through the PaymentMethodFactory + # and returns a ready-to-execute PaymentBuilder. + builder = app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 15.75, + "description": "env-config demo", + "invoice": "ENV-DEMO-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": {"issuer": "ABNANL2A"}, + }, + ) + response = builder.pay() + + print(f" is_pending={response.is_pending()} redirect={response.get_redirect_url()}") + app.log_info("env-config demo finished") except Exception as e: - print(f"❌ Error: {e}") - if 'app' in locals(): - app.log_exception(e) + print(f" ❌ {e}") -def demo_with_custom_config(): - """Demonstrate using custom configuration.""" - - print("\n3. Custom Configuration Demo:") - print("-" * 35) - - store_key = os.getenv("BUCKAROO_STORE_KEY") - secret_key = os.getenv("BUCKAROO_SECRET_KEY") - - if not store_key or not secret_key: - print("⚠️ Skipping - credentials not available") +def demo_custom_config() -> None: + """Construct Buckaroo with a hand-built ``BuckarooConfig``.""" + print("\n3. Custom config") + print("-" * 40) + if not _have_credentials(): return - + try: - # Create custom configuration config = BuckarooConfig( - store_key=store_key, - secret_key=secret_key, + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], mode="test", enable_logging=True, log_level=LogLevel.DEBUG, log_destination=LogDestination.STDOUT, mask_sensitive_data=True, timeout=45, - retry_attempts=5 + retry_attempts=5, ) - app = Buckaroo(config) - - app.log_info("Custom configuration demo started") - - # Create payment with child logger (adds context to all logs) - session_context = { - "session_id": "sess_custom_001", - "user_id": "demo_user", - "demo_type": "custom_config" - } - - child_logger = app.create_child_logger(session_context) - - if child_logger: - child_logger.log_info("Starting payment with custom context") - - payment = app.create_ideal_payment( - amount=42.00, - currency="EUR", - description="Custom config demo payment", - invoice="CUSTOM-001" + + # create_child_logger adds structured context to every subsequent log line. + child = app.create_child_logger({"session_id": "sess_custom_001", "demo": "custom_config"}) + if child: + child.log_info("payment flow started with session context") + + builder = app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 42.00, + "description": "custom-config demo", + "invoice": "CUSTOM-001", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": {"issuer": "ABNANL2A"}, + }, ) - - response = app.execute_payment(payment) - - print("✅ Custom configuration demo completed!") - app.log_info("Custom config demo finished") - + response = builder.pay() + print(f" status.code={response.status.code.code} key={response.key}") except Exception as e: - print(f"❌ Error: {e}") - if 'app' in locals(): - app.log_exception(e) + print(f" ❌ {e}") -def demo_with_context_manager(): - """Demonstrate using app as context manager.""" - - print("\n4. Context Manager Demo:") - print("-" * 30) - - store_key = os.getenv("BUCKAROO_STORE_KEY") - secret_key = os.getenv("BUCKAROO_SECRET_KEY") - - if not store_key or not secret_key: - print("⚠️ Skipping - credentials not available") +def demo_context_manager() -> None: + """``Buckaroo`` supports ``with`` — logs entry + exit automatically.""" + print("\n4. Context manager") + print("-" * 40) + if not _have_credentials(): return - + try: - # Use app as context manager - with Buckaroo.quick_setup(store_key, secret_key, log_to_stdout=True) as app: - app.log_info("Context manager demo started") - - # Multiple operations within the context + with Buckaroo.quick_setup( + store_key=os.environ["BUCKAROO_STORE_KEY"], + secret_key=os.environ["BUCKAROO_SECRET_KEY"], + ) as app: + app.log_info("context-manager demo started") + for i in range(2): - app.log_info(f"Creating payment {i+1}") - - payment = app.create_ideal_payment( - amount=10.00 + i, - currency="EUR", - description=f"Context demo payment {i+1}", - invoice=f"CTX-{i+1:03d}" + builder = app.payments.create_payment( + "ideal", + { + "currency": "EUR", + "amount": 10.00 + i, + "description": f"ctx demo {i + 1}", + "invoice": f"CTX-{i + 1:03d}", + "return_url": "https://www.buckaroo.nl", + "return_url_cancel": "https://www.buckaroo.nl/cancel", + "return_url_error": "https://www.buckaroo.nl/error", + "return_url_reject": "https://www.buckaroo.nl/reject", + "service_parameters": {"issuer": "ABNANL2A"}, + }, ) - - # Simulate processing - app.log_info(f"Processing payment {i+1}") - - print("✅ Context manager demo completed!") - + response = builder.pay() + app.log_info(f"payment {i + 1} dispatched", code=response.status.code.code) except Exception as e: - print(f"❌ Error: {e}") + print(f" ❌ {e}") -def main(): - """Run all demos.""" - print("BUCKAROO SDK - APP WRAPPER DEMOS") +def main() -> None: + print("BUCKAROO SDK — APP WRAPPER DEMOS") print("=" * 60) - - print("\n📋 Available Logging Environment Variables:") - print("- BUCKAROO_LOG_LEVEL=DEBUG|INFO|WARNING|ERROR") - print("- BUCKAROO_LOG_DESTINATION=stdout|file|both") - print("- BUCKAROO_LOG_FILE=custom.log") - print("- BUCKAROO_LOG_MASK_SENSITIVE=true|false") - - demo_with_app_wrapper() - # demo_with_environment_config() - # demo_with_custom_config() - # demo_with_context_manager() - + print("Env vars read by BuckarooConfig.from_env():") + print(" BUCKAROO_STORE_KEY, BUCKAROO_SECRET_KEY, BUCKAROO_MODE") + print(" BUCKAROO_LOG_LEVEL={DEBUG|INFO|WARNING|ERROR}") + print(" BUCKAROO_LOG_DESTINATION={stdout|file|both}") + print(" BUCKAROO_LOG_FILE=/path/to/log") + print(" BUCKAROO_LOG_MASK_SENSITIVE={true|false}") + print(" BUCKAROO_TIMEOUT, BUCKAROO_RETRY_ATTEMPTS") + + demo_quick_setup() + demo_from_env() + demo_custom_config() + demo_context_manager() + print("\n" + "=" * 60) - print("🎉 ALL DEMOS COMPLETED!") - print("The Buckaroo wrapper automatically handles:") - print("✅ Logging initialization") - print("✅ Client setup") - print("✅ Automatic payment logging") - print("✅ Exception handling") - print("✅ Environment configuration") - print("=" * 60) + print("done.") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0affbcc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-ra" + +[tool.coverage.run] +source = ["buckaroo"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.coverage.paths] +source = ["buckaroo", "*/buckaroo"] + +[tool.ruff] +line-length = 100 +target-version = "py38" +extend-exclude = ["examples", "plans"] + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = ["E501"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4a8fc3e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest>=7.0 +pytest-cov>=4.0 +coverage[toml]>=7.0 diff --git a/setup.py b/setup.py index 12446af..7b9c0a5 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ version_contents = {} with open(os.path.join(ROOT_DIR, "buckaroo", "_version.py"), encoding="utf-8") as f: exec(f.read(), version_contents) - + setup( name="buckaroo-sdk-python", version=version_contents["VERSION"], @@ -26,13 +26,10 @@ package_data={"buckaroo": ["data/ca-certificates.crt", "py.typed"]}, zip_safe=False, install_requires=[ - 'typing_extensions <= 4.2.0, > 3.7.2; python_version < "3.7"', - # The best typing support comes from 4.5.0+ but we can support down to - # 3.7.2 without throwing exceptions. - 'typing_extensions >= 4.5.0; python_version >= "3.7"', - 'requests >= 2.20; python_version >= "3.0"', + "typing_extensions >= 4.5.0", + "requests >= 2.20", ], - python_requires=">=3.6", + python_requires=">=3.8", project_urls={ "Bug Tracker": "https://github.com/buckaroo-it/BuckarooSDK_Python/issues", "Changes": "https://github.com/buckaroo-it/BuckarooSDK_Python//blob/master/CHANGELOG.md", @@ -47,16 +44,15 @@ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries :: Python Modules", ], setup_requires=["wheel"], -) \ No newline at end of file +) diff --git a/buckaroo/services/transaction_service.py b/tests/__init__.py similarity index 100% rename from buckaroo/services/transaction_service.py rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5a91996 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +"""Shared pytest configuration and fixtures for the Buckaroo SDK test suite.""" + +import pytest + +from tests.support.recording_mock import RecordingMock + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Stash per-phase report on the item so fixtures can skip asserts on failure.""" + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) + + +@pytest.fixture +def mock_strategy(request): + """Fresh :class:`RecordingMock` per test — records outgoing calls so feature + helpers can assert the wire-level ``Action``. Subclass of ``MockBuckaroo`` so + it's a drop-in wherever the old fixture was used. + """ + mock = RecordingMock() + yield mock + rep_call = getattr(request.node, "rep_call", None) + if rep_call is not None and rep_call.failed: + return + mock.assert_all_consumed() diff --git a/tests/feature/__init__.py b/tests/feature/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/feature/conftest.py b/tests/feature/conftest.py new file mode 100644 index 0000000..e2b9852 --- /dev/null +++ b/tests/feature/conftest.py @@ -0,0 +1,51 @@ +"""Shared fixtures for feature tests.""" + +import pytest + +from buckaroo.app import Buckaroo, BuckarooConfig +from tests.support.recording_mock import RecordingMock + + +@pytest.fixture +def buckaroo(mock_strategy): + """Buckaroo app with MockBuckaroo injected as HTTP strategy.""" + app = Buckaroo( + BuckarooConfig( + store_key="test_store_key", + secret_key="test_secret_key", + mode="test", + enable_logging=False, + ) + ) + app.client.http_client.http_strategy = mock_strategy + return app + + +@pytest.fixture +def recording_mock(request): + """Fresh :class:`RecordingMock` per test, asserts-consumed on clean teardown. + + Use together with :func:`recording_buckaroo` when a feature test needs to + assert on the exact JSON that reached the wire (Action, Parameters, etc.). + """ + mock = RecordingMock() + yield mock + rep_call = getattr(request.node, "rep_call", None) + if rep_call is not None and rep_call.failed: + return + mock.assert_all_consumed() + + +@pytest.fixture +def recording_buckaroo(recording_mock): + """Buckaroo app with :class:`RecordingMock` injected as HTTP strategy.""" + app = Buckaroo( + BuckarooConfig( + store_key="test_store_key", + secret_key="test_secret_key", + mode="test", + enable_logging=False, + ) + ) + app.client.http_client.http_strategy = recording_mock + return app diff --git a/tests/feature/error_paths/__init__.py b/tests/feature/error_paths/__init__.py new file mode 100644 index 0000000..9cc8244 --- /dev/null +++ b/tests/feature/error_paths/__init__.py @@ -0,0 +1,7 @@ +"""HTTP error-path feature tests. + +These tests exercise only ``create_payment("ideal", ...)`` because error +mapping (auth failures, server errors, malformed bodies) lives in the HTTP +client and is method-agnostic. Per-method duplication would be wasteful; +a single representative method covers the contract. +""" diff --git a/tests/feature/error_paths/test_auth_failure.py b/tests/feature/error_paths/test_auth_failure.py new file mode 100644 index 0000000..9dbe403 --- /dev/null +++ b/tests/feature/error_paths/test_auth_failure.py @@ -0,0 +1,46 @@ +import pytest + +from buckaroo.exceptions._authentication_error import AuthenticationError +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestAuthFailure: + """Verify that 401 / 403 responses surface as AuthenticationError.""" + + @pytest.mark.parametrize( + "status,match,invoice", + [ + (401, "store key and secret key", "INV-AUTH-001"), + (403, "Access forbidden", "INV-AUTH-403"), + ], + ) + def test_auth_failure_raises_authentication_error( + self, buckaroo, mock_strategy, status, match, invoice + ): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction", + { + "Key": None, + "Status": { + "Code": {"Code": 491, "Description": "Validation failure"}, + "SubCode": {"Code": "S001", "Description": "Authentication failed"}, + "DateTime": "2024-01-01T00:00:00", + }, + "RequiredAction": None, + "Services": [], + }, + status=status, + ) + ) + + with pytest.raises(AuthenticationError, match=match): + buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice=invoice, + description=f"Auth failure {status} test", + ), + ).pay() diff --git a/tests/feature/error_paths/test_malformed_response.py b/tests/feature/error_paths/test_malformed_response.py new file mode 100644 index 0000000..d9fb26e --- /dev/null +++ b/tests/feature/error_paths/test_malformed_response.py @@ -0,0 +1,79 @@ +"""Tests for malformed (non-JSON) response handling.""" + +import json + +import pytest + +from buckaroo.http.client import BuckarooApiError, BuckarooResponse +from buckaroo.http.strategies.http_strategy import HttpResponse +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestMalformedResponse: + """Verify that non-JSON response bodies raise BuckarooApiError.""" + + def test_malformed_json_raises_error(self, buckaroo, mock_strategy): + """A 200 response with non-JSON text triggers BuckarooApiError.""" + mock_strategy.queue( + BuckarooMockRequest.text( + "POST", + "*/json/transaction", + body="not json at all", + ) + ) + + with pytest.raises(BuckarooApiError, match="Failed to parse Buckaroo response JSON"): + buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice="TEST-MALFORMED", + description="Malformed JSON test", + ), + ).pay() + + def test_malformed_json_wraps_json_decode_error(self, buckaroo, mock_strategy): + """The raised BuckarooApiError chains the original JSONDecodeError.""" + mock_strategy.queue( + BuckarooMockRequest.text( + "POST", + "*/json/transaction", + body="{truncated", + ) + ) + + with pytest.raises(BuckarooApiError) as exc_info: + buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice="TEST-CHAIN", + amount=5.00, + description="Chained exception test", + ), + ).pay() + + assert exc_info.value.__cause__ is not None + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) + + def test_buckaroo_response_directly_with_garbage(self): + """BuckarooResponse raises BuckarooApiError for non-JSON text.""" + http_resp = HttpResponse( + status_code=200, + headers={}, + text="THIS IS NOT JSON", + success=True, + ) + with pytest.raises(BuckarooApiError, match="Failed to parse Buckaroo response JSON"): + BuckarooResponse(http_resp) + + @pytest.mark.parametrize("text", ["", " ", "\n"]) + def test_empty_response_does_not_raise(self, text): + """Empty or whitespace-only body is treated as empty dict, no error.""" + http_resp = HttpResponse( + status_code=200, + headers={}, + text=text, + success=True, + ) + resp = BuckarooResponse(http_resp) + assert resp.data == {} diff --git a/tests/feature/error_paths/test_server_error.py b/tests/feature/error_paths/test_server_error.py new file mode 100644 index 0000000..b04374f --- /dev/null +++ b/tests/feature/error_paths/test_server_error.py @@ -0,0 +1,63 @@ +"""Tests for HTTP 5xx server error handling.""" + +import pytest + +from buckaroo.http.client import BuckarooApiError +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +def _error_body(): + return { + "Status": { + "Code": {"Code": 492, "Description": "Technical failure"}, + "SubCode": None, + "DateTime": "2024-01-01T00:00:00", + }, + } + + +class TestServerError: + """Verify that 5xx responses raise BuckarooApiError with response attached.""" + + @pytest.mark.parametrize( + "status,body,invoice", + [ + (500, _error_body(), "TEST-500"), + (502, {}, "TEST-502"), + ], + ) + def test_5xx_response_raises_api_error(self, buckaroo, mock_strategy, status, body, invoice): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", body, status=status) + ) + + with pytest.raises(BuckarooApiError, match=str(status)) as exc_info: + buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice=invoice, + description=f"Server error {status} test", + ), + ).pay() + + err = exc_info.value + assert err.status_code == status + assert err.response is not None + + def test_500_response_is_not_successful(self, buckaroo, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json("POST", "*/json/transaction", _error_body(), status=500) + ) + + with pytest.raises(BuckarooApiError) as exc_info: + buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice="TEST-500-SUCCESS", + description="Success flag test", + ), + ).pay() + + assert exc_info.value.response.success is False + assert exc_info.value.response.status_code == 500 diff --git a/tests/feature/payments/__init__.py b/tests/feature/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/feature/payments/test_alipay.py b/tests/feature/payments/test_alipay.py new file mode 100644 index 0000000..4d3f6a5 --- /dev/null +++ b/tests/feature/payments/test_alipay.py @@ -0,0 +1,17 @@ +"""Feature test: alipay pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestAlipayFeature: + def test_alipay_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response = Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="alipay", + invoice="INV-ALIPAY-001", + payload_overrides={"description": "Test alipay payment"}, + service_params={"UseMobileView": False}, + ) + assert response.currency == "EUR" + assert response.amount_debit == 10.00 diff --git a/tests/feature/payments/test_applepay.py b/tests/feature/payments/test_applepay.py new file mode 100644 index 0000000..2ac7775 --- /dev/null +++ b/tests/feature/payments/test_applepay.py @@ -0,0 +1,15 @@ +"""Feature tests for Apple Pay payment method.""" + +from tests.support.helpers import Helpers + + +class TestApplepayFeature: + def test_applepay_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="applepay", + invoice="INV-APPLEPAY-001", + payload_overrides={"description": "Test applepay payment"}, + service_params={"PaymentData": "eyJ0b2tlbiI6InRlc3QifQ=="}, + ) diff --git a/tests/feature/payments/test_bancontact.py b/tests/feature/payments/test_bancontact.py new file mode 100644 index 0000000..9e4ff53 --- /dev/null +++ b/tests/feature/payments/test_bancontact.py @@ -0,0 +1,35 @@ +"""Feature test: bancontact pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestBancontactFeature: + def test_bancontact_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response = Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="bancontact", + invoice="INV-BANCONTACT-001", + payload_overrides={"description": "Test bancontact payment"}, + ) + assert response.currency == "EUR" + assert response.amount_debit == 10.00 + + def test_bancontact_pay_encrypted(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("bancontactmrcash", "PayEncrypted") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "bancontact", + Helpers.standard_payload( + invoice="INV-BANCONTACT-ENC", + description="Test bancontact encrypted", + service_parameters={ + "encryptedCardData": "encrypted_test_data_abc123", + }, + ), + ) + response = builder.execute_action("PayEncrypted") + + assert response.is_pending() + assert response.get_redirect_url() is not None diff --git a/tests/feature/payments/test_belfius.py b/tests/feature/payments/test_belfius.py new file mode 100644 index 0000000..4dcca94 --- /dev/null +++ b/tests/feature/payments/test_belfius.py @@ -0,0 +1,10 @@ +"""Feature test: belfius pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestBelfiusFeature: + def test_belfius_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, mock_strategy, method="belfius", invoice="INV-001" + ) diff --git a/tests/feature/payments/test_billink.py b/tests/feature/payments/test_billink.py new file mode 100644 index 0000000..125c139 --- /dev/null +++ b/tests/feature/payments/test_billink.py @@ -0,0 +1,23 @@ +"""Feature tests for Billink payment method.""" + +from tests.support.helpers import Helpers + + +class TestBillinkFeature: + def test_billink_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="billink", + invoice="INV-BILLINK-001", + payload_overrides={"description": "Test billink"}, + service_params={ + "billingCustomer": [ + {"firstName": "John", "lastName": "Doe", "email": "john@example.com"}, + ], + "shippingCustomer": [{"firstName": "John", "lastName": "Doe"}], + "article": [ + {"description": "Widget", "quantity": "1", "price": "10.00"}, + ], + }, + ) diff --git a/tests/feature/payments/test_bizum.py b/tests/feature/payments/test_bizum.py new file mode 100644 index 0000000..8a607da --- /dev/null +++ b/tests/feature/payments/test_bizum.py @@ -0,0 +1,14 @@ +"""Feature test: bizum pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestBizumFeature: + def test_bizum_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="bizum", + invoice="INV-BIZUM-001", + payload_overrides={"description": "Test bizum"}, + ) diff --git a/tests/feature/payments/test_blik.py b/tests/feature/payments/test_blik.py new file mode 100644 index 0000000..52bb911 --- /dev/null +++ b/tests/feature/payments/test_blik.py @@ -0,0 +1,14 @@ +"""Feature tests for Blik payment method.""" + +from tests.support.helpers import Helpers + + +class TestBlikFeature: + def test_blik_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="blik", + invoice="INV-BLIK-001", + payload_overrides={"description": "Test blik"}, + ) diff --git a/tests/feature/payments/test_buckaroovoucher.py b/tests/feature/payments/test_buckaroovoucher.py new file mode 100644 index 0000000..ac55d82 --- /dev/null +++ b/tests/feature/payments/test_buckaroovoucher.py @@ -0,0 +1,13 @@ +from tests.support.helpers import Helpers + + +class TestBuckaroovoucherFeature: + def test_buckaroovoucher_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="buckaroovoucher", + invoice="INV-BV-001", + payload_overrides={"description": "Test buckaroovoucher"}, + service_params={"VoucherCode": "TESTVOUCHER123"}, + ) diff --git a/tests/feature/payments/test_clicktopay.py b/tests/feature/payments/test_clicktopay.py new file mode 100644 index 0000000..519bb58 --- /dev/null +++ b/tests/feature/payments/test_clicktopay.py @@ -0,0 +1,12 @@ +from tests.support.helpers import Helpers + + +class TestClicktopayFeature: + def test_clicktopay_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="clicktopay", + invoice="INV-CTP-001", + payload_overrides={"description": "Test clicktopay"}, + ) diff --git a/tests/feature/payments/test_creditcard.py b/tests/feature/payments/test_creditcard.py new file mode 100644 index 0000000..63cce13 --- /dev/null +++ b/tests/feature/payments/test_creditcard.py @@ -0,0 +1,294 @@ +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action +from tests.support.helpers import Helpers + + +class TestCreditcardFeature: + """Feature tests for creditcard payment method with all capability actions.""" + + def test_creditcard_pay(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "Pay") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-001", + description="Test pay", + ), + ).pay() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + def test_creditcard_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="creditcard", + invoice="INV-CC-002", + payload_overrides={"description": "Test refund"}, + ) + + def test_creditcard_authorize(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "Authorize") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-003", + description="Test authorize", + ), + ).authorize() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + def test_creditcard_capture(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "creditcard", "Action": "Capture", "Parameters": []}], + "ServiceCode": "creditcard", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-004", + description="Test capture", + original_transaction_key="ABC123", + ), + ).capture() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_creditcard_cancel_authorize(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "creditcard", "Action": "CancelAuthorize", "Parameters": []}], + "ServiceCode": "creditcard", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-005", + description="Test cancel authorize", + original_transaction_key="ABC123", + ), + ).cancelAuthorize() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_creditcard_pay_encrypted(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "PayEncrypted") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-006", + description="Test pay encrypted", + ), + ) + builder.add_parameter("EncryptedCardData", "encrypted-data-here") + response = builder.payEncrypted() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + def test_creditcard_pay_with_security_code(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "PayWithSecurityCode") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-007", + description="Test pay with security code", + ), + ) + builder.add_parameter("EncryptedSecurityCode", "encrypted-code-here") + response = builder.payWithSecurityCode() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + def test_creditcard_pay_with_token(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "PayWithToken") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-008", + description="Test pay with token", + ), + ) + builder.add_parameter("SessionId", "session-token-123") + response = builder.payWithToken() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + def test_creditcard_pay_recurrent(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "creditcard", "Action": "PayRecurrent", "Parameters": []}], + "ServiceCode": "creditcard", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-009", + description="Test pay recurrent", + original_transaction_key="ABC123", + ), + ).payRecurrent() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_creditcard_authorize_encrypted(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "AuthorizeEncrypted") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-010", + description="Test authorize encrypted", + ), + ) + builder.add_parameter("EncryptedCardData", "encrypted-data-here") + response = builder.authorizeEncrypted() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + # ------------------------------------------------------------------ + # Wire-level assertions — verify the Action string on the outgoing + # request for each capability mixin. These guard against a builder + # that silently routes .refund() through Action="Pay" or similar. + + def test_creditcard_refund_sends_action_refund_on_the_wire( + self, recording_buckaroo, recording_mock + ): + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.refund_response("creditcard"), + ) + ) + recording_buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-WIRE-REFUND", + original_transaction_key="ABC123", + ), + ).refund() + + assert recorded_action(recording_mock) == "Refund" + + def test_creditcard_authorize_sends_action_authorize_on_the_wire( + self, recording_buckaroo, recording_mock + ): + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.pending_redirect_response("creditcard", "Authorize"), + ) + ) + recording_buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload(invoice="INV-CC-WIRE-AUTH"), + ).authorize() + + assert recorded_action(recording_mock) == "Authorize" + + def test_creditcard_capture_sends_action_capture_on_the_wire( + self, recording_buckaroo, recording_mock + ): + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [{"Name": "creditcard", "Action": "Capture", "Parameters": []}], + "ServiceCode": "creditcard", + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-WIRE-CAP", + original_transaction_key="ABC123", + ), + ).capture() + + assert recorded_action(recording_mock) == "Capture" + + def test_creditcard_cancel_authorize_sends_action_cancelauthorize_on_the_wire( + self, recording_buckaroo, recording_mock + ): + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [ + {"Name": "creditcard", "Action": "CancelAuthorize", "Parameters": []} + ], + "ServiceCode": "creditcard", + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-WIRE-CANCEL", + original_transaction_key="ABC123", + ), + ).cancelAuthorize() + + assert recorded_action(recording_mock) == "CancelAuthorize" + + def test_creditcard_pay_encrypted_sends_action_payencrypted_on_the_wire( + self, recording_buckaroo, recording_mock + ): + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.pending_redirect_response("creditcard", "PayEncrypted"), + ) + ) + builder = recording_buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload(invoice="INV-CC-WIRE-ENC"), + ) + builder.add_parameter("EncryptedCardData", "encrypted-data-here") + builder.payEncrypted() + + assert recorded_action(recording_mock) == "PayEncrypted" + + def test_creditcard_authorize_with_token(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("creditcard", "AuthorizeWithToken") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "creditcard", + Helpers.standard_payload( + invoice="INV-CC-011", + description="Test authorize with token", + ), + ) + builder.add_parameter("SessionId", "session-token-456") + response = builder.authorizeWithToken() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] diff --git a/tests/feature/payments/test_default.py b/tests/feature/payments/test_default.py new file mode 100644 index 0000000..a0457f6 --- /dev/null +++ b/tests/feature/payments/test_default.py @@ -0,0 +1,12 @@ +from tests.support.helpers import Helpers + + +class TestDefaultFeature: + def test_default_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="default", + invoice="INV-DEF-001", + payload_overrides={"description": "Test default"}, + ) diff --git a/tests/feature/payments/test_eps.py b/tests/feature/payments/test_eps.py new file mode 100644 index 0000000..4702e6f --- /dev/null +++ b/tests/feature/payments/test_eps.py @@ -0,0 +1,14 @@ +"""Feature test: eps pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestEpsFeature: + def test_eps_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="eps", + invoice="INV-EPS-001", + payload_overrides={"description": "Test eps"}, + ) diff --git a/tests/feature/payments/test_external_payment.py b/tests/feature/payments/test_external_payment.py new file mode 100644 index 0000000..c4b07de --- /dev/null +++ b/tests/feature/payments/test_external_payment.py @@ -0,0 +1,28 @@ +"""Feature test: externalPayment resolves to ExternalPaymentBuilder.""" + +from buckaroo.builders.payments.external_payment_builder import ExternalPaymentBuilder +from buckaroo.factories.payment_method_factory import PaymentMethodFactory +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestExternalPaymentFeature: + def test_factory_resolves_to_external_payment_builder(self): + builder = PaymentMethodFactory.create_builder("externalPayment", None) + assert isinstance(builder, ExternalPaymentBuilder) + + def test_external_payment_pay(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("ExternalPayment") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + builder = buckaroo.payments.create_payment( + "externalPayment", + Helpers.standard_payload( + invoice="INV-EXT-001", + description="Test external payment", + ), + ) + assert isinstance(builder, ExternalPaymentBuilder) + response = builder.pay() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] diff --git a/tests/feature/payments/test_giftcards.py b/tests/feature/payments/test_giftcards.py new file mode 100644 index 0000000..736781d --- /dev/null +++ b/tests/feature/payments/test_giftcards.py @@ -0,0 +1,46 @@ +"""Feature test: giftcards pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters +from tests.support.helpers import Helpers + + +class TestGiftcardsFeature: + def test_giftcards_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="giftcards", + invoice="INV-GC-001", + payload_overrides={"description": "Test giftcards"}, + service_params={"Cardnumber": "1234567890123456", "PIN": "1234"}, + ) + + def test_giftcards_pay_sends_cardnumber_and_pin_on_the_wire( + self, recording_buckaroo, recording_mock + ): + """service_parameters dict must reach ServiceList[0].Parameters. + + The SDK capitalizes parameter names, so ``"PIN"`` becomes ``"Pin"`` + on the wire. Assert both the value pairing and the presence of both + keys so a builder that silently drops service_parameters would fail. + """ + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.pending_redirect_response("giftcards"), + ) + ) + recording_buckaroo.payments.create_payment( + "giftcards", + Helpers.standard_payload( + invoice="INV-GC-WIRE", + service_parameters={"Cardnumber": "1234567890123456", "PIN": "1234"}, + ), + ).pay() + + assert recorded_action(recording_mock) == "Pay" + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(recording_mock)} + assert params.get("Cardnumber") == "1234567890123456" + assert params.get("Pin") == "1234" diff --git a/tests/feature/payments/test_googlepay.py b/tests/feature/payments/test_googlepay.py new file mode 100644 index 0000000..baf2815 --- /dev/null +++ b/tests/feature/payments/test_googlepay.py @@ -0,0 +1,15 @@ +"""Feature tests for Google Pay payment method.""" + +from tests.support.helpers import Helpers + + +class TestGooglepayFeature: + def test_googlepay_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="googlepay", + invoice="INV-GP-001", + payload_overrides={"description": "Test googlepay"}, + service_params={"PaymentData": "eyJ0b2tlbiI6InRlc3QifQ=="}, + ) diff --git a/tests/feature/payments/test_ideal.py b/tests/feature/payments/test_ideal.py new file mode 100644 index 0000000..01c1be9 --- /dev/null +++ b/tests/feature/payments/test_ideal.py @@ -0,0 +1,106 @@ +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action +from tests.support.helpers import Helpers + + +class TestIdealFeature: + """Feature tests for iDEAL payment method with InstantRefund and FastCheckout capabilities.""" + + def test_ideal_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="ideal", + invoice="INV-IDEAL-001", + payload_overrides={"description": "Test ideal"}, + ) + + def test_ideal_case_insensitive_lookup(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("ideal") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "IDEAL", + Helpers.standard_payload( + invoice="INV-CASE", + description="Case test", + ), + ).pay() + assert response.is_pending() + assert response.key == response_body["Key"] + + def test_ideal_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="ideal", + invoice="INV-REFUND", + ) + + def test_ideal_instant_refund(self, buckaroo, mock_strategy): + Helpers.assert_instant_refund_returns_success( + buckaroo, + mock_strategy, + method="ideal", + invoice="INV-IREFUND", + ) + + def test_ideal_fast_checkout(self, buckaroo, mock_strategy): + Helpers.assert_fast_checkout_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="ideal", + invoice="INV-FAST", + ) + + # ------------------------------------------------------------------ + # Wire-level assertions — verify BankTransferCapabilities mixins + # (InstantRefund + FastCheckout) actually put the right Action on + # the outgoing request. + + def test_ideal_instant_refund_sends_action_instantrefund_on_the_wire( + self, recording_buckaroo, recording_mock + ): + """The InstantRefundCapable mixin must put ``Action=instantRefund`` on the wire.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.success_response( + { + "Services": [ + {"Name": "ideal", "Action": "InstantRefund", "Parameters": []} + ], + "ServiceCode": "ideal", + "AmountCredit": 10.00, + "AmountDebit": None, + } + ), + ) + ) + recording_buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice="INV-IDEAL-WIRE-IREFUND", + original_transaction_key="ABC123", + ), + ).instantRefund() + + assert recorded_action(recording_mock) == "instantRefund" + + def test_ideal_fast_checkout_sends_action_payfastcheckout_on_the_wire( + self, recording_buckaroo, recording_mock + ): + """The FastCheckoutCapable mixin must put ``Action=payFastCheckout`` on the wire.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.pending_redirect_response("ideal", "PayFastCheckout"), + ) + ) + recording_buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload(invoice="INV-IDEAL-WIRE-FAST"), + ).payFastCheckout() + + assert recorded_action(recording_mock) == "payFastCheckout" diff --git a/tests/feature/payments/test_idealqr.py b/tests/feature/payments/test_idealqr.py new file mode 100644 index 0000000..3574841 --- /dev/null +++ b/tests/feature/payments/test_idealqr.py @@ -0,0 +1,23 @@ +"""Feature test: idealqr pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestIdealqrFeature: + def test_idealqr_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="idealqr", + invoice="INV-IQRT-001", + payload_overrides={"description": "Test idealqr"}, + ) + + def test_idealqr_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="idealqr", + invoice="INV-IQRR-001", + original_transaction_key="some-key", + ) diff --git a/tests/feature/payments/test_in3.py b/tests/feature/payments/test_in3.py new file mode 100644 index 0000000..96bfd7f --- /dev/null +++ b/tests/feature/payments/test_in3.py @@ -0,0 +1,25 @@ +"""Feature test: in3 pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestIn3Feature: + def test_in3_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="in3", + invoice="INV-IN3-001", + payload_overrides={"amount": 25.00, "description": "Test in3"}, + service_params={ + "article": [ + {"description": "Widget", "quantity": "2", "price": "12.50"}, + ], + "billingCustomer": [ + {"firstName": "John", "lastName": "Doe"}, + ], + "shippingCustomer": [ + {"firstName": "John", "lastName": "Doe"}, + ], + }, + ) diff --git a/tests/feature/payments/test_kbc.py b/tests/feature/payments/test_kbc.py new file mode 100644 index 0000000..7945e9e --- /dev/null +++ b/tests/feature/payments/test_kbc.py @@ -0,0 +1,14 @@ +"""Feature test: kbc pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestKbcFeature: + def test_kbc_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="kbc", + invoice="INV-KBC-001", + payload_overrides={"description": "Test kbc"}, + ) diff --git a/tests/feature/payments/test_klarna.py b/tests/feature/payments/test_klarna.py new file mode 100644 index 0000000..509fd1d --- /dev/null +++ b/tests/feature/payments/test_klarna.py @@ -0,0 +1,22 @@ +"""Feature test: klarna pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestKlarnaFeature: + def test_klarna_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response = Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="klarna", + invoice="INV-KLARNA-001", + payload_overrides={"amount": 25.00, "description": "Test klarna"}, + service_params={ + "article": [ + {"description": "Widget", "quantity": "2", "price": "12.50"}, + ], + "billingCustomer": [{"firstName": "John", "lastName": "Doe"}], + "shippingCustomer": [{"firstName": "John", "lastName": "Doe"}], + }, + ) + assert response.currency == "EUR" diff --git a/tests/feature/payments/test_klarnakp.py b/tests/feature/payments/test_klarnakp.py new file mode 100644 index 0000000..ca8aa74 --- /dev/null +++ b/tests/feature/payments/test_klarnakp.py @@ -0,0 +1,65 @@ +"""Feature test: klarnakp pay() and reserve() round-trips through full stack with MockBuckaroo.""" + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestKlarnakpFeature: + def test_klarnakp_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response = Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="klarnakp", + invoice="INV-KKP-001", + payload_overrides={"amount": 25.00, "description": "Test klarnakp"}, + service_params={"reservationNumber": "RES-12345"}, + response_overrides={"AmountDebit": 25.00}, + ) + assert response.currency == "EUR" + assert response.amount_debit == 25.00 + + def test_klarnakp_reserve_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response( + "klarnakp", overrides={"AmountDebit": 50.00} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarnakp", + Helpers.standard_payload( + invoice="INV-KKP-002", + amount=50.00, + description="Test klarnakp reserve", + service_parameters={ + "operatingCountry": "NL", + "article": [ + {"description": "Widget", "quantity": "2", "price": "25.00"}, + ], + }, + ), + ).reserve() + + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + assert response.currency == "EUR" + assert response.amount_debit == 50.00 + + def test_klarnakp_cancel_reservation_returns_pending(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response( + "klarnakp", overrides={"AmountDebit": 25.00} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarnakp", + Helpers.standard_payload( + invoice="INV-KKP-003", + amount=25.00, + description="Test klarnakp cancel reservation", + service_parameters={ + "reservationNumber": "RES-12345", + }, + ), + ).cancelReservation() + + assert response.is_pending() + assert response.get_redirect_url() is not None diff --git a/tests/feature/payments/test_knaken.py b/tests/feature/payments/test_knaken.py new file mode 100644 index 0000000..3d38dd8 --- /dev/null +++ b/tests/feature/payments/test_knaken.py @@ -0,0 +1,14 @@ +"""Feature test: knaken pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestKnakenFeature: + def test_knaken_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="knaken", + invoice="INV-KNK-001", + payload_overrides={"description": "Test knaken"}, + ) diff --git a/tests/feature/payments/test_mbway.py b/tests/feature/payments/test_mbway.py new file mode 100644 index 0000000..30bab6b --- /dev/null +++ b/tests/feature/payments/test_mbway.py @@ -0,0 +1,14 @@ +from tests.support.helpers import Helpers + + +class TestMbwayFeature: + """Feature tests for MB WAY payment method.""" + + def test_mbway_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="mbway", + invoice="INV-MBW-001", + payload_overrides={"description": "Test mbway"}, + ) diff --git a/tests/feature/payments/test_multibanco.py b/tests/feature/payments/test_multibanco.py new file mode 100644 index 0000000..ea65140 --- /dev/null +++ b/tests/feature/payments/test_multibanco.py @@ -0,0 +1,12 @@ +from tests.support.helpers import Helpers + + +class TestMultibancoFeature: + def test_multibanco_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="multibanco", + invoice="INV-MB-001", + payload_overrides={"description": "Test multibanco"}, + ) diff --git a/tests/feature/payments/test_paybybank.py b/tests/feature/payments/test_paybybank.py new file mode 100644 index 0000000..73ae6c3 --- /dev/null +++ b/tests/feature/payments/test_paybybank.py @@ -0,0 +1,64 @@ +"""Feature test: paybybank pay() and capability methods through full stack with MockBuckaroo.""" + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_service_parameters +from tests.support.helpers import Helpers + + +class TestPaybybankFeature: + """Feature tests for PayByBank with InstantRefund and FastCheckout capabilities.""" + + def test_paybybank_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="paybybank", + invoice="INV-PBB-001", + payload_overrides={"description": "Test paybybank"}, + service_params={"issuer": "INGBNL2A"}, + ) + + def test_paybybank_pay_sends_issuer_on_the_wire(self, recording_buckaroo, recording_mock): + """service_parameters['issuer'] must reach ServiceList[0].Parameters.""" + recording_mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + Helpers.pending_redirect_response("paybybank"), + ) + ) + recording_buckaroo.payments.create_payment( + "paybybank", + Helpers.standard_payload( + invoice="INV-PBB-WIRE", + service_parameters={"issuer": "INGBNL2A"}, + ), + ).pay() + + assert recorded_action(recording_mock) == "Pay" + params = {p["Name"]: p["Value"] for p in recorded_service_parameters(recording_mock)} + assert params.get("Issuer") == "INGBNL2A" + + def test_paybybank_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="paybybank", + invoice="INV-PBB-REFUND", + ) + + def test_paybybank_instant_refund(self, buckaroo, mock_strategy): + Helpers.assert_instant_refund_returns_success( + buckaroo, + mock_strategy, + method="paybybank", + invoice="INV-PBB-IREFUND", + ) + + def test_paybybank_fast_checkout(self, buckaroo, mock_strategy): + Helpers.assert_fast_checkout_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="paybybank", + invoice="INV-PBB-FAST", + ) diff --git a/tests/feature/payments/test_payconiq.py b/tests/feature/payments/test_payconiq.py new file mode 100644 index 0000000..e22ff81 --- /dev/null +++ b/tests/feature/payments/test_payconiq.py @@ -0,0 +1,40 @@ +"""Feature test: payconiq pay() and capability methods through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestPayconiqFeature: + """Feature tests for Payconiq with InstantRefund and FastCheckout capabilities.""" + + def test_payconiq_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="payconiq", + invoice="INV-PCQ-001", + payload_overrides={"description": "Test payconiq"}, + ) + + def test_payconiq_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="payconiq", + invoice="INV-PCQ-REFUND", + ) + + def test_payconiq_instant_refund(self, buckaroo, mock_strategy): + Helpers.assert_instant_refund_returns_success( + buckaroo, + mock_strategy, + method="payconiq", + invoice="INV-PCQ-IREFUND", + ) + + def test_payconiq_fast_checkout(self, buckaroo, mock_strategy): + Helpers.assert_fast_checkout_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="payconiq", + invoice="INV-PCQ-FAST", + ) diff --git a/tests/feature/payments/test_paypal.py b/tests/feature/payments/test_paypal.py new file mode 100644 index 0000000..76ac725 --- /dev/null +++ b/tests/feature/payments/test_paypal.py @@ -0,0 +1,22 @@ +from tests.support.helpers import Helpers + + +class TestPaypalFeature: + def test_paypal_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="paypal", + invoice="INV-PP-001", + payload_overrides={"description": "Test paypal"}, + ) + + def test_paypal_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="paypal", + invoice="INV-PPR-001", + original_transaction_key="some-key", + payload_overrides={"description": "Refund"}, + ) diff --git a/tests/feature/payments/test_przelewy24.py b/tests/feature/payments/test_przelewy24.py new file mode 100644 index 0000000..8ddb20b --- /dev/null +++ b/tests/feature/payments/test_przelewy24.py @@ -0,0 +1,19 @@ +"""Feature tests for Przelewy24 payment method.""" + +from tests.support.helpers import Helpers + + +class TestPrzelewy24Feature: + def test_przelewy24_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="przelewy24", + invoice="INV-P24-001", + payload_overrides={"description": "Test przelewy24"}, + service_params={ + "customerEmail": "test@example.com", + "customerFirstName": "John", + "customerLastName": "Doe", + }, + ) diff --git a/tests/feature/payments/test_riverty.py b/tests/feature/payments/test_riverty.py new file mode 100644 index 0000000..1632a02 --- /dev/null +++ b/tests/feature/payments/test_riverty.py @@ -0,0 +1,21 @@ +"""Feature test: riverty pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestRivertyFeature: + def test_riverty_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="riverty", + invoice="INV-RIV-001", + payload_overrides={"amount": 25.00, "description": "Test riverty"}, + service_params={ + "article": [ + {"description": "Widget", "quantity": "2", "price": "12.50"}, + ], + "billingCustomer": {"firstName": "John", "lastName": "Doe"}, + "shippingCustomer": {"firstName": "John", "lastName": "Doe"}, + }, + ) diff --git a/tests/feature/payments/test_sepadirectdebit.py b/tests/feature/payments/test_sepadirectdebit.py new file mode 100644 index 0000000..1745f6d --- /dev/null +++ b/tests/feature/payments/test_sepadirectdebit.py @@ -0,0 +1,27 @@ +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestSepadirectdebitFeature: + """Feature tests for SEPA Direct Debit payment method with mandate parameters.""" + + def test_sepadirectdebit_pay_returns_pending(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("sepadirectdebit") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "sepadirectdebit", + Helpers.standard_payload( + invoice="INV-SEPA-001", + description="Test SEPA DD", + service_parameters={ + "customerIBAN": "NL91ABNA0417164300", + "customerBIC": "ABNANL2A", + "mandateReference": "MANDATE-001", + "mandateDate": "2024-01-01", + "customerAccountName": "John Doe", + }, + ), + ).pay() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] diff --git a/tests/feature/payments/test_sofort.py b/tests/feature/payments/test_sofort.py new file mode 100644 index 0000000..b4af001 --- /dev/null +++ b/tests/feature/payments/test_sofort.py @@ -0,0 +1,40 @@ +from tests.support.helpers import Helpers + + +class TestSofortFeature: + """Feature tests for Sofort payment method with InstantRefund and FastCheckout capabilities.""" + + def test_sofort_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response = Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="sofort", + invoice="INV-SOF-001", + payload_overrides={"description": "Test sofort"}, + ) + assert response.currency == "EUR" + assert response.amount_debit == 10.00 + + def test_sofort_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="sofort", + invoice="INV-SOF-REFUND", + ) + + def test_sofort_instant_refund(self, buckaroo, mock_strategy): + Helpers.assert_instant_refund_returns_success( + buckaroo, + mock_strategy, + method="sofort", + invoice="INV-SOF-IREFUND", + ) + + def test_sofort_fast_checkout(self, buckaroo, mock_strategy): + Helpers.assert_fast_checkout_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="sofort", + invoice="INV-SOF-FAST", + ) diff --git a/tests/feature/payments/test_swish.py b/tests/feature/payments/test_swish.py new file mode 100644 index 0000000..a3cf7b7 --- /dev/null +++ b/tests/feature/payments/test_swish.py @@ -0,0 +1,14 @@ +"""Feature tests for Swish payment method.""" + +from tests.support.helpers import Helpers + + +class TestSwishFeature: + def test_swish_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="swish", + invoice="INV-SWI-001", + payload_overrides={"description": "Test swish"}, + ) diff --git a/tests/feature/payments/test_transfer.py b/tests/feature/payments/test_transfer.py new file mode 100644 index 0000000..c372a88 --- /dev/null +++ b/tests/feature/payments/test_transfer.py @@ -0,0 +1,42 @@ +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestTransferFeature: + def test_transfer_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="transfer", + invoice="INV-TRF-001", + payload_overrides={"description": "Test transfer"}, + service_params={ + "customeremail": "test@example.com", + "customerfirstname": "John", + "customerlastname": "Doe", + }, + ) + + def test_transfer_cancel(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "transfer", "Action": "Cancel", "Parameters": []}], + "ServiceCode": "transfer", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.payments.create_payment( + "transfer", + Helpers.standard_payload( + invoice="INV-TRFC-001", + description="Cancel", + original_transaction_key="some-key", + service_parameters={ + "customeremail": "test@example.com", + "customerfirstname": "John", + "customerlastname": "Doe", + }, + ), + ).cancel() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] diff --git a/tests/feature/payments/test_trustly.py b/tests/feature/payments/test_trustly.py new file mode 100644 index 0000000..453ae37 --- /dev/null +++ b/tests/feature/payments/test_trustly.py @@ -0,0 +1,27 @@ +from tests.support.helpers import Helpers + + +class TestTrustlyFeature: + def test_trustly_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="trustly", + invoice="INV-TRS-001", + payload_overrides={"description": "Test trustly"}, + service_params={ + "customerFirstName": "John", + "customerLastName": "Doe", + "customerCountryCode": "NL", + "consumeremail": "john@example.com", + }, + ) + + def test_trustly_refund(self, buckaroo, mock_strategy): + Helpers.assert_refund_returns_success( + buckaroo, + mock_strategy, + method="trustly", + invoice="INV-TRSR-001", + original_transaction_key="some-key", + ) diff --git a/tests/feature/payments/test_twint.py b/tests/feature/payments/test_twint.py new file mode 100644 index 0000000..5393d60 --- /dev/null +++ b/tests/feature/payments/test_twint.py @@ -0,0 +1,12 @@ +from tests.support.helpers import Helpers + + +class TestTwintFeature: + def test_twint_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="twint", + invoice="INV-TWI-001", + payload_overrides={"description": "Test twint"}, + ) diff --git a/tests/feature/payments/test_voucher.py b/tests/feature/payments/test_voucher.py new file mode 100644 index 0000000..a0d164d --- /dev/null +++ b/tests/feature/payments/test_voucher.py @@ -0,0 +1,24 @@ +"""Feature test: voucher pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestVoucherFeature: + def test_voucher_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="voucher", + invoice="INV-VOU-001", + payload_overrides={"description": "Test voucher"}, + service_params={ + "article": [ + { + "identifier": "ART-001", + "description": "Test Article", + "quantity": "1", + "price": "10.00", + }, + ], + }, + ) diff --git a/tests/feature/payments/test_wechatpay.py b/tests/feature/payments/test_wechatpay.py new file mode 100644 index 0000000..ce3f79d --- /dev/null +++ b/tests/feature/payments/test_wechatpay.py @@ -0,0 +1,14 @@ +"""Feature test: wechatpay pay() round-trip through full stack with MockBuckaroo.""" + +from tests.support.helpers import Helpers + + +class TestWechatpayFeature: + def test_wechatpay_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="wechatpay", + invoice="INV-WCP-001", + payload_overrides={"description": "Test wechatpay"}, + ) diff --git a/tests/feature/payments/test_wero.py b/tests/feature/payments/test_wero.py new file mode 100644 index 0000000..a02414e --- /dev/null +++ b/tests/feature/payments/test_wero.py @@ -0,0 +1,14 @@ +from tests.support.helpers import Helpers + + +class TestWeroFeature: + """Feature tests for Wero payment method.""" + + def test_wero_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + Helpers.assert_pay_returns_pending_with_redirect( + buckaroo, + mock_strategy, + method="wero", + invoice="INV-WER-001", + payload_overrides={"description": "Test wero"}, + ) diff --git a/tests/feature/solutions/__init__.py b/tests/feature/solutions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/feature/solutions/test_default_solution.py b/tests/feature/solutions/test_default_solution.py new file mode 100644 index 0000000..f2ae1a6 --- /dev/null +++ b/tests/feature/solutions/test_default_solution.py @@ -0,0 +1,77 @@ +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestDefaultSolutionFeature: + """Default solution uses the fallback DefaultBuilder since 'default' is not + registered in SolutionMethodFactory._solution_methods.""" + + def test_default_solution_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response("default") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.solutions.create_solution( + "default", + Helpers.standard_payload( + invoice="INV-SOL-DEF-001", + description="Test default solution", + ), + ).pay() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + + def test_default_solution_pay_success(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [{"Name": "default", "Action": "Pay", "Parameters": []}], + "ServiceCode": "default", + "AmountDebit": 25.00, + "Currency": "EUR", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.solutions.create_solution( + "default", + Helpers.standard_payload( + invoice="INV-SOL-DEF-002", + amount=25.00, + description="Test default solution success", + ), + ).pay() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_default_solution_refund(self, buckaroo, mock_strategy): + response_body = Helpers.refund_response("default") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.solutions.create_solution( + "default", + Helpers.standard_payload( + invoice="INV-SOL-DEF-003", + description="Test default solution refund", + original_transaction_key="ABCD1234", + ), + ).refund() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_default_solution_not_in_factory_registry(self): + """DefaultBuilder is a fallback, not a registered solution method.""" + from buckaroo.factories.solution_method_factory import SolutionMethodFactory + + assert not SolutionMethodFactory.is_method_supported("default") + + def test_default_solution_service_name_from_payload(self, buckaroo, mock_strategy): + """DefaultBuilder reads service name from payload's 'method' key.""" + response_body = Helpers.pending_redirect_response("custommethod") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + response = buckaroo.solutions.create_solution( + "nonexistent", + Helpers.standard_payload( + invoice="INV-SOL-DEF-004", + amount=5.00, + description="Test custom method fallback", + method="custommethod", + ), + ).pay() + assert response.is_pending() diff --git a/tests/feature/solutions/test_subscription.py b/tests/feature/solutions/test_subscription.py new file mode 100644 index 0000000..5e55aac --- /dev/null +++ b/tests/feature/solutions/test_subscription.py @@ -0,0 +1,73 @@ +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestSubscriptionFeature: + """Feature tests for the Subscription solution.""" + + def test_create_subscription(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + {"Name": "Subscription", "Action": "CreateSubscription", "Parameters": []} + ], + "ServiceCode": "Subscription", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.solutions.create_solution( + "subscription", + Helpers.standard_payload( + invoice="INV-SUB-001", + description="Test subscription", + ), + ).createSubscription() + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_create_subscription_with_fluent_interface(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + {"Name": "Subscription", "Action": "CreateSubscription", "Parameters": []} + ], + "ServiceCode": "Subscription", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = ( + buckaroo.solutions.create_solution("subscription") + .amount(15.00) + .currency("EUR") + .description("Fluent subscription") + .invoice("INV-SUB-002") + .return_url("https://example.com/return") + .return_url_cancel("https://example.com/cancel") + .return_url_error("https://example.com/error") + .return_url_reject("https://example.com/reject") + .createSubscription() + ) + assert response.status.code.code == 190 + assert response.key == response_body["Key"] + + def test_subscription_case_insensitive_lookup(self, buckaroo, mock_strategy): + response_body = Helpers.success_response( + { + "Services": [ + {"Name": "Subscription", "Action": "CreateSubscription", "Parameters": []} + ], + "ServiceCode": "Subscription", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.solutions.create_solution( + "SUBSCRIPTION", + Helpers.standard_payload( + invoice="INV-SUB-CASE", + description="Case test", + ), + ).createSubscription() + assert response.status.code.code == 190 + + def test_subscription_is_available(self, buckaroo): + assert buckaroo.solutions.is_method_supported("subscription") diff --git a/tests/feature/test_smoke.py b/tests/feature/test_smoke.py new file mode 100644 index 0000000..e49175a --- /dev/null +++ b/tests/feature/test_smoke.py @@ -0,0 +1,57 @@ +"""Smoke test verifying feature test fixtures work end-to-end.""" + +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestFeatureFixturesSmoke: + """Verify conftest fixtures wire up correctly.""" + + def test_buckaroo_fixture_creates_payment_builder(self, buckaroo, mock_strategy): + """buckaroo.payments.create_payment returns a builder.""" + builder = buckaroo.payments.create_payment( + "ideal", + { + "amount": 10.00, + "currency": "EUR", + "description": "Smoke test", + "invoice": "SMOKE-001", + }, + ) + assert isinstance(builder, PaymentBuilder) + + def test_mock_strategy_intercepts_pay_call(self, buckaroo, mock_strategy): + """Queued mock is consumed by builder.pay().""" + response_body = Helpers.success_response( + { + "Services": [{"Name": "ideal", "Action": "Pay", "Parameters": []}], + "ServiceCode": "ideal", + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + + builder = buckaroo.payments.create_payment( + "ideal", + Helpers.standard_payload( + invoice="SMOKE-001", + description="Smoke test", + ), + ) + result = builder.pay() + assert result.key == response_body["Key"] + + def test_pending_redirect_response_helper(self): + """pending_redirect_response builds a valid Buckaroo response shape.""" + resp = Helpers.pending_redirect_response("ideal") + assert resp["Status"]["Code"]["Code"] == 791 + assert resp["RequiredAction"]["Name"] == "Redirect" + assert resp["ServiceCode"] == "ideal" + assert resp["Services"][0]["Name"] == "ideal" + + def test_refund_response_helper(self): + """refund_response builds a valid Buckaroo refund shape.""" + resp = Helpers.refund_response("ideal") + assert resp["Services"][0]["Action"] == "Refund" + assert resp["AmountCredit"] == 10.00 + assert resp["ServiceCode"] == "ideal" diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/support/builders.py b/tests/support/builders.py new file mode 100644 index 0000000..f094fae --- /dev/null +++ b/tests/support/builders.py @@ -0,0 +1,97 @@ +"""Factory for one-off :class:`PaymentBuilder` subclasses used in tests. + +Keeps phase-4 tests for builder bases and capability mixins decoupled from any +concrete payment method. Callers pick the service name, allowed parameters +per action, and which capability mixins to compose in — no imports of +``IdealBuilder`` / ``CreditcardBuilder`` / etc. required. + +Usage:: + + from tests.support.builders import make_test_builder, populate_required_fields + from buckaroo.builders.payments.capabilities.authorize_capture_capable import ( + AuthorizeCaptureCapable, + ) + + builder = make_test_builder( + client, + service_name="dummy", + allowed_params={"Pay": {"issuer": {"type": str, "required": False}}}, + capabilities=(AuthorizeCaptureCapable,), + ) + populate_required_fields(builder) +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, Optional, Type + +from buckaroo.builders.payments.payment_builder import PaymentBuilder + + +def make_test_builder( + client: Any, + *, + service_name: str = "dummy", + allowed_params: Optional[Dict[str, Any]] = None, + capabilities: Iterable[Type] = (), +) -> PaymentBuilder: + """Build a throwaway :class:`PaymentBuilder` subclass and return an instance. + + Args: + client: The client instance the builder is bound to. + service_name: Value for ``_serviceName`` and return value of + ``get_service_name()``. + allowed_params: Mapping of action name to allowed-parameter spec. + The value for each action is returned verbatim from + ``get_allowed_service_parameters(action)``. The validator expects + a dict like ``{"issuer": {"type": str, "required": False}}``; + lighter-weight callers may pass lists of names. Unknown actions + return ``{}``. + capabilities: Capability mixin classes to compose into the subclass + (e.g. ``EncryptedPayCapable``, ``AuthorizeCaptureCapable``). + """ + params_map: Dict[str, Any] = dict(allowed_params or {}) + bases: tuple = (PaymentBuilder, *tuple(capabilities)) + + class _TestBuilder(*bases): + _serviceName = service_name + + def get_service_name(self) -> str: + return service_name + + def get_allowed_service_parameters(self, action: str = "Pay"): + return params_map.get(action, {}) + + _TestBuilder.__name__ = "TestBuilder" + _TestBuilder.__qualname__ = "TestBuilder" + + return _TestBuilder(client) + + +def populate_required_fields( + builder, + *, + currency: str = "EUR", + amount: float = 10.0, + description: str = "desc", + invoice: str = "INV-1", + return_url: str = "https://ret.example/ok", + return_url_cancel: str = "https://ret.example/cancel", + return_url_error: str = "https://ret.example/error", + return_url_reject: str = "https://ret.example/reject", +): + """Apply every required core setter so ``build()`` passes validation. + + Any field can be overridden via keyword argument. Returns the builder so + the helper can be chained. + """ + return ( + builder.currency(currency) + .amount(amount) + .description(description) + .invoice(invoice) + .return_url(return_url) + .return_url_cancel(return_url_cancel) + .return_url_error(return_url_error) + .return_url_reject(return_url_reject) + ) diff --git a/tests/support/helpers.py b/tests/support/helpers.py new file mode 100644 index 0000000..2a5dc5f --- /dev/null +++ b/tests/support/helpers.py @@ -0,0 +1,306 @@ +"""Reusable test helpers for the Buckaroo SDK test suite. + +Named ``Helpers`` (not ``TestHelpers``) so pytest doesn't auto-collect the +class under its ``Test*`` discovery rule. +""" + +from __future__ import annotations + +import json +import secrets +from typing import Any, Dict, Optional + +from tests.support.mock_request import BuckarooMockRequest + +STATUS_SUCCESS = 190 +STATUS_FAILED = 490 +SUBCODE_SUCCESS = "S001" +SUBCODE_FAILED = "F001" +FIXED_DATETIME = "2026-01-01T00:00:00" +FIXED_INVOICE = "INV-FIXED" + + +class Helpers: + """Fixture builders for Buckaroo-shaped payloads and responses.""" + + @staticmethod + def generate_transaction_key() -> str: + """Return a 32-character uppercase hex transaction key.""" + return secrets.token_hex(16).upper() + + @staticmethod + def standard_payload(invoice: str = "INV-1", **overrides: Any) -> Dict[str, Any]: + """Return the standard create_payment payload every feature test uses. + + ``overrides`` are shallow-merged over the defaults, so callers can + adjust or add fields (e.g. ``service_parameters``) per-test. + """ + payload: Dict[str, Any] = { + "currency": "EUR", + "amount": 10.00, + "description": "Test", + "invoice": invoice, + "return_url": "https://example.com/return", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + } + payload.update(overrides) + return payload + + @staticmethod + def success_response(overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Return a Buckaroo-shaped success response dict. + + ``overrides`` is shallow-merged over the top-level dict. + """ + response: Dict[str, Any] = { + "Key": Helpers.generate_transaction_key(), + "Status": { + "Code": {"Code": STATUS_SUCCESS, "Description": "Success"}, + "SubCode": {"Code": SUBCODE_SUCCESS, "Description": "Transaction successful"}, + "DateTime": FIXED_DATETIME, + }, + "RequiredAction": None, + "Services": [], + "Invoice": FIXED_INVOICE, + "ServiceCode": "creditcard", + "IsTest": True, + "Currency": "EUR", + "AmountDebit": 10.00, + } + if overrides: + response.update(overrides) + return response + + @staticmethod + def failed_response( + error: str = "Transaction failed", + overrides: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Return a Buckaroo-shaped failed response dict. + + Mutates only ``Status.Code`` and ``Status.SubCode`` on top of + :meth:`success_response`, then shallow-merges ``overrides`` over the + top-level dict. + """ + response = Helpers.success_response() + response["Status"]["Code"] = {"Code": STATUS_FAILED, "Description": "Failed"} + response["Status"]["SubCode"] = {"Code": SUBCODE_FAILED, "Description": error} + if overrides: + response.update(overrides) + return response + + @staticmethod + def pending_redirect_response( + service_name: str, + action: str = "Pay", + redirect_url: Optional[str] = None, + overrides: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Buckaroo-shaped pending response with redirect action.""" + tx_key = Helpers.generate_transaction_key() + if redirect_url is None: + redirect_url = f"https://checkout.buckaroo.nl/redirect/{tx_key}" + response: Dict[str, Any] = { + "Key": tx_key, + "Status": { + "Code": {"Code": 791, "Description": "Pending processing"}, + "SubCode": {"Code": "S001", "Description": "Transaction pending"}, + "DateTime": FIXED_DATETIME, + }, + "RequiredAction": { + "Name": "Redirect", + "RedirectURL": redirect_url, + }, + "Services": [ + { + "Name": service_name, + "Action": action, + "Parameters": [], + } + ], + "Invoice": FIXED_INVOICE, + "ServiceCode": service_name, + "IsTest": True, + "Currency": "EUR", + "AmountDebit": 10.00, + } + if overrides: + response.update(overrides) + return response + + @staticmethod + def refund_response( + service_name: str, + overrides: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Buckaroo-shaped successful refund response.""" + response = Helpers.success_response( + { + "Services": [{"Name": service_name, "Action": "Refund", "Parameters": []}], + "ServiceCode": service_name, + "AmountCredit": 10.00, + "AmountDebit": None, + } + ) + if overrides: + response.update(overrides) + return response + + @staticmethod + def assert_pay_returns_pending_with_redirect( + buckaroo: Any, + mock_strategy: Any, + *, + method: str, + invoice: str, + service_params: Optional[Dict[str, Any]] = None, + payload_overrides: Optional[Dict[str, Any]] = None, + response_overrides: Optional[Dict[str, Any]] = None, + ) -> Any: + """Queue a pending-redirect mock, run ``pay()``, assert the common trio. + + Returns the ``PaymentResponse`` so callers can tack on extra + per-method assertions (currency, amount_debit, etc.). + """ + response_body = Helpers.pending_redirect_response(method, overrides=response_overrides) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + overrides = dict(payload_overrides or {}) + overrides.pop("invoice", None) + payload = Helpers.standard_payload(invoice=invoice, **overrides) + if service_params is not None: + payload["service_parameters"] = service_params + response = buckaroo.payments.create_payment(method, payload).pay() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + _assert_recorded_action(mock_strategy, "Pay") + return response + + @staticmethod + def assert_refund_returns_success( + buckaroo: Any, + mock_strategy: Any, + *, + method: str, + invoice: str, + original_transaction_key: str = "ABC123", + payload_overrides: Optional[Dict[str, Any]] = None, + ) -> Any: + """Queue a refund-shaped response, run ``refund()``, assert success.""" + response_body = Helpers.refund_response(method) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + overrides = { + "description": "Refund", + "original_transaction_key": original_transaction_key, + **(payload_overrides or {}), + } + overrides.pop("invoice", None) + payload = Helpers.standard_payload(invoice=invoice, **overrides) + response = buckaroo.payments.create_payment(method, payload).refund() + assert response.status.code.code == STATUS_SUCCESS + assert response.key == response_body["Key"] + _assert_recorded_action(mock_strategy, "Refund") + return response + + @staticmethod + def assert_instant_refund_returns_success( + buckaroo: Any, + mock_strategy: Any, + *, + method: str, + invoice: str, + original_transaction_key: str = "ABC123", + payload_overrides: Optional[Dict[str, Any]] = None, + ) -> Any: + """Queue an InstantRefund-shaped response, run ``instantRefund()``.""" + response_body = Helpers.success_response( + { + "Services": [{"Name": method, "Action": "InstantRefund", "Parameters": []}], + "ServiceCode": method, + "AmountCredit": 10.00, + "AmountDebit": None, + } + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + overrides = { + "description": "Instant refund", + "original_transaction_key": original_transaction_key, + **(payload_overrides or {}), + } + overrides.pop("invoice", None) + payload = Helpers.standard_payload(invoice=invoice, **overrides) + response = buckaroo.payments.create_payment(method, payload).instantRefund() + assert response.status.code.code == STATUS_SUCCESS + assert response.key == response_body["Key"] + _assert_recorded_action(mock_strategy, "instantRefund") + return response + + @staticmethod + def assert_action_returns_pending_with_redirect( + buckaroo: Any, + mock_strategy: Any, + *, + method: str, + invoice: str, + action_name: str, + call_method: str, + payload_overrides: Optional[Dict[str, Any]] = None, + extra_builder_setup: Optional[Any] = None, + ) -> Any: + """Queue a pending-redirect mock for ``action_name``, invoke ``call_method``. + + ``extra_builder_setup`` is a callable that receives the builder + before the action fires, so tests can ``add_parameter(...)``. + """ + response_body = Helpers.pending_redirect_response(method, action_name) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + overrides = dict(payload_overrides or {}) + overrides.pop("invoice", None) + payload = Helpers.standard_payload(invoice=invoice, **overrides) + builder = buckaroo.payments.create_payment(method, payload) + if extra_builder_setup is not None: + extra_builder_setup(builder) + response = getattr(builder, call_method)() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + _assert_recorded_action(mock_strategy, action_name) + return response + + @staticmethod + def assert_fast_checkout_returns_pending_with_redirect( + buckaroo: Any, + mock_strategy: Any, + *, + method: str, + invoice: str, + payload_overrides: Optional[Dict[str, Any]] = None, + ) -> Any: + """Queue a PayFastCheckout redirect response, run ``payFastCheckout()``.""" + response_body = Helpers.pending_redirect_response(method, "PayFastCheckout") + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", response_body)) + overrides = { + "description": "Fast checkout", + **(payload_overrides or {}), + } + overrides.pop("invoice", None) + payload = Helpers.standard_payload(invoice=invoice, **overrides) + response = buckaroo.payments.create_payment(method, payload).payFastCheckout() + assert response.is_pending() + assert response.get_redirect_url() is not None + assert response.key == response_body["Key"] + _assert_recorded_action(mock_strategy, "payFastCheckout") + return response + + +def _assert_recorded_action(mock_strategy: Any, expected: str) -> None: + """Assert the last outgoing request carried ``Action=expected``. + + Requires a ``RecordingMock`` (root ``mock_strategy`` fixture). Raises + ``AttributeError`` if the mock doesn't record calls, so swapping the + fixture to a plain ``MockBuckaroo`` surfaces immediately. + """ + actual = json.loads(mock_strategy.calls[-1]["data"])["Services"]["ServiceList"][0]["Action"] + assert actual == expected, f"wire-level Action mismatch: expected {expected!r}, got {actual!r}" diff --git a/tests/support/mock_buckaroo.py b/tests/support/mock_buckaroo.py new file mode 100644 index 0000000..3e8843c --- /dev/null +++ b/tests/support/mock_buckaroo.py @@ -0,0 +1,90 @@ +"""Queue-based fake :class:`HttpStrategy` for deterministic SDK tests. + +Drop-in replacement for :class:`RequestsStrategy` / :class:`CurlStrategy`. +Queue expected requests in order; every outgoing call pops the next and +returns its canned response. + +Usage:: + + from tests.support.mock_buckaroo import MockBuckaroo + from tests.support.mock_request import BuckarooMockRequest + + def test_it(mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/Transaction*", + {"Key": "abc", "Status": {"Code": {"Code": 190}}}, + ) + ) + + # inject mock_strategy as the http strategy for your client + response = mock_strategy.request("POST", "https://x/json/Transaction") + assert response.json()["Status"]["Code"]["Code"] == 190 +""" + +from __future__ import annotations + +from collections import deque +from typing import Deque, Dict, Iterable, Optional + +from buckaroo.http.strategies.http_strategy import HttpResponse, HttpStrategy + +from .mock_request import BuckarooMockRequest + + +class MockBuckaroo(HttpStrategy): + """Order-based fake HTTP strategy. Queue requests, then call ``request``.""" + + def __init__(self) -> None: + self._queue: Deque[BuckarooMockRequest] = deque() + + def configure(self, **kwargs) -> None: + return None + + def is_available(self) -> bool: + return True + + def get_name(self) -> str: + return "mock" + + def queue(self, req: BuckarooMockRequest) -> "MockBuckaroo": + self._queue.append(req) + return self + + def queue_many(self, reqs: Iterable[BuckarooMockRequest]) -> "MockBuckaroo": + self._queue.extend(list(reqs)) + return self + + def reset(self) -> None: + self._queue.clear() + + def assert_all_consumed(self) -> None: + leftover = len(self._queue) + if leftover > 0: + raise AssertionError(f"{leftover} Buckaroo mock request(s) were not consumed") + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[str] = None, + timeout: Optional[int] = None, + verify_ssl: bool = True, + ) -> HttpResponse: + if not self._queue: + raise AssertionError( + f"Unexpected Buckaroo call with no mocks left: {method.upper()} {url}" + ) + + expected = self._queue[0] + if not expected.matches(method, url): + raise AssertionError(expected.mismatch_message(method, url)) + + self._queue.popleft() + + if expected.exception is not None: + raise expected.exception + + return expected.to_http_response() diff --git a/tests/support/mock_request.py b/tests/support/mock_request.py new file mode 100644 index 0000000..3be619f --- /dev/null +++ b/tests/support/mock_request.py @@ -0,0 +1,107 @@ +"""Expected request + canned response used by :class:`MockBuckaroo`.""" + +from __future__ import annotations + +import json as _json +import re +from fnmatch import translate +from typing import Any, Dict, Optional + +from buckaroo.http.strategies.http_strategy import HttpResponse + + +class BuckarooMockRequest: + """One expected HTTP request paired with a canned response or exception. + + URL patterns support three modes: + + - exact match: the pattern must equal the URL byte-for-byte. + - ``*`` wildcard glob: any pattern containing ``*`` is matched with + :func:`fnmatch.translate` + :func:`re.fullmatch`. + - ``/regex/`` delimited regex: patterns wrapped in forward slashes are + matched with :func:`re.search` (inner text is the regex). + """ + + def __init__(self, method: str, url_pattern: str) -> None: + self._method = method.upper() + self._url_pattern = url_pattern + self._url_matcher = self._compile_url_matcher(url_pattern) + self._status = 200 + self._headers: Dict[str, str] = {} + self._body: Any = None + self._raw_text: Optional[str] = None + self._content_type: str = "application/json" + self._exception: Optional[BaseException] = None + + @staticmethod + def _compile_url_matcher(pattern: str): + if len(pattern) >= 2 and pattern.startswith("/") and pattern.endswith("/"): + compiled = re.compile(pattern[1:-1]) + return lambda url: compiled.search(url) is not None + if "*" in pattern: + compiled = re.compile(translate(pattern)) + return lambda url: compiled.fullmatch(url) is not None + return lambda url: url == pattern + + @classmethod + def json( + cls, + method: str, + url_pattern: str, + body: Any, + status: int = 200, + headers: Optional[Dict[str, str]] = None, + ) -> "BuckarooMockRequest": + req = cls(method, url_pattern) + req._status = status + req._body = body + req._headers = dict(headers) if headers else {} + return req + + @classmethod + def text( + cls, + method: str, + url_pattern: str, + body: str, + status: int = 200, + headers: Optional[Dict[str, str]] = None, + content_type: str = "text/html", + ) -> "BuckarooMockRequest": + """Canned raw-text (non-JSON) response. Body is emitted verbatim.""" + req = cls(method, url_pattern) + req._status = status + req._raw_text = body + req._content_type = content_type + req._headers = dict(headers) if headers else {} + return req + + def with_exception(self, exc: BaseException) -> "BuckarooMockRequest": + self._exception = exc + return self + + @property + def exception(self) -> Optional[BaseException]: + return self._exception + + def matches(self, method: str, url: str) -> bool: + if method.upper() != self._method: + return False + return self._url_matcher(url) + + def mismatch_message(self, method: str, url: str) -> str: + return ( + "Buckaroo request mismatch\n" + f"expected: {self._method} {self._url_pattern}\n" + f"actual: {method.upper()} {url}" + ) + + def to_http_response(self) -> HttpResponse: + headers = {"Content-Type": self._content_type, **self._headers} + text = self._raw_text if self._raw_text is not None else _json.dumps(self._body) + return HttpResponse( + status_code=self._status, + headers=headers, + text=text, + success=200 <= self._status < 300, + ) diff --git a/tests/support/recording_mock.py b/tests/support/recording_mock.py new file mode 100644 index 0000000..821f7d9 --- /dev/null +++ b/tests/support/recording_mock.py @@ -0,0 +1,103 @@ +"""Recording variant of :class:`MockBuckaroo` plus a stub client wiring helper. + +Phase-4 tests wire a real :class:`BuckarooHttpClient` to a recording HTTP +strategy so they can assert the exact request shape that reached the wire +(via ``json.loads(call["data"])``). The pattern was copy-pasted across 7 +test files before being consolidated here. + +Usage:: + + from tests.support.recording_mock import ( + recorded_action, + recorded_request, + wire_recording_http, + ) + + def test_something(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + + # ...drive the SUT via client.http_client... + + assert recorded_action(mock) == "Pay" +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional, Tuple + +from buckaroo.config.buckaroo_config import BuckarooConfig +from buckaroo.http.client import BuckarooHttpClient + +from .mock_buckaroo import MockBuckaroo + + +class RecordingMock(MockBuckaroo): + """:class:`MockBuckaroo` variant that records every outgoing request.""" + + def __init__(self) -> None: + super().__init__() + self.calls: List[Dict[str, Any]] = [] + + def request(self, method, url, headers=None, data=None, timeout=None, verify_ssl=True): + self.calls.append( + { + "method": method, + "url": url, + "headers": dict(headers) if headers else {}, + "data": data, + "timeout": timeout, + "verify_ssl": verify_ssl, + } + ) + return super().request(method, url, headers, data, timeout, verify_ssl) + + +class StubClient: + """Thin stand-in for :class:`BuckarooClient` that exposes ``http_client``.""" + + def __init__(self, mock: RecordingMock, *, config: Optional[BuckarooConfig] = None) -> None: + self.http_client = BuckarooHttpClient( + store_key="test_store_key", + secret_key="test_secret_key", + config=config or BuckarooConfig(), + ) + self.http_client.http_strategy = mock + + +def wire_recording_http( + *, config: Optional[BuckarooConfig] = None +) -> Tuple[RecordingMock, StubClient]: + """Return a fresh ``(mock, stub_client)`` pair wired together. + + The returned stub client has a real :class:`BuckarooHttpClient` whose + ``http_strategy`` is the recording mock, so every call made through the + client is appended to ``mock.calls``. + """ + mock = RecordingMock() + client = StubClient(mock, config=config) + return mock, client + + +def recorded_request(mock: RecordingMock) -> Dict[str, Any]: + """Return the single recorded call's parsed JSON body. + + Fails the test if zero or more than one call was recorded. + """ + assert len(mock.calls) == 1, f"expected 1 call, got {len(mock.calls)}" + return json.loads(mock.calls[0]["data"]) + + +def recorded_action(mock: RecordingMock) -> str: + """Return the ``Action`` from the single recorded call's first service.""" + return recorded_request(mock)["Services"]["ServiceList"][0]["Action"] + + +def recorded_service_parameters(mock: RecordingMock) -> List[Dict[str, Any]]: + """Return the ``Parameters`` list from the single recorded call's first service. + + Each entry is the full Buckaroo shape, i.e. + ``{"Name": ..., "GroupType": ..., "GroupID": ..., "Value": ...}``. + """ + return recorded_request(mock)["Services"]["ServiceList"][0].get("Parameters") or [] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/builders/__init__.py b/tests/unit/builders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/builders/payments/__init__.py b/tests/unit/builders/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/builders/payments/capabilities/__init__.py b/tests/unit/builders/payments/capabilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py b/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py new file mode 100644 index 0000000..3dd5e75 --- /dev/null +++ b/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py @@ -0,0 +1,313 @@ +"""Tests for :class:`AuthorizeCaptureCapable`. + +Exercises the capability mixin through a real :class:`BuckarooHttpClient` +wired to a recording :class:`MockBuckaroo`. Assertions read the request +shape off the recorded HTTP call (``json.loads(call["data"])``), never +builder internals. +""" + +from __future__ import annotations + +import json + +import pytest + +from buckaroo.builders.payments.capabilities.authorize_capture_capable import ( + AuthorizeCaptureCapable, +) +from buckaroo.builders.payments.capabilities.encrypted_pay_capable import ( + EncryptedPayCapable, +) +from tests.support.builders import make_test_builder, populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + wire_recording_http, +) + + +# --------------------------------------------------------------------------- +# Helpers + + +def _ready_builder(client, capabilities=(AuthorizeCaptureCapable,), allowed=None): + """Build a fully-populated test builder so ``build()`` passes required checks.""" + builder = make_test_builder( + client, + service_name="dummy", + allowed_params=allowed or {}, + capabilities=capabilities, + ) + populate_required_fields(builder) + return builder + + +# --------------------------------------------------------------------------- +# authorize() + + +class TestAuthorize: + def test_authorize_posts_action_Authorize(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.authorize(validate=False) + + assert recorded_action(mock) == "Authorize" + mock.assert_all_consumed() + + def test_authorize_posts_to_transaction_endpoint(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = _ready_builder(client) + + builder.authorize(validate=False) + + assert mock.calls[0]["method"] == "POST" + assert "/json/transaction" in mock.calls[0]["url"].lower() + + +# --------------------------------------------------------------------------- +# authorizeEncrypted() + + +class TestAuthorizeEncrypted: + def test_authorize_encrypted_posts_action_AuthorizeEncrypted(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.authorizeEncrypted(validate=False) + + assert recorded_action(mock) == "AuthorizeEncrypted" + + +# --------------------------------------------------------------------------- +# capture() +# +# AuthorizeCaptureCapable.capture is dead code: BaseBuilder.capture shadows +# it through the MRO on every real builder (see TestMroShadowing below). Any +# test calling the mixin method directly would only exercise unreachable code. +# +# --------------------------------------------------------------------------- +# cancelAuthorize() + + +class TestCancelAuthorize: + @pytest.mark.parametrize( + "key_source,expected_key", + [ + ("arg", "key-from-arg"), + ("original_transaction_key_payload", "key-from-otk"), + ("authorization_key_payload", "key-from-auth"), + ], + ) + def test_cancel_authorize_reads_key_from_each_source(self, key_source, expected_key): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + if key_source == "arg": + builder.cancelAuthorize(original_transaction_key=expected_key, validate=False) + elif key_source == "original_transaction_key_payload": + builder.from_dict({"original_transaction_key": expected_key}) + builder.cancelAuthorize(validate=False) + else: + builder.from_dict({"authorization_key": expected_key}) + builder.cancelAuthorize(validate=False) + + body = recorded_request(mock) + assert body["OriginalTransactionKey"] == expected_key + + def test_cancel_authorize_arg_wins_over_payload(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = _ready_builder(client) + builder.from_dict( + { + "original_transaction_key": "from-payload", + "authorization_key": "from-auth", + } + ) + + builder.cancelAuthorize(original_transaction_key="from-arg", validate=False) + + assert recorded_request(mock)["OriginalTransactionKey"] == "from-arg" + + def test_cancel_authorize_without_any_key_raises_value_error(self): + _, client = wire_recording_http() + builder = _ready_builder(client) + + with pytest.raises(ValueError, match="original_transaction_key"): + builder.cancelAuthorize(validate=False) + + def test_cancel_authorize_swaps_amount_debit_to_amount_credit(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = _ready_builder(client) # amount(10.0) sets _amount_debit + + builder.cancelAuthorize(original_transaction_key="abc", validate=False) + + body = recorded_request(mock) + assert "AmountDebit" not in body + assert body["AmountCredit"] == 10.0 + + def test_cancel_authorize_posts_action_CancelAuthorize(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = _ready_builder(client) + + builder.cancelAuthorize(original_transaction_key="abc", validate=False) + + assert recorded_action(mock) == "CancelAuthorize" + + def test_cancel_authorize_when_both_amounts_present_debit_replaces_credit(self): + """When AmountDebit AND a pre-existing AmountCredit both appear on the + built request, the swap clobbers AmountCredit with the old debit value. + + Implementation does ``req['AmountCredit'] = req.pop('AmountDebit')``, + so any prior AmountCredit is lost. This pins that behavior. + """ + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = _ready_builder(client) + + # Patch ``build`` so the resulting ``to_dict()`` returns a dict that + # contains BOTH AmountCredit (5.0) AND AmountDebit (10.0) — a shape + # the mixin has to resolve. + original_build = builder.build + + class _BothAmounts: + def __init__(self, underlying): + self._underlying = underlying + + def to_dict(self): + d = self._underlying.to_dict() + d["AmountCredit"] = 5.0 # pre-existing credit + return d + + def _build(action="Pay", validate=True, strict_validation=False): + return _BothAmounts(original_build(action, validate, strict_validation)) + + builder.build = _build + + builder.cancelAuthorize(original_transaction_key="abc", validate=False) + + body = recorded_request(mock) + # AmountDebit gone; AmountCredit holds the former debit value (10.0). + assert "AmountDebit" not in body + assert body["AmountCredit"] == 10.0 + + +# --------------------------------------------------------------------------- +# MRO / composition + + +class TestMroShadowing: + """Pin how capability methods compose into a builder's MRO.""" + + def test_base_builder_capture_shadows_mixin_capture(self): + _, client = wire_recording_http() + builder = _ready_builder(client) + + # The capture the instance resolves is BaseBuilder's (needs auth key). + assert type(builder).capture.__qualname__ == "BaseBuilder.capture" + # The mixin's simpler capture is still reachable via the class itself. + assert AuthorizeCaptureCapable.capture.__qualname__ == ("AuthorizeCaptureCapable.capture") + + def test_mixin_capture_posts_capture_action_when_invoked_directly(self): + """Direct-invocation pin on the mixin's ``capture`` body. + + No composed builder routes to this method because ``BaseBuilder.capture`` + shadows it in MRO. The method is only callable as an unbound reference. + Pinned here so the shadowed logic still has a behavioral contract. + """ + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "cap"})) + builder = _ready_builder(client) + + response = AuthorizeCaptureCapable.capture(builder, validate=False) + + assert recorded_action(mock) == "Capture" + assert response.key == "cap" + + +class TestMultiCapabilityBuilder: + """Composing both mixins must expose all six action methods with no collisions.""" + + def test_all_six_action_methods_invoke_and_post_expected_actions(self): + """MRO-resolved action methods must each post the right Buckaroo Action. + + ``capture`` resolves to :meth:`BaseBuilder.capture` (needs an auth + key) - the mixin's ``capture`` is dead code. This test pins both the + resolution AND the on-wire Action for every method on a composed + builder. + """ + mock, client = wire_recording_http() + # One queued response per invoked action (six total). + for _ in range(6): + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + + def _fresh_builder(): + b = make_test_builder( + client, + service_name="dummy", + capabilities=(EncryptedPayCapable, AuthorizeCaptureCapable), + ) + return populate_required_fields(b) + + calls = [ + ("pay", lambda b: b.pay(validate=False), "Pay"), + ("authorize", lambda b: b.authorize(validate=False), "Authorize"), + ( + "authorizeEncrypted", + lambda b: b.authorizeEncrypted(validate=False), + "AuthorizeEncrypted", + ), + ( + "capture", + lambda b: b.capture(original_transaction_key="AUTH-1", validate=False), + "Capture", + ), + ( + "payEncrypted", + lambda b: b.payEncrypted(validate=False), + "PayEncrypted", + ), + ( + "cancelAuthorize", + lambda b: b.cancelAuthorize(original_transaction_key="AUTH-1", validate=False), + "CancelAuthorize", + ), + ] + + for _name, invoke, _action in calls: + invoke(_fresh_builder()) + + observed = [ + json.loads(c["data"])["Services"]["ServiceList"][0]["Action"] for c in mock.calls + ] + expected = [action for _, _, action in calls] + assert observed == expected + + def test_authorize_and_payEncrypted_coexist(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + + builder = make_test_builder( + client, + service_name="dummy", + capabilities=(EncryptedPayCapable, AuthorizeCaptureCapable), + ) + populate_required_fields(builder) + + builder.authorize(validate=False) + builder.payEncrypted(validate=False) + + actions = [ + json.loads(c["data"])["Services"]["ServiceList"][0]["Action"] for c in mock.calls + ] + assert actions == ["Authorize", "PayEncrypted"] diff --git a/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py b/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py new file mode 100644 index 0000000..25107e6 --- /dev/null +++ b/tests/unit/builders/payments/capabilities/test_bank_transfer_capabilities.py @@ -0,0 +1,145 @@ +"""Tests for :class:`BankTransferCapabilities`. + +``BankTransferCapabilities`` composes :class:`InstantRefundCapable` and +:class:`FastCheckoutCapable` — no new methods of its own. These tests +exercise the composition through a real :class:`BuckarooHttpClient` +wired to a recording :class:`MockBuckaroo`. Assertions read the request +shape off the recorded HTTP call (``json.loads(call["data"])``), never +builder internals. +""" + +from __future__ import annotations + +from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( + BankTransferCapabilities, +) +from buckaroo.builders.payments.capabilities.fast_checkout_capable import ( + FastCheckoutCapable, +) +from buckaroo.builders.payments.capabilities.instant_refund_capable import ( + InstantRefundCapable, +) +from buckaroo.models.payment_response import PaymentResponse +from tests.support.builders import make_test_builder, populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, wire_recording_http + + +# --------------------------------------------------------------------------- +# Helpers + + +def _ready_builder(client, allowed=None): + """Build a fully-populated test builder mixing in BankTransferCapabilities.""" + builder = make_test_builder( + client, + service_name="sofort", + allowed_params=allowed or {}, + capabilities=(BankTransferCapabilities,), + ) + populate_required_fields(builder) + return builder + + +# --------------------------------------------------------------------------- +# Composition + + +class TestComposition: + def test_mixes_in_instant_refund_capable(self): + _mock, client = wire_recording_http() + builder = _ready_builder(client) + + assert isinstance(builder, InstantRefundCapable) + + def test_mixes_in_fast_checkout_capable(self): + _mock, client = wire_recording_http() + builder = _ready_builder(client) + + assert isinstance(builder, FastCheckoutCapable) + + +# --------------------------------------------------------------------------- +# instantRefund() + + +class TestInstantRefund: + def test_posts_action_instantRefund(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund(validate=False) + + assert recorded_action(mock) == "instantRefund" + + def test_posts_to_transaction_endpoint(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund(validate=False) + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://testcheckout.buckaroo.nl/json/transaction" + + def test_returns_payment_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "refund-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.instantRefund(validate=False) + + assert isinstance(response, PaymentResponse) + assert response.key == "refund-123" + + +# --------------------------------------------------------------------------- +# payFastCheckout() + + +class TestPayFastCheckout: + def test_posts_action_payFastCheckout(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payFastCheckout(validate=False) + + assert recorded_action(mock) == "payFastCheckout" + + def test_posts_to_transaction_endpoint(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payFastCheckout(validate=False) + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://testcheckout.buckaroo.nl/json/transaction" + + def test_returns_payment_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "checkout-789", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.payFastCheckout(validate=False) + + assert isinstance(response, PaymentResponse) + assert response.key == "checkout-789" diff --git a/tests/unit/builders/payments/capabilities/test_encrypted_pay_capable.py b/tests/unit/builders/payments/capabilities/test_encrypted_pay_capable.py new file mode 100644 index 0000000..5fa7c82 --- /dev/null +++ b/tests/unit/builders/payments/capabilities/test_encrypted_pay_capable.py @@ -0,0 +1,138 @@ +"""Tests for :class:`EncryptedPayCapable`. + +Exercises the capability mixin through a real :class:`BuckarooHttpClient` +wired to a recording :class:`MockBuckaroo`. Assertions read the request +shape off the recorded HTTP call (``json.loads(call["data"])``), never +builder internals. + +The mixin currently exposes one method: :meth:`payEncrypted`. +``payWithSecurityCode`` and ``payWithToken`` live on +:class:`CreditcardBuilder`. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.capabilities.encrypted_pay_capable import ( + EncryptedPayCapable, +) +from buckaroo.exceptions._parameter_validation_error import ( + RequiredParameterMissingError, +) +from buckaroo.models.payment_response import PaymentResponse +from tests.support.builders import make_test_builder, populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + wire_recording_http, +) + + +# --------------------------------------------------------------------------- +# Helpers + + +def _ready_builder(client, allowed=None): + """Build a fully-populated test builder so ``build()`` passes required checks.""" + builder = make_test_builder( + client, + service_name="creditcard", + allowed_params=allowed or {}, + capabilities=(EncryptedPayCapable,), + ) + populate_required_fields(builder) + return builder + + +# --------------------------------------------------------------------------- +# payEncrypted() + + +class TestPayEncrypted: + def test_posts_action_PayEncrypted(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payEncrypted(validate=False) + + assert recorded_action(mock) == "PayEncrypted" + + def test_posts_to_transaction_endpoint(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payEncrypted(validate=False) + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://testcheckout.buckaroo.nl/json/transaction" + + def test_returns_PaymentResponse_parsed_from_http_body(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "txn-abc-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.payEncrypted(validate=False) + + assert isinstance(response, PaymentResponse) + assert response.key == "txn-abc-123" + + def test_posts_service_named_from_builder(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payEncrypted(validate=False) + + service = recorded_request(mock)["Services"]["ServiceList"][0] + assert service["Name"] == "creditcard" + assert service["Action"] == "PayEncrypted" + + def test_forwards_validate_flag_to_build(self): + """The ``validate`` kwarg flows through to ``build()``. + + Invalid parameters survive when ``validate=False`` because the + builder skips filtering. A validator run would have dropped them. + """ + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + builder.add_parameter("not_allowed", "value") + + builder.payEncrypted(validate=False) + + params = recorded_request(mock)["Services"]["ServiceList"][0].get("Parameters") or [] + names = {p["Name"] for p in params} + assert "Not_allowed" in names + + def test_forwards_validate_flag_true_triggers_validation(self): + """``validate=True`` runs the real service-parameter validator against + ``get_allowed_service_parameters("PayEncrypted")`` — so declaring a + required parameter and omitting it must raise + :class:`RequiredParameterMissingError`. Pins that the flag actually + routes through ``build()``'s validation, not just a quirk crash.""" + _mock, client = wire_recording_http() + builder = _ready_builder( + client, + allowed={ + "PayEncrypted": { + "encryptedPaymentData": {"type": str, "required": True}, + } + }, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.payEncrypted(validate=True) + + assert exc.value.parameter_name == "encryptedPaymentData" diff --git a/tests/unit/builders/payments/capabilities/test_fast_checkout_capable.py b/tests/unit/builders/payments/capabilities/test_fast_checkout_capable.py new file mode 100644 index 0000000..b86f507 --- /dev/null +++ b/tests/unit/builders/payments/capabilities/test_fast_checkout_capable.py @@ -0,0 +1,105 @@ +"""Tests for :class:`FastCheckoutCapable`. + +Exercises the capability mixin through a real :class:`BuckarooHttpClient` +wired to a recording :class:`MockBuckaroo`. Assertions read the request +shape off the recorded HTTP call (``json.loads(call["data"])``), never +builder internals. +""" + +from __future__ import annotations + +from buckaroo.builders.payments.capabilities.fast_checkout_capable import ( + FastCheckoutCapable, +) +from buckaroo.models.payment_response import PaymentResponse +from tests.support.builders import make_test_builder, populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + wire_recording_http, +) + + +# --------------------------------------------------------------------------- +# Helpers + + +def _ready_builder(client, allowed=None): + """Build a fully-populated test builder so ``build()`` passes required checks.""" + # Default: action known to validator with no allowed params (empty dict). + # Keys must be dicts; validator iterates via ``.items()``. + builder = make_test_builder( + client, + service_name="ideal", + allowed_params=allowed if allowed is not None else {"payFastCheckout": {}}, + capabilities=(FastCheckoutCapable,), + ) + populate_required_fields(builder) + return builder + + +# --------------------------------------------------------------------------- +# payFastCheckout() + + +class TestPayFastCheckout: + def test_posts_action_payFastCheckout(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payFastCheckout() + + assert recorded_action(mock) == "payFastCheckout" + + def test_posts_to_transaction_endpoint(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payFastCheckout() + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://testcheckout.buckaroo.nl/json/transaction" + + def test_posts_expected_service_name(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.payFastCheckout() + + service = recorded_request(mock)["Services"]["ServiceList"][0] + assert service["Name"] == "ideal" + + def test_returns_PaymentResponse_parsed_from_http_body(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "fastcheckout-xyz", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.payFastCheckout() + + assert isinstance(response, PaymentResponse) + assert response.key == "fastcheckout-xyz" + + def test_validate_false_skips_parameter_validation(self): + """With ``validate=False``, unknown parameters pass through to the request.""" + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client, allowed={"payFastCheckout": {}}) + builder.add_parameter("someUnknownParam", "value") + + builder.payFastCheckout(validate=False) + + service = recorded_request(mock)["Services"]["ServiceList"][0] + parameter_names = [p["Name"] for p in (service.get("Parameters") or [])] + assert "Someunknownparam" in parameter_names diff --git a/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py b/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py new file mode 100644 index 0000000..b024c52 --- /dev/null +++ b/tests/unit/builders/payments/capabilities/test_instant_refund_capable.py @@ -0,0 +1,116 @@ +"""Tests for :class:`InstantRefundCapable`. + +Exercises the capability mixin through a real :class:`BuckarooHttpClient` +wired to a recording :class:`MockBuckaroo`. Assertions read the request +shape off the recorded HTTP call (``json.loads(call["data"])``), never +builder internals. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.capabilities.instant_refund_capable import ( + InstantRefundCapable, +) +from buckaroo.exceptions._parameter_validation_error import ( + RequiredParameterMissingError, +) +from buckaroo.models.payment_response import PaymentResponse +from tests.support.builders import make_test_builder, populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import ( + recorded_action, + recorded_request, + wire_recording_http, +) + + +# --------------------------------------------------------------------------- +# Helpers + + +def _ready_builder(client, allowed=None): + """Build a fully-populated test builder so ``build()`` passes required checks.""" + builder = make_test_builder( + client, + service_name="ideal", + allowed_params=allowed or {}, + capabilities=(InstantRefundCapable,), + ) + populate_required_fields(builder) + return builder + + +# --------------------------------------------------------------------------- +# instantRefund() + + +class TestInstantRefund: + def test_posts_action_instantRefund(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund(validate=False) + + assert recorded_action(mock) == "instantRefund" + + def test_posts_to_transaction_endpoint(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund(validate=False) + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://testcheckout.buckaroo.nl/json/transaction" + + def test_posts_expected_service_name(self): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = _ready_builder(client) + + builder.instantRefund(validate=False) + + service = recorded_request(mock)["Services"]["ServiceList"][0] + assert service["Name"] == "ideal" + + def test_returns_payment_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "refund-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.instantRefund(validate=False) + + assert isinstance(response, PaymentResponse) + assert response.key == "refund-123" + + def test_forwards_validate_flag_true_triggers_validation(self): + """``validate=True`` runs the real service-parameter validator against + ``get_allowed_service_parameters("instantRefund")`` — so declaring a + required parameter and omitting it must raise + :class:`RequiredParameterMissingError`. Pins that the flag actually + routes through ``build()``'s validation, not just a quirk crash.""" + _mock, client = wire_recording_http() + builder = _ready_builder( + client, + allowed={ + "instantRefund": { + "refund_reason": {"type": str, "required": True}, + } + }, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.instantRefund(validate=True) + + assert exc.value.parameter_name == "refund_reason" diff --git a/tests/unit/builders/payments/test_alipay_builder.py b/tests/unit/builders/payments/test_alipay_builder.py new file mode 100644 index 0000000..c0b8fc7 --- /dev/null +++ b/tests/unit/builders/payments/test_alipay_builder.py @@ -0,0 +1,74 @@ +"""Unit tests for :class:`AlipayBuilder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/alipay_builder.py``. The builder carries no +capability mixins and defines no action methods of its own, so the surface +under test is: + +- construction via ``BuckarooClient`` wired to :class:`MockBuckaroo` +- ``get_service_name()`` +- ``get_allowed_service_parameters(action)`` for both branches +- ``pay()`` end-to-end through the mock strategy +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.alipay_builder import AlipayBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = AlipayBuilder(client) + assert isinstance(builder, AlipayBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_alipay(client): + assert AlipayBuilder(client).get_service_name() == "Alipay" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = AlipayBuilder(client) + + assert builder.get_allowed_service_parameters("Pay") == { + "UseMobileView": { + "type": (str, bool), + "required": True, + "description": "Use mobile view for Alipay", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = AlipayBuilder(client) + + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert AlipayBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "alipay-key-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(AlipayBuilder(client), amount=12.34) + .add_parameter("UseMobileView", True) + .pay() + ) + + assert response.key == "alipay-key-123" diff --git a/tests/unit/builders/payments/test_apple_pay_builder.py b/tests/unit/builders/payments/test_apple_pay_builder.py new file mode 100644 index 0000000..9533270 --- /dev/null +++ b/tests/unit/builders/payments/test_apple_pay_builder.py @@ -0,0 +1,71 @@ +"""Per-builder unit tests for :class:`ApplePayBuilder`. + +Covers construction, service-name shape, allowed-parameter snapshots for every +supported action, mixin presence, and an end-to-end ``pay()`` dispatch through +``MockBuckaroo``. Phase 7.2. +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.apple_pay_builder import ApplePayBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +def test_construct_with_buckaroo_client_returns_payment_builder(client): + builder = ApplePayBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_applepay(client): + assert ApplePayBuilder(client).get_service_name() == "applepay" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + assert ApplePayBuilder(client).get_allowed_service_parameters("Pay") == { + "PaymentData": { + "type": str, + "required": True, + "description": "Apple Pay payment data", + }, + "CustomerCardName": { + "type": str, + "required": False, + "description": "Customer card name", + }, + } + + +def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): + """Source lower-cases the action before matching, so "pay" equals "Pay".""" + builder = ApplePayBuilder(client) + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + assert ApplePayBuilder(client).get_allowed_service_parameters("Refund") == {} + + +def test_pay_dispatches_applepay_service_through_mock_buckaroo(): + client = BuckarooClient("store_key", "secret_key", mode="test") + mock = MockBuckaroo() + client.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "AP-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields(ApplePayBuilder(client), amount=10.50) + + response = builder.pay(validate=False) + + assert response.key == "AP-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_bancontact_builder.py b/tests/unit/builders/payments/test_bancontact_builder.py new file mode 100644 index 0000000..60c99b9 --- /dev/null +++ b/tests/unit/builders/payments/test_bancontact_builder.py @@ -0,0 +1,85 @@ +"""Unit tests for :class:`BancontactBuilder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/bancontact_builder.py``. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.bancontact_builder import BancontactBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = BancontactBuilder(client) + assert isinstance(builder, BancontactBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_bancontactmrcash(client): + assert BancontactBuilder(client).get_service_name() == "bancontactmrcash" + + +def test_get_allowed_service_parameters_pay(client): + params = BancontactBuilder(client).get_allowed_service_parameters("Pay") + assert "savetoken" in params + assert params["savetoken"]["required"] is False + + +def test_get_allowed_service_parameters_authenticate(client): + params = BancontactBuilder(client).get_allowed_service_parameters("Authenticate") + assert params == BancontactBuilder(client).get_allowed_service_parameters("Pay") + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = BancontactBuilder(client) + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +def test_get_allowed_service_parameters_payEncrypted_snapshot(client): + assert BancontactBuilder(client).get_allowed_service_parameters("payEncrypted") == { + "encryptedCardData": { + "type": str, + "required": True, + "description": "Encrypted card data for payment", + }, + } + + +def test_get_allowed_service_parameters_completePayment_snapshot(client): + assert BancontactBuilder(client).get_allowed_service_parameters("completePayment") == { + "encryptedCardData": { + "type": str, + "required": True, + "description": "Encrypted card data for payment", + }, + } + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Cancel"]) +def test_get_allowed_service_parameters_refund_capture_cancel(client, action): + assert BancontactBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_unknown_action(client): + assert BancontactBuilder(client).get_allowed_service_parameters("SomethingElse") == {} + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "bancontact-key-456", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields(BancontactBuilder(client), amount=25.00).pay() + + assert response.key == "bancontact-key-456" diff --git a/tests/unit/builders/payments/test_belfius_builder.py b/tests/unit/builders/payments/test_belfius_builder.py new file mode 100644 index 0000000..4ad80d1 --- /dev/null +++ b/tests/unit/builders/payments/test_belfius_builder.py @@ -0,0 +1,87 @@ +"""Unit coverage for :class:`BelfiusBuilder`. + +Phase 7.4 — per-builder coverage. BelfiusBuilder is a minimal subclass of +:class:`PaymentBuilder` that only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes. +Tests exercise every public surface and pin the allowed-parameter shape for +every action we care about. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.belfius_builder import BelfiusBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wired_to_mock_buckaroo_succeeds(client): + builder = BelfiusBuilder(client) + + assert isinstance(builder, BelfiusBuilder) + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_belfius(client): + builder = BelfiusBuilder(client) + + assert builder.get_service_name() == "belfius" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "PayRemainder", "ExtraInfo", "UnknownAction"], +) +def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(client, action): + builder = BelfiusBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = BelfiusBuilder(client) + + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_belfius_service_to_transaction_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "BEL-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(BelfiusBuilder(client), amount=12.34) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "belfius" + assert service["Action"] == "Pay" + assert response.key == "BEL-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_billink_builder.py b/tests/unit/builders/payments/test_billink_builder.py new file mode 100644 index 0000000..127845d --- /dev/null +++ b/tests/unit/builders/payments/test_billink_builder.py @@ -0,0 +1,107 @@ +"""Unit coverage for :class:`BillinkBuilder`. + +Billink is a buy-now-pay-later method. The builder has no capability mixins +and exposes a single cart-line-item oriented parameter spec for ``Pay``. +Per-action spec and end-to-end ``pay()`` via :class:`MockBuckaroo` are pinned +inline so drift is loud. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.billink_builder import BillinkBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client: BuckarooClient) -> BillinkBuilder: + return BillinkBuilder(client) + + +def test_builder_instantiates_as_payment_builder(builder: BillinkBuilder) -> None: + assert isinstance(builder, BillinkBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_billink(builder: BillinkBuilder) -> None: + assert builder.get_service_name() == "billink" + + +def test_get_allowed_service_parameters_pay_snapshot(builder: BillinkBuilder) -> None: + assert builder.get_allowed_service_parameters("Pay") == { + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, + "article": { + "type": list, + "required": True, + "description": "Billink articles", + }, + } + + +def test_get_allowed_service_parameters_pay_case_insensitive( + builder: BillinkBuilder, +) -> None: + # Source lowercases the action before comparing. + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + assert builder.get_allowed_service_parameters("PAY") == builder.get_allowed_service_parameters( + "Pay" + ) + + +@pytest.mark.parametrize("action", ["Refund", "Authorize", "Capture", "CancelAuthorize", ""]) +def test_get_allowed_service_parameters_non_pay_actions_return_empty( + builder: BillinkBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +def test_pay_end_to_end_via_mock_buckaroo( + builder: BillinkBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "billink-key", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(builder, amount=49.95) + .from_dict( + { + "service_parameters": { + "billingCustomer": {"firstName": "Jane", "lastName": "Doe"}, + "shippingCustomer": {"firstName": "Jane", "lastName": "Doe"}, + "article": [ + { + "identifier": "SKU-1", + "description": "Widget", + "quantity": 1, + "price": 49.95, + }, + ], + } + } + ) + .pay() + ) + + assert response.key == "billink-key" + assert response.status.code.code == 190 diff --git a/tests/unit/builders/payments/test_bizum_builder.py b/tests/unit/builders/payments/test_bizum_builder.py new file mode 100644 index 0000000..a624982 --- /dev/null +++ b/tests/unit/builders/payments/test_bizum_builder.py @@ -0,0 +1,95 @@ +"""Unit coverage for :class:`BizumBuilder`. + +Phase 7.6 — per-builder coverage. BizumBuilder is a minimal subclass of +:class:`PaymentBuilder`. It only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes +and declares no ``_serviceName`` class attribute. Tests pin the public +surface and the allowed-parameter shape for every action we care about. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.bizum_builder import BizumBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_with_client_succeeds(client): + builder = BizumBuilder(client) + + assert isinstance(builder, BizumBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_bizum_builder_does_not_declare_service_name_class_attribute(): + """BizumBuilder relies on :meth:`get_service_name` — no ``_serviceName`` attr. + + Pin the stub shape so a future refactor that introduces ``_serviceName`` + has to update the assertion consciously. + """ + assert "_serviceName" not in BizumBuilder.__dict__ + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_bizum(client): + builder = BizumBuilder(client) + + assert builder.get_service_name() == "Bizum" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "Capture", "Authorize", "UnknownAction"], +) +def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(client, action): + builder = BizumBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = BizumBuilder(client) + + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_bizum_service_to_transaction_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "BZM-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(BizumBuilder(client), amount=12.34) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Bizum" + assert service["Action"] == "Pay" + assert response.key == "BZM-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_blik_builder.py b/tests/unit/builders/payments/test_blik_builder.py new file mode 100644 index 0000000..652a7be --- /dev/null +++ b/tests/unit/builders/payments/test_blik_builder.py @@ -0,0 +1,97 @@ +"""Unit coverage for :class:`BlikBuilder`. + +Blik is a minimal payment builder: no capability mixins, empty allowed-service- +parameter map, ``"Blik"`` service name. These tests pin that surface and drive +a single ``pay()`` round-trip through :class:`MockBuckaroo` for end-to-end +coverage of the builder's inherited action path. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.blik_builder import BlikBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +@pytest.fixture +def builder(client: BuckarooClient) -> BlikBuilder: + return BlikBuilder(client) + + +def test_instantiates_as_payment_builder(builder: BlikBuilder) -> None: + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_blik(builder: BlikBuilder) -> None: + assert builder.get_service_name() == "Blik" + + +def test_get_allowed_service_parameters_pay_is_empty_dict( + builder: BlikBuilder, +) -> None: + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_default_action_matches_pay( + builder: BlikBuilder, +) -> None: + # The ``action`` parameter defaults to ``"Pay"``; snapshot the default branch. + assert builder.get_allowed_service_parameters() == {} + + +def test_get_allowed_service_parameters_other_actions_also_empty( + builder: BlikBuilder, +) -> None: + # BlikBuilder returns an empty dict unconditionally — snapshot non-Pay actions + # so a future per-action table doesn't silently regress the Blik contract. + for action in ("Refund", "Authorize", "Capture"): + assert builder.get_allowed_service_parameters(action) == {} + + +def test_does_not_mix_in_capability_only_methods(builder: BlikBuilder) -> None: + # Capability mixins are opt-in. Blik opts out; none of the capability-only + # methods (i.e. methods that *only* exist on a mixin, not on the base) should + # appear on the builder. ``capture`` is inherited from ``BaseBuilder`` and + # deliberately excluded from this list. + for method in ( + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "payEncrypted", + "instantRefund", + "payFastCheckout", + ): + assert not hasattr(builder, method), ( + f"BlikBuilder unexpectedly exposes capability method {method!r}" + ) + + +def test_pay_posts_transaction_through_mock_strategy( + builder: BlikBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "blik-key", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields( + builder, + currency="PLN", + description="Blik payment", + invoice="INV-BLIK-1", + return_url="https://example.test/return", + return_url_cancel="https://example.test/cancel", + return_url_error="https://example.test/error", + return_url_reject="https://example.test/reject", + ).pay() + + assert response.key == "blik-key" + assert response.status.code.code == 190 diff --git a/tests/unit/builders/payments/test_buckaroo_voucher_builder.py b/tests/unit/builders/payments/test_buckaroo_voucher_builder.py new file mode 100644 index 0000000..e308d37 --- /dev/null +++ b/tests/unit/builders/payments/test_buckaroo_voucher_builder.py @@ -0,0 +1,115 @@ +"""Unit tests for :class:`BuckarooVoucherBuilder`. + +Per-builder coverage: construction, ``get_service_name()``, parameter +spec snapshots per supported action, and an end-to-end post round-trip +via :class:`MockBuckaroo`. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.buckaroo_voucher_builder import ( + BuckarooVoucherBuilder, +) +from buckaroo.builders.payments.payment_builder import PaymentBuilder + + +class TestConstruction: + def test_instantiates_with_client_as_payment_builder(self, client): + builder = BuckarooVoucherBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +class TestServiceName: + def test_get_service_name_returns_buckaroo_voucher(self, client): + assert BuckarooVoucherBuilder(client).get_service_name() == "Buckaroo Voucher" + + +VOUCHER_CODE_SPEC = { + "VoucherCode": { + "type": str, + "required": True, + "description": "The voucher code to use for the payment", + }, +} + + +class TestAllowedServiceParameters: + def test_pay_action_returns_voucher_code_spec(self, client): + allowed = BuckarooVoucherBuilder(client).get_allowed_service_parameters("Pay") + assert allowed == VOUCHER_CODE_SPEC + + def test_getbalance_action_returns_voucher_code_spec(self, client): + allowed = BuckarooVoucherBuilder(client).get_allowed_service_parameters("GetBalance") + assert allowed == VOUCHER_CODE_SPEC + + def test_deactivatevoucher_action_returns_voucher_code_spec(self, client): + allowed = BuckarooVoucherBuilder(client).get_allowed_service_parameters("DeactivateVoucher") + assert allowed == VOUCHER_CODE_SPEC + + def test_createapplication_action_returns_application_spec(self, client): + allowed = BuckarooVoucherBuilder(client).get_allowed_service_parameters("CreateApplication") + assert allowed == { + "GroupReference": { + "type": str, + "required": False, + "description": "The group reference for the application", + }, + "UsageType": { + "type": str, + "required": True, + "description": "The usage type for the voucher application", + }, + "ValidFrom": { + "type": str, + "required": True, + "description": "The start date of voucher validity", + }, + "ValidUntil": { + "type": str, + "required": False, + "description": "The end date of voucher validity", + }, + "CreationBalance": { + "type": float, + "required": True, + "description": "The initial balance of the voucher", + }, + } + + def test_default_action_is_pay(self, client): + builder = BuckarooVoucherBuilder(client) + assert builder.get_allowed_service_parameters() == VOUCHER_CODE_SPEC + + @pytest.mark.parametrize( + "action", + [ + "pay", + "PAY", + "getbalance", + "GETBALANCE", + "deactivatevoucher", + "createapplication", + "CREATEAPPLICATION", + ], + ) + def test_action_matching_is_case_insensitive(self, client, action): + """The source lowercases ``action`` so alt-cased inputs hit the same branch.""" + builder = BuckarooVoucherBuilder(client) + allowed = builder.get_allowed_service_parameters(action) + canonical_actions = { + "pay": "Pay", + "PAY": "Pay", + "getbalance": "GetBalance", + "GETBALANCE": "GetBalance", + "deactivatevoucher": "DeactivateVoucher", + "createapplication": "CreateApplication", + "CREATEAPPLICATION": "CreateApplication", + } + expected = builder.get_allowed_service_parameters(canonical_actions[action]) + assert allowed == expected + + @pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "unknown"]) + def test_unsupported_action_returns_empty_dict(self, client, action): + assert BuckarooVoucherBuilder(client).get_allowed_service_parameters(action) == {} diff --git a/tests/unit/builders/payments/test_click_to_pay_builder.py b/tests/unit/builders/payments/test_click_to_pay_builder.py new file mode 100644 index 0000000..8f543b7 --- /dev/null +++ b/tests/unit/builders/payments/test_click_to_pay_builder.py @@ -0,0 +1,66 @@ +"""Per-builder unit tests for :class:`ClickToPayBuilder`. + +Covers the trivial per-builder surface: construction, service name, allowed +service parameters per action, capability sanity, and one end-to-end ``pay()`` +round-trip through :class:`MockBuckaroo`. + +``ClickToPayBuilder`` doesn't declare a ``_serviceName`` class attribute (unlike +:class:`CreditcardBuilder`); the authoritative service name is exposed via +``get_service_name()`` and is what gets asserted here. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.click_to_pay_builder import ClickToPayBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client): + return ClickToPayBuilder(client) + + +def test_construction_returns_payment_builder(builder): + assert isinstance(builder, PaymentBuilder) + assert isinstance(builder, ClickToPayBuilder) + + +def test_get_service_name_returns_click_to_pay(builder): + assert builder.get_service_name() == "ClickToPay" + + +def test_get_allowed_service_parameters_pay_is_empty(builder): + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_defaults_to_pay(builder): + assert builder.get_allowed_service_parameters() == {} + + +def test_get_allowed_service_parameters_unknown_action_is_empty(builder): + assert builder.get_allowed_service_parameters("Refund") == {} + + +def test_pay_end_to_end_through_mock_buckaroo(builder, mock_strategy): + """pay() builds a Pay action against the ClickToPay service, sends it + through the HTTP client, and returns a parsed PaymentResponse.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + { + "Key": "CTP-KEY", + "Status": {"Code": {"Code": 190, "Description": "Success"}}, + "Services": [{"Name": "ClickToPay", "Action": "Pay"}], + }, + ) + ) + + response = populate_required_fields(builder, amount=12.34).pay() + + assert response.key == "CTP-KEY" + assert response.status.code.code == 190 diff --git a/tests/unit/builders/payments/test_concrete_builders_contract.py b/tests/unit/builders/payments/test_concrete_builders_contract.py new file mode 100644 index 0000000..b0279a6 --- /dev/null +++ b/tests/unit/builders/payments/test_concrete_builders_contract.py @@ -0,0 +1,194 @@ +"""Shared contract tests for every concrete payment builder in the factory registry. + +Lands first in phase 7. Parametrizes over +``PaymentMethodFactory._payment_methods`` so any registry entry that fails the +builder contract surfaces before per-builder work starts. + +These are *surface* tests: instantiation, ``get_service_name()`` shape, and +``get_allowed_service_parameters("Pay")`` shape. Per-builder behaviour lives in +the dedicated test file for each builder. + +The capability matrix asserts that the documented method names on each +capability mixin are present and callable on every subclass that mixes it in. +Actual behaviour of those methods is covered by the phase-4 capability tests +under ``tests/unit/builders/payments/capabilities/``. +""" + +from __future__ import annotations + +from typing import Dict, List, Tuple, Type + +import pytest + +from buckaroo.builders.payments.capabilities.authorize_capture_capable import ( + AuthorizeCaptureCapable, +) +from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( + BankTransferCapabilities, +) +from buckaroo.builders.payments.capabilities.encrypted_pay_capable import ( + EncryptedPayCapable, +) +from buckaroo.builders.payments.capabilities.fast_checkout_capable import ( + FastCheckoutCapable, +) +from buckaroo.builders.payments.capabilities.instant_refund_capable import ( + InstantRefundCapable, +) +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.factories.payment_method_factory import PaymentMethodFactory + + +REGISTRY: List[Tuple[str, Type[PaymentBuilder]]] = sorted( + PaymentMethodFactory._payment_methods.items() +) + + +# Documented public methods each capability mixin contributes. Read off the +# mixin source — ``BankTransferCapabilities`` inherits from ``InstantRefundCapable`` +# and ``FastCheckoutCapable`` so it exposes both of their methods. +CAPABILITY_METHODS: Dict[Type, List[str]] = { + AuthorizeCaptureCapable: [ + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "capture", + ], + BankTransferCapabilities: ["instantRefund", "payFastCheckout"], + EncryptedPayCapable: ["payEncrypted"], + InstantRefundCapable: ["instantRefund"], + FastCheckoutCapable: ["payFastCheckout"], +} + + +def test_registry_has_sanity_floor(): + """Sanity floor so an accidental registry wipe fails loudly. The per-entry + parametrized tests below catch per-builder damage.""" + assert len(REGISTRY) >= 20 + + +@pytest.mark.parametrize( + "method_name,builder_class", REGISTRY, ids=lambda x: x if isinstance(x, str) else x.__name__ +) +def test_builder_instantiates_with_client(method_name, builder_class, client): + builder = builder_class(client) + assert isinstance(builder, PaymentBuilder) + + +@pytest.mark.parametrize( + "method_name,builder_class", REGISTRY, ids=lambda x: x if isinstance(x, str) else x.__name__ +) +def test_builder_get_service_name_returns_non_empty_string(method_name, builder_class, client): + builder = builder_class(client) + service_name = builder.get_service_name() + assert isinstance(service_name, str) + assert service_name != "" + + +@pytest.mark.parametrize( + "method_name,builder_class", REGISTRY, ids=lambda x: x if isinstance(x, str) else x.__name__ +) +def test_builder_get_allowed_service_parameters_pay_returns_dict( + method_name, builder_class, client +): + builder = builder_class(client) + allowed = builder.get_allowed_service_parameters("Pay") + assert isinstance(allowed, dict) + + +def _capability_matrix_params(): + """Yield (capability, method_name, builder_name, builder_class) for every + registry entry that mixes in each capability.""" + rows = [] + for capability, methods in CAPABILITY_METHODS.items(): + for method_name, builder_class in REGISTRY: + if not issubclass(builder_class, capability): + continue + for meth in methods: + rows.append( + pytest.param( + capability, + meth, + method_name, + builder_class, + id=f"{capability.__name__}.{meth}-{method_name}", + ) + ) + return rows + + +@pytest.mark.parametrize("capability,method,method_name,builder_class", _capability_matrix_params()) +def test_capability_method_present_and_callable( + capability, method, method_name, builder_class, client +): + builder = builder_class(client) + assert hasattr(builder, method), ( + f"{builder_class.__name__} mixes in {capability.__name__} but is missing method {method!r}" + ) + assert callable(getattr(builder, method)) + + +# --------------------------------------------------------------------------- +# Capability-declaration matrix +# +# Pins the expected capability mixin set per registry entry. Historically the +# same assertion was duplicated in every per-builder file that shipped with +# no capabilities; the matrix below replaces those copies. Add the mixin to +# the builder's set here when a new capability lands. + +KNOWN_MIXINS: List[Type] = list(CAPABILITY_METHODS.keys()) + +# BankTransferCapabilities extends both InstantRefundCapable and +# FastCheckoutCapable, so declaring BankTransferCapabilities implies the other +# two. The expected sets below include every mixin the builder is a subclass +# of — direct mixin plus transitive bases. +EXPECTED_CAPABILITIES: Dict[str, set] = { + "creditcard": {EncryptedPayCapable, AuthorizeCaptureCapable}, + "ideal": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, + "paybybank": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, + "payconiq": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, + "sofort": {BankTransferCapabilities, InstantRefundCapable, FastCheckoutCapable}, +} + + +@pytest.mark.parametrize( + "method_name,builder_class", REGISTRY, ids=lambda x: x if isinstance(x, str) else x.__name__ +) +@pytest.mark.parametrize("mixin", KNOWN_MIXINS, ids=lambda c: c.__name__) +def test_builder_declares_only_expected_capabilities(method_name, builder_class, mixin, client): + expected = EXPECTED_CAPABILITIES.get(method_name, set()) + actual = issubclass(builder_class, mixin) + assert actual is (mixin in expected), ( + f"{builder_class.__name__} capability mismatch for {mixin.__name__}: " + f"issubclass={actual}, expected={mixin in expected}" + ) + + +# --------------------------------------------------------------------------- +# Inherited-base-method matrix +# +# Every concrete builder inherits these public action methods from +# :class:`BaseBuilder`. The per-builder copies of this assertion used to live +# inline; consolidating here keeps the contract in one place. + +INHERITED_BASE_METHODS: List[str] = [ + "pay", + "refund", + "capture", + "cancel", + "partial_refund", + "build", + "execute_action", +] + + +@pytest.mark.parametrize( + "method_name,builder_class", REGISTRY, ids=lambda x: x if isinstance(x, str) else x.__name__ +) +@pytest.mark.parametrize("base_method", INHERITED_BASE_METHODS) +def test_inherited_base_builder_methods_callable(method_name, builder_class, base_method, client): + builder = builder_class(client) + assert hasattr(builder, base_method), ( + f"{builder_class.__name__} is missing inherited BaseBuilder method {base_method!r}" + ) + assert callable(getattr(builder, base_method)) diff --git a/tests/unit/builders/payments/test_credit_card_builder.py b/tests/unit/builders/payments/test_credit_card_builder.py new file mode 100644 index 0000000..48fa668 --- /dev/null +++ b/tests/unit/builders/payments/test_credit_card_builder.py @@ -0,0 +1,264 @@ +"""Tests for :class:`buckaroo.builders.payments.credit_card_builder.CreditcardBuilder`. + +Covers the three quirks specific to this builder: + +- Dynamic :meth:`get_service_name` — reads ``brand`` off ``_payload`` and + defaults to ``"CreditCard"`` when absent. +- The per-action allowed-parameters matrix for ``Pay``, ``PayEncrypted``, + ``PayWithSecurityCode``, ``PayWithToken``, ``PayRecurrent``, ``Authorize``, + ``AuthorizeWithToken``, ``Capture`` and ``Refund``. +- The four builder-specific actions: ``payWithSecurityCode``, ``payWithToken``, + ``authorizeWithToken`` and ``payRecurrent``. Each is exercised end-to-end + through a :class:`BuckarooClient` wired to :class:`MockBuckaroo`. +""" + +from __future__ import annotations + +from buckaroo.builders.payments.credit_card_builder import CreditcardBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, wire_recording_http + + +# --------------------------------------------------------------------------- +# Fixtures + + +# --------------------------------------------------------------------------- +# Construction + source constants + + +class TestConstruction: + def test_instantiates_with_buckaroo_client(self, client): + builder = CreditcardBuilder(client) + assert isinstance(builder, CreditcardBuilder) + assert isinstance(builder, PaymentBuilder) + + def test_service_name_class_constant_is_creditcard(self): + assert CreditcardBuilder._serviceName == "creditcard" + + +# --------------------------------------------------------------------------- +# Dynamic get_service_name() + + +class TestGetServiceName: + def test_returns_CreditCard_when_payload_has_no_brand(self, client): + builder = CreditcardBuilder(client) + assert builder.get_service_name() == "CreditCard" + + def test_returns_brand_from_payload_when_present(self, client): + builder = CreditcardBuilder(client) + builder.from_dict({"brand": "Visa"}) + assert builder.get_service_name() == "Visa" + + def test_brand_from_dict_propagates_to_built_request(self, client): + builder = CreditcardBuilder(client) + populate_required_fields(builder, amount=10.00) + builder.from_dict({"brand": "Visa"}) + request = builder.build("Pay", validate=False) + service = request.services.services[0] + assert service.name == "Visa" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — full action matrix + + +class TestGetAllowedServiceParameters: + def test_pay_returns_empty_dict(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("Pay") == {} + + def test_pay_encrypted_returns_encryptedcarddata_spec(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("PayEncrypted") == { + "encryptedcarddata": { + "type": str, + "required": True, + "description": "Encrypted card data", + }, + } + + def test_pay_with_security_code_returns_encryptedsecuritycode_spec(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("PayWithSecurityCode") == { + "encryptedsecuritycode": { + "type": str, + "required": True, + "description": "Encrypted security code", + }, + } + + def test_pay_with_token_returns_sessionid_spec(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("PayWithToken") == { + "sessionid": { + "type": str, + "required": True, + "description": "Session ID token from Hosted Fields submitSession()", + }, + } + + def test_authorize_with_token_returns_sessionid_spec(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("AuthorizeWithToken") == { + "sessionid": { + "type": str, + "required": True, + "description": "Session ID token from Hosted Fields submitSession()", + }, + } + + def test_pay_recurrent_returns_empty_dict(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("PayRecurrent") == {} + + def test_authorize_returns_empty_dict(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("Authorize") == {} + + def test_capture_returns_empty_dict(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("Capture") == {} + + def test_refund_returns_empty_dict(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("Refund") == {} + + def test_defaults_to_pay_when_action_omitted(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters() == {} + + def test_unknown_action_returns_empty_dict(self, client): + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("Completely-Unknown") == {} + + def test_action_matching_is_case_insensitive(self, client): + """The source lowercases ``action`` before each branch comparison.""" + builder = CreditcardBuilder(client) + assert builder.get_allowed_service_parameters("payencrypted") == { + "encryptedcarddata": { + "type": str, + "required": True, + "description": "Encrypted card data", + }, + } + + +# --------------------------------------------------------------------------- +# Capability-mixin sanity — methods from mixins are present and callable + + +class TestCapabilityMixinMethods: + def test_pay_encrypted_present_and_callable(self, client): + builder = CreditcardBuilder(client) + assert hasattr(builder, "payEncrypted") + assert callable(builder.payEncrypted) + + def test_authorize_present_and_callable(self, client): + builder = CreditcardBuilder(client) + assert hasattr(builder, "authorize") + assert callable(builder.authorize) + + def test_authorize_encrypted_present_and_callable(self, client): + builder = CreditcardBuilder(client) + assert hasattr(builder, "authorizeEncrypted") + assert callable(builder.authorizeEncrypted) + + def test_capture_present_and_callable(self, client): + builder = CreditcardBuilder(client) + assert hasattr(builder, "capture") + assert callable(builder.capture) + + def test_cancel_authorize_present_and_callable(self, client): + builder = CreditcardBuilder(client) + assert hasattr(builder, "cancelAuthorize") + assert callable(builder.cancelAuthorize) + + +# --------------------------------------------------------------------------- +# Builder-specific action methods — end-to-end through MockBuckaroo + + +def _ready_builder(client): + builder = CreditcardBuilder(client) + populate_required_fields(builder, amount=10.50) + return builder + + +class TestPayWithSecurityCode: + def test_posts_action_PayWithSecurityCode_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "PSC-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.payWithSecurityCode(validate=False) + + assert recorded_action(mock) == "PayWithSecurityCode" + assert response.key == "PSC-1" + mock.assert_all_consumed() + + +class TestPayWithToken: + def test_posts_action_PayWithToken_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "PWT-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.payWithToken(validate=False) + + assert recorded_action(mock) == "PayWithToken" + assert response.key == "PWT-1" + mock.assert_all_consumed() + + +class TestAuthorizeWithToken: + def test_posts_action_AuthorizeWithToken_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "AWT-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.authorizeWithToken(validate=False) + + assert recorded_action(mock) == "AuthorizeWithToken" + assert response.key == "AWT-1" + mock.assert_all_consumed() + + +class TestPayRecurrent: + def test_posts_action_PayRecurrent_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "PR-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = _ready_builder(client) + + response = builder.payRecurrent(validate=False) + + assert recorded_action(mock) == "PayRecurrent" + assert response.key == "PR-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_default_builder.py b/tests/unit/builders/payments/test_default_builder.py new file mode 100644 index 0000000..4e81e76 --- /dev/null +++ b/tests/unit/builders/payments/test_default_builder.py @@ -0,0 +1,116 @@ +"""Unit tests for :class:`DefaultBuilder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/default_builder.py``. The builder is a catch-all +that unknown-method lookups fall back to. It declares no capability mixins, +no ``_serviceName`` class attribute, and no action methods of its own. The +surface under test is: + +- construction via ``BuckarooClient`` wired to :class:`MockBuckaroo` +- ``get_service_name()`` reading ``method`` from the payload (and falling + back to ``"Unknown"`` when absent) +- ``get_allowed_service_parameters(action)`` returning ``{}`` for every + action — the catch-all has no required params +- ``pay()`` end-to-end through the mock strategy +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.default_builder import DefaultBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +def test_construction_with_client_succeeds(client): + builder = DefaultBuilder(client) + assert isinstance(builder, DefaultBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_defaults_to_unknown_when_payload_has_no_method(client): + assert DefaultBuilder(client).get_service_name() == "Unknown" + + +def test_get_service_name_reads_method_from_payload(client): + builder = DefaultBuilder(client) + builder.from_dict({"method": "someobscuremethod"}) + assert builder.get_service_name() == "someobscuremethod" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + """Catch-all: no required (or optional) params for any action.""" + assert DefaultBuilder(client).get_allowed_service_parameters("Pay") == {} + + +@pytest.mark.parametrize( + "action", + ["Pay", "pay", "Refund", "Capture", "Authorize", "UnknownAction", ""], +) +def test_get_allowed_service_parameters_any_action_returns_empty(client, action): + assert DefaultBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_default_action_returns_empty(client): + """Exercises the ``action="Pay"`` default argument.""" + assert DefaultBuilder(client).get_allowed_service_parameters() == {} + + +@pytest.mark.parametrize( + "mixin_method", + [ + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "payEncrypted", + "instantRefund", + "payFastCheckout", + ], +) +def test_has_no_capability_mixin_methods(client, mixin_method): + """DefaultBuilder mixes in no capabilities — the mixin methods are absent.""" + assert not hasattr(DefaultBuilder(client), mixin_method), ( + f"DefaultBuilder unexpectedly exposes capability method {mixin_method!r}" + ) + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "default-key-42", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields(DefaultBuilder(client)).pay() + + assert response.key == "default-key-42" + + +def test_pay_uses_method_from_payload_as_service_name(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "default-key-99"}, + ) + ) + + response = ( + DefaultBuilder(client) + .from_dict( + Helpers.standard_payload( + invoice="INV-DEF-2", + amount=5.55, + description="via from_dict", + method="obscuremethod", + ) + ) + .pay() + ) + + assert response.key == "default-key-99" diff --git a/tests/unit/builders/payments/test_eps_builder.py b/tests/unit/builders/payments/test_eps_builder.py new file mode 100644 index 0000000..4246bdb --- /dev/null +++ b/tests/unit/builders/payments/test_eps_builder.py @@ -0,0 +1,65 @@ +"""Per-builder unit tests for :class:`EpsBuilder`. + +EPS is a trivial builder: no capability mixins, no builder-specific action +methods, and ``get_allowed_service_parameters`` returns an empty dict for +every action. Tests pin those contracts and exercise ``pay()`` end-to-end +through :class:`tests.support.mock_buckaroo.MockBuckaroo`. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.eps_builder import EpsBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client: BuckarooClient) -> EpsBuilder: + return EpsBuilder(client) + + +def test_construction_yields_payment_builder_subclass(builder: EpsBuilder) -> None: + assert isinstance(builder, EpsBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_eps(builder: EpsBuilder) -> None: + assert builder.get_service_name() == "EPS" + + +@pytest.mark.parametrize("action", ["Pay", "Refund", "Authorize", "Capture"]) +def test_get_allowed_service_parameters_is_empty_for_every_action( + builder: EpsBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay(builder: EpsBuilder) -> None: + assert builder.get_allowed_service_parameters() == {} + + +def test_pay_posts_eps_action_and_parses_response( + builder: EpsBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + { + "Key": "eps-txn-key", + "Status": {"Code": {"Code": 190}}, + "Services": [{"Name": "EPS", "Action": "Pay"}], + }, + ) + ) + + response = populate_required_fields(builder, amount=12.34).pay() + + assert response.key == "eps-txn-key" + assert response.services[0].name == "EPS" + assert response.services[0].action == "Pay" diff --git a/tests/unit/builders/payments/test_external_payment_builder.py b/tests/unit/builders/payments/test_external_payment_builder.py new file mode 100644 index 0000000..3b22906 --- /dev/null +++ b/tests/unit/builders/payments/test_external_payment_builder.py @@ -0,0 +1,133 @@ +"""Unit coverage for :class:`ExternalPaymentBuilder`. + +The External payment method is a pass-through: it declares no service-level +parameters and inherits every action method from :class:`PaymentBuilder` / +:class:`BaseBuilder`. These tests pin that shape and round-trip a ``pay()`` +and ``refund()`` through :class:`MockBuckaroo` so regressions in the inherited +plumbing surface here too. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.external_payment_builder import ExternalPaymentBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.models.payment_response import PaymentResponse +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +@pytest.fixture +def builder(client: BuckarooClient) -> ExternalPaymentBuilder: + return ExternalPaymentBuilder(client) + + +def test_instantiates_as_payment_builder(builder: ExternalPaymentBuilder) -> None: + assert isinstance(builder, PaymentBuilder) + + +def test_does_not_declare_class_level_service_name() -> None: + # ExternalPayment resolves the service name at call time via + # get_service_name(); no class-level _serviceName attribute is declared. + assert "_serviceName" not in vars(ExternalPaymentBuilder) + + +def test_get_service_name_returns_external_payment( + builder: ExternalPaymentBuilder, +) -> None: + assert builder.get_service_name() == "ExternalPayment" + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "Capture", "PayRemainder", "Authorize", ""], +) +def test_get_allowed_service_parameters_is_empty_for_every_action( + builder: ExternalPaymentBuilder, action: str +) -> None: + # Pass-through builder: no service-level parameters declared, regardless + # of the action supplied. Snapshot the declared spec inline as {}. + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay( + builder: ExternalPaymentBuilder, +) -> None: + assert builder.get_allowed_service_parameters() == {} + + +def test_get_allowed_service_parameters_overrides_base_stub( + builder: ExternalPaymentBuilder, +) -> None: + """Every concrete builder overrides :meth:`BaseBuilder.get_allowed_service_parameters`. + ExternalPayment's override returns an empty dict unconditionally; pin that + for both the no-arg call and an explicit ``Refund`` action so the abstract + stub is never what callers see.""" + assert builder.get_allowed_service_parameters() == {} + assert builder.get_allowed_service_parameters("Refund") == {} + + +def test_pay_round_trips_through_mock_buckaroo( + builder: ExternalPaymentBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + { + "Key": "EXT-TXN-1", + "Status": {"Code": {"Code": 190, "Description": "Success"}}, + "Services": [{"Name": "ExternalPayment", "Action": "Pay", "Parameters": []}], + "Invoice": "INV-EXT-001", + "Currency": "EUR", + "AmountDebit": 25.0, + }, + ) + ) + + response = builder.from_dict( + Helpers.standard_payload( + invoice="INV-EXT-001", + amount=25.0, + description="External pay", + ) + ).pay() + + assert isinstance(response, PaymentResponse) + assert response.key == "EXT-TXN-1" + assert response.status.code.code == 190 + + +def test_refund_round_trips_through_mock_buckaroo( + builder: ExternalPaymentBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + { + "Key": "EXT-REFUND-1", + "Status": {"Code": {"Code": 190, "Description": "Success"}}, + "Services": [{"Name": "ExternalPayment", "Action": "Refund", "Parameters": []}], + "Invoice": "INV-EXT-REFUND", + "Currency": "EUR", + "AmountCredit": 10.0, + }, + ) + ) + + response = builder.from_dict( + Helpers.standard_payload( + invoice="INV-EXT-REFUND", + amount=10.0, + description="External refund", + original_transaction_key="ORIG-EXT-KEY", + ) + ).refund() + + assert isinstance(response, PaymentResponse) + assert response.key == "EXT-REFUND-1" + assert response.status.code.code == 190 diff --git a/tests/unit/builders/payments/test_giftcards_builder.py b/tests/unit/builders/payments/test_giftcards_builder.py new file mode 100644 index 0000000..5ebcece --- /dev/null +++ b/tests/unit/builders/payments/test_giftcards_builder.py @@ -0,0 +1,122 @@ +"""Per-builder unit tests for :class:`GiftcardsBuilder`. + +Covers construction, service-name shape, allowed-parameter snapshots for every +``giftcard_name`` branch, empty-payload default fallback, and a ``pay()`` +dispatch through ``MockBuckaroo``. +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.giftcards_builder import GiftcardsBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +def test_construct_with_buckaroo_client_returns_payment_builder(client): + builder = GiftcardsBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_class_does_not_declare_service_name_attribute(): + """Unlike ``CreditcardBuilder``, ``GiftcardsBuilder`` does not set + ``_serviceName`` on the class — service name is derived dynamically from + ``_payload['giftcard_name']`` with a ``'Giftcards'`` default. + """ + assert not hasattr(GiftcardsBuilder, "_serviceName") + + +def test_get_service_name_defaults_to_giftcards_when_payload_empty(client): + assert GiftcardsBuilder(client).get_service_name() == "Giftcards" + + +def test_get_service_name_reads_giftcard_name_from_payload(client): + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "fashioncheque"}) + assert builder.get_service_name() == "fashioncheque" + + +def test_get_allowed_service_parameters_empty_payload_returns_default(client): + builder = GiftcardsBuilder(client) + spec = builder.get_allowed_service_parameters("Pay") + assert "Cardnumber" in spec + assert "PIN" in spec + + +def test_get_allowed_service_parameters_pay_fashioncheque_snapshot(client): + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "fashioncheque"}) + assert builder.get_allowed_service_parameters("Pay") == { + "FashionChequeCardNumber": { + "type": str, + "required": True, + "description": "Save payment token for future use", + }, + "FashionChequePIN": { + "type": str, + "required": True, + "description": "Save payment token for future use", + }, + } + + +def test_get_allowed_service_parameters_pay_intersolve_snapshot(client): + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "intersolve"}) + assert builder.get_allowed_service_parameters("Pay") == { + "IntersolveCardnumber": {"type": str, "required": True, "description": ""}, + "IntersolvePIN": {"type": str, "required": True, "description": ""}, + } + + +def test_get_allowed_service_parameters_pay_tcs_snapshot(client): + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "tcs"}) + assert builder.get_allowed_service_parameters("Pay") == { + "TCSCardnumber": {"type": str, "required": True, "description": ""}, + "TCSValidationCode": {"type": str, "required": True, "description": ""}, + } + + +def test_get_allowed_service_parameters_pay_default_branch_snapshot(client): + """Unknown ``giftcard_name`` values fall through to the generic spec.""" + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "other"}) + assert builder.get_allowed_service_parameters("Pay") == { + "Cardnumber": {"type": str, "required": True, "description": ""}, + "PIN": {"type": str, "required": True, "description": ""}, + "LastName": {"type": str, "required": False, "description": ""}, + "Email": {"type": str, "required": False, "description": ""}, + } + + +def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): + """Source lower-cases the action; ``'pay'`` and ``'Pay'`` must match.""" + builder = GiftcardsBuilder(client).from_dict({"giftcard_name": "other"}) + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + """Unsupported actions short-circuit to ``{}`` before the ``giftcard_name`` + branch, so the empty-payload bug does not fire here.""" + assert GiftcardsBuilder(client).get_allowed_service_parameters("Refund") == {} + + +def test_pay_dispatches_giftcards_service_through_mock_buckaroo(): + client = BuckarooClient("store_key", "secret_key", mode="test") + mock = MockBuckaroo() + client.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "GC-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields(GiftcardsBuilder(client), amount=10.50) + builder.from_dict({"giftcard_name": "fashioncheque"}) + + response = builder.pay(validate=False) + + assert response.key == "GC-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_google_pay_builder.py b/tests/unit/builders/payments/test_google_pay_builder.py new file mode 100644 index 0000000..3d455b0 --- /dev/null +++ b/tests/unit/builders/payments/test_google_pay_builder.py @@ -0,0 +1,73 @@ +"""Per-builder unit tests for :class:`GooglePayBuilder`. + +Covers construction, service-name shape, allowed-parameter snapshots for every +supported action, mixin presence, and an end-to-end ``pay()`` dispatch through +``MockBuckaroo``. Phase 7.15. +""" + +from __future__ import annotations + + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.google_pay_builder import GooglePayBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +def test_construct_with_buckaroo_client_returns_payment_builder(client): + builder = GooglePayBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_google_pay(client): + assert GooglePayBuilder(client).get_service_name() == "GooglePay" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + assert GooglePayBuilder(client).get_allowed_service_parameters("Pay") == { + "PaymentData": {"type": str, "required": True, "description": ""}, + "CustomerCardName": {"type": str, "required": False, "description": ""}, + } + + +def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): + """Source lower-cases the action before matching, so "pay" equals "Pay".""" + builder = GooglePayBuilder(client) + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + assert GooglePayBuilder(client).get_allowed_service_parameters("Refund") == {} + + +def test_google_pay_declares_from_dict_method(client): + """``from_dict`` is inherited from PaymentBuilder and used by GooglePay + callers to bulk-populate service parameters. Pin the shape so a base-class + refactor that renames it surfaces here.""" + builder = GooglePayBuilder(client) + assert hasattr(builder, "from_dict") + assert callable(builder.from_dict) + + +def test_pay_dispatches_googlepay_service_through_mock_buckaroo(): + client = BuckarooClient("store_key", "secret_key", mode="test") + mock = MockBuckaroo() + client.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "GP-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields(GooglePayBuilder(client), amount=10.50) + + response = builder.pay(validate=False) + + assert response.key == "GP-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_ideal_builder.py b/tests/unit/builders/payments/test_ideal_builder.py new file mode 100644 index 0000000..e314b6e --- /dev/null +++ b/tests/unit/builders/payments/test_ideal_builder.py @@ -0,0 +1,113 @@ +"""Unit coverage for :class:`IdealBuilder`. + +Phase 7.16 — per-builder coverage. IdealBuilder subclasses +:class:`PaymentBuilder` and mixes in :class:`BankTransferCapabilities` (which +itself composes :class:`InstantRefundCapable` + :class:`FastCheckoutCapable`). +Tests pin: + + * construction + ``PaymentBuilder`` lineage; + * ``get_service_name()``; + * the full allowed-parameter spec per action, including ``issuer``'s + required flag on ``Pay`` / ``PayFastCheckout``; + * mixin presence (``instantRefund`` + ``payFastCheckout`` bound and callable); + * end-to-end ``pay()``, ``instantRefund()``, ``payFastCheckout()`` dispatch + through :class:`MockBuckaroo`. +""" + +from __future__ import annotations + +from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( + BankTransferCapabilities, +) +from buckaroo.builders.payments.capabilities.fast_checkout_capable import ( + FastCheckoutCapable, +) +from buckaroo.builders.payments.capabilities.instant_refund_capable import ( + InstantRefundCapable, +) +from buckaroo.builders.payments.ideal_builder import IdealBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wired_to_mock_buckaroo_succeeds(client): + builder = IdealBuilder(client) + + assert isinstance(builder, IdealBuilder) + assert isinstance(builder, PaymentBuilder) + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_ideal(client): + assert IdealBuilder(client).get_service_name() == "ideal" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot per supported action + + +def test_get_allowed_service_parameters_pay_snapshot(client): + assert IdealBuilder(client).get_allowed_service_parameters("Pay") == { + "issuer": { + "type": str, + "required": False, + "description": "iDEAL bank issuer code", + }, + } + + +def test_pay_spec_declares_issuer_required_flag_explicitly(client): + """Pin ``issuer``'s required-ness flag on the ``Pay`` spec. + + The Buckaroo v2 iDEAL flow no longer requires callers to supply a bank + issuer up-front (the hosted page handles selection), so the SDK spec marks + it optional. This test surfaces that decision explicitly so a future swap + to ``required: True`` breaks here and gets scrutinised. + """ + spec = IdealBuilder(client).get_allowed_service_parameters("Pay") + assert "issuer" in spec + assert spec["issuer"]["required"] is False + + +def test_get_allowed_service_parameters_payfastcheckout_matches_pay(client): + """``PayFastCheckout`` shares the same issuer spec as ``Pay``.""" + builder = IdealBuilder(client) + + assert builder.get_allowed_service_parameters( + "PayFastCheckout" + ) == builder.get_allowed_service_parameters("Pay") + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + assert IdealBuilder(client).get_allowed_service_parameters("Refund") == {} + + +def test_mixes_in_bank_transfer_capabilities(client): + builder = IdealBuilder(client) + assert isinstance(builder, BankTransferCapabilities) + assert isinstance(builder, InstantRefundCapable) + assert isinstance(builder, FastCheckoutCapable) + + +def test_pay_dispatches_ideal_service_through_mock_buckaroo(client): + mock = client.http_client.http_strategy + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "ideal-key-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields(IdealBuilder(client)).pay() + + assert response.key == "ideal-key-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_ideal_qr_builder.py b/tests/unit/builders/payments/test_ideal_qr_builder.py new file mode 100644 index 0000000..1cfa4ed --- /dev/null +++ b/tests/unit/builders/payments/test_ideal_qr_builder.py @@ -0,0 +1,173 @@ +"""Per-builder unit tests for :class:`IdealQrBuilder`. + +Covers construction, service-name shape, allowed-parameter snapshots for the +``Pay`` and ``Generate`` actions, required-fields override, and an end-to-end +``generate()`` dispatch through ``MockBuckaroo``. Phase 7.17. + +The iDEAL QR builder only exposes parameters for the ``Generate`` action; the +default ``Pay`` path returns an empty dict from the override. ``generate()`` +posts to ``/json/DataRequest`` rather than ``/json/transaction``. +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.ideal_qr_builder import IdealQrBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +def test_construct_with_buckaroo_client_returns_payment_builder(client): + builder = IdealQrBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_idealqr(client): + assert IdealQrBuilder(client).get_service_name() == "IdealQr" + + +def test_get_allowed_service_parameters_pay_is_empty(client): + """``Pay`` is not in the builder's action whitelist; the override returns {}.""" + assert IdealQrBuilder(client).get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_generate_snapshot(client): + assert IdealQrBuilder(client).get_allowed_service_parameters("Generate") == { + "amount": { + "type": str, + "required": True, + "description": "iDEAL QR payment amount", + }, + "amountIsChangeable": { + "type": bool, + "required": True, + "description": "Indicates if the amount can be changed", + }, + "purchaseId": { + "type": str, + "required": True, + "description": "Unique purchase identifier", + }, + "description": { + "type": str, + "required": True, + "description": "Description of the payment", + }, + "isOneOff": { + "type": bool, + "required": True, + "description": "Indicates if the payment is a one-off", + }, + "expiration": { + "type": str, + "required": True, + "description": "Expiration time for the QR code", + }, + "imageSize": { + "type": str, + "required": True, + "description": "Size of the QR code image", + }, + "isProcessing": { + "type": bool, + "required": False, + "description": "Indicates if the payment is processing", + }, + "minAmount": { + "type": str, + "required": False, + "description": "Minimum amount allowed for the payment", + }, + "maxAmount": { + "type": str, + "required": False, + "description": "Maximum amount allowed for the payment", + }, + } + + +def test_pay_and_generate_snapshots_are_distinct(client): + builder = IdealQrBuilder(client) + assert builder.get_allowed_service_parameters("Pay") != builder.get_allowed_service_parameters( + "Generate" + ) + + +def test_get_allowed_service_parameters_is_case_insensitive_for_generate(client): + """Source lower-cases the action before matching, so "generate" equals "Generate".""" + builder = IdealQrBuilder(client) + assert builder.get_allowed_service_parameters( + "generate" + ) == builder.get_allowed_service_parameters("Generate") + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + assert IdealQrBuilder(client).get_allowed_service_parameters("Refund") == {} + + +def test_required_fields_is_callable_not_property(client): + """IdealQr overrides ``BaseBuilder.required_fields`` as a method; passing an + explicit ``"Pay"`` action must return a dict that includes ``currency``. + Pins the regression from phase-4 where a property shadow swallowed the arg.""" + fields = IdealQrBuilder(client).required_fields("Pay") + assert isinstance(fields, dict) + assert "currency" in fields + + +def test_required_fields_omits_amount_debit(client): + """IdealQr overrides ``required_fields`` to drop ``amount_debit`` since + QR flows carry the amount in service parameters instead.""" + fields = IdealQrBuilder(client).required_fields("Pay") + + assert set(fields.keys()) == { + "currency", + "description", + "invoice", + "return_url", + "return_url_cancel", + "return_url_error", + "return_url_reject", + } + + +def test_required_fields_reflects_current_state(client): + builder = IdealQrBuilder(client) + builder.currency("EUR").description("desc").invoice("INV-1") + builder.return_url("https://ret/ok") + builder.return_url_cancel("https://ret/cancel") + builder.return_url_error("https://ret/error") + builder.return_url_reject("https://ret/reject") + + fields = builder.required_fields() + assert fields["currency"] == "EUR" + assert fields["description"] == "desc" + assert fields["invoice"] == "INV-1" + assert fields["return_url"] == "https://ret/ok" + assert fields["return_url_cancel"] == "https://ret/cancel" + assert fields["return_url_error"] == "https://ret/error" + assert fields["return_url_reject"] == "https://ret/reject" + + +def test_generate_dispatches_through_mock_buckaroo(): + mock = MockBuckaroo() + c = BuckarooClient("store_key", "secret_key", mode="test") + c.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "qr-key-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = IdealQrBuilder(c) + builder.currency("EUR").description("QR").invoice("INV-QR-1") + builder.return_url("https://ret/ok") + builder.return_url_cancel("https://ret/cancel") + builder.return_url_error("https://ret/error") + builder.return_url_reject("https://ret/reject") + + response = builder.generate(validate=False) + + assert response.key == "qr-key-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_in3_builder.py b/tests/unit/builders/payments/test_in3_builder.py new file mode 100644 index 0000000..fef7206 --- /dev/null +++ b/tests/unit/builders/payments/test_in3_builder.py @@ -0,0 +1,79 @@ +"""Unit tests for :class:`In3Builder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/in3_builder.py``. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.in3_builder import In3Builder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = In3Builder(client) + assert isinstance(builder, In3Builder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_in3(client): + assert In3Builder(client).get_service_name() == "in3" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = In3Builder(client) + + assert builder.get_allowed_service_parameters("Pay") == { + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, + "article": { + "type": list, + "required": True, + "description": "IN3 articles", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = In3Builder(client) + + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert In3Builder(client).get_allowed_service_parameters(action) == {} + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "in3-key-456", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(In3Builder(client), amount=99.95) + .add_parameter("billingCustomer", [{"Name": "John"}]) + .add_parameter("shippingCustomer", [{"Name": "John"}]) + .add_parameter("article", [{"Description": "Widget", "Quantity": 1}]) + .pay() + ) + + assert response.key == "in3-key-456" diff --git a/tests/unit/builders/payments/test_kbc_builder.py b/tests/unit/builders/payments/test_kbc_builder.py new file mode 100644 index 0000000..940ed49 --- /dev/null +++ b/tests/unit/builders/payments/test_kbc_builder.py @@ -0,0 +1,86 @@ +"""Unit coverage for :class:`KBCBuilder`. + +Phase 7.19 — per-builder coverage. KBCBuilder is a minimal subclass of +:class:`PaymentBuilder` that only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes and +does not declare a ``_serviceName`` class attribute. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.kbc_builder import KBCBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wired_to_mock_buckaroo_succeeds(client): + builder = KBCBuilder(client) + + assert isinstance(builder, KBCBuilder) + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_kbc_payment_button(client): + builder = KBCBuilder(client) + + assert builder.get_service_name() == "KBCPaymentButton" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every action + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "PayRemainder", "ExtraInfo", "UnknownAction"], +) +def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(client, action): + builder = KBCBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = KBCBuilder(client) + + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_kbc_payment_button_service_to_transaction_endpoint(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "KBC-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(KBCBuilder(client), amount=42.00) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "KBCPaymentButton" + assert service["Action"] == "Pay" + assert response.key == "KBC-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_klarna_builder.py b/tests/unit/builders/payments/test_klarna_builder.py new file mode 100644 index 0000000..5f636ac --- /dev/null +++ b/tests/unit/builders/payments/test_klarna_builder.py @@ -0,0 +1,83 @@ +"""Per-builder unit tests for :class:`KlarnaBuilder`. + +Covers construction, service-name shape, allowed-parameter snapshots for every +supported action including the grouped article / line-item structure, the +mixin-free baseline (KlarnaBuilder composes no capability mixins despite the +docstring mention of "bank transfer capabilities"), and an end-to-end +``pay()`` dispatch through ``MockBuckaroo``. Phase 7.20. +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.klarna_builder import KlarnaBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +def test_construct_with_buckaroo_client_returns_payment_builder(client): + builder = KlarnaBuilder(client) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_klarna(client): + assert KlarnaBuilder(client).get_service_name() == "klarna" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + """Cart / grouped-article parameter spec. ``billingCustomer`` and + ``shippingCustomer`` declare the customer groups; ``article`` declares the + line-item group. All three are marked required lists so the + ``add_parameter`` list-of-dicts path groups them into Buckaroo's + ``GroupType`` / ``GroupId`` convention at serialise time.""" + assert KlarnaBuilder(client).get_allowed_service_parameters("Pay") == { + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, + "article": { + "type": list, + "required": True, + "description": "Klarna articles", + }, + } + + +def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): + """Source lower-cases the action before matching, so "pay" equals "Pay".""" + builder = KlarnaBuilder(client) + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client): + assert KlarnaBuilder(client).get_allowed_service_parameters("Refund") == {} + + +def test_pay_dispatches_klarna_service_through_mock_buckaroo(): + client = BuckarooClient("store_key", "secret_key", mode="test") + mock = MockBuckaroo() + client.http_client.http_strategy = mock + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "KL-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields(KlarnaBuilder(client), amount=49.95) + + response = builder.pay(validate=False) + + assert response.key == "KL-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_klarnakp_builder.py b/tests/unit/builders/payments/test_klarnakp_builder.py new file mode 100644 index 0000000..cd46c2a --- /dev/null +++ b/tests/unit/builders/payments/test_klarnakp_builder.py @@ -0,0 +1,322 @@ +"""Per-builder unit tests for :class:`KlarnaKPBuilder`. + +Phase 7.21 — KlarnaKP has reservation-based actions on top of ``Pay``: +``Reserve``, ``CancelReservation``, ``UpdateReservation``, +``ExtendReservation``, and ``AddShippingInfo``. Each has a builder-specific +method (``reserve``, ``cancelReservation``, ``updateReservation``, +``extendReservation``, ``addShippingInfo``) that posts to ``/json/DataRequest`` +rather than ``/json/transaction``. + +The source also narrows :meth:`required_fields` so only ``Reserve`` has any +required fields (currency + invoice); every other action returns ``{}``, +including ``Pay``. That is load-bearing behaviour — several of the builder- +specific action methods would otherwise fail ``_validate_required_fields``. + +``KlarnaKPBuilder`` mixes in no capability classes; the docstring mentions +"bank transfer capabilities" but that is documentation drift, not inheritance. +""" + +from __future__ import annotations + + +from buckaroo.builders.payments.klarnakp_builder import KlarnaKPBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_action, recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Fixtures + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construct_with_client_returns_payment_builder(client): + builder = KlarnaKPBuilder(client) + assert isinstance(builder, KlarnaKPBuilder) + assert isinstance(builder, PaymentBuilder) + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_klarnakp(client): + assert KlarnaKPBuilder(client).get_service_name() == "klarnakp" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every KlarnaKP action + + +class TestGetAllowedServiceParameters: + def test_pay_returns_reservation_number_spec(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("Pay") == { + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, + } + + def test_cancel_reservation_returns_reservation_number_spec(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("CancelReservation") == { + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, + } + + def test_reserve_returns_operating_country_and_article_spec(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("Reserve") == { + "operatingCountry": { + "type": str, + "required": True, + "description": "Operating country code", + }, + "article": { + "type": list, + "required": True, + "description": "Klarna KP articles", + }, + } + + def test_extend_reservation_returns_reservation_number_spec(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("ExtendReservation") == { + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, + } + + def test_extend_reservation_matches_pay_result(self, client): + builder = KlarnaKPBuilder(client) + assert builder.get_allowed_service_parameters( + "ExtendReservation" + ) == builder.get_allowed_service_parameters("Pay") + + def test_update_reservation_returns_reservation_number_and_article_spec(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("UpdateReservation") == { + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, + "article": { + "type": list, + "required": True, + "description": "Klarna KP articles", + }, + } + + def test_add_shipping_info_returns_shipping_spec(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("AddShippingInfo") == { + "originalTransactionKey": { + "type": str, + "required": True, + "description": "Original transaction key", + }, + "shippingMethod": { + "type": str, + "required": False, + "description": "Shipping method", + }, + "company": { + "type": str, + "required": False, + "description": "Shipping company name", + }, + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, + } + + def test_defaults_to_pay_when_action_omitted(self, client): + builder = KlarnaKPBuilder(client) + assert builder.get_allowed_service_parameters() == builder.get_allowed_service_parameters( + "Pay" + ) + + def test_unknown_action_returns_empty_dict(self, client): + assert KlarnaKPBuilder(client).get_allowed_service_parameters("Refund") == {} + + def test_action_matching_is_case_insensitive(self, client): + """Source lowercases ``action`` before every branch comparison.""" + builder = KlarnaKPBuilder(client) + assert builder.get_allowed_service_parameters( + "reserve" + ) == builder.get_allowed_service_parameters("Reserve") + assert builder.get_allowed_service_parameters( + "PAY" + ) == builder.get_allowed_service_parameters("Pay") + + +# --------------------------------------------------------------------------- +# required_fields override + + +class TestRequiredFields: + def test_reserve_requires_only_currency_and_invoice(self, client): + builder = KlarnaKPBuilder(client).currency("EUR").invoice("INV-1") + assert builder.required_fields("Reserve") == { + "currency": "EUR", + "invoice": "INV-1", + } + + def test_reserve_case_insensitive_matches_Reserve_branch(self, client): + builder = KlarnaKPBuilder(client).currency("EUR").invoice("INV-1") + assert builder.required_fields("reserve") == builder.required_fields("Reserve") + + def test_non_reserve_action_returns_empty_dict(self, client): + """Every action other than Reserve returns ``{}`` — overrides the base + class which would otherwise require currency/amount/description/etc.""" + builder = KlarnaKPBuilder(client) + assert builder.required_fields("Pay") == {} + assert builder.required_fields("CancelReservation") == {} + assert builder.required_fields("UpdateReservation") == {} + assert builder.required_fields("ExtendReservation") == {} + assert builder.required_fields("AddShippingInfo") == {} + + def test_defaults_to_pay_and_returns_empty_dict(self, client): + assert KlarnaKPBuilder(client).required_fields() == {} + + +# --------------------------------------------------------------------------- +# Builder-specific reservation action methods — end-to-end through MockBuckaroo + + +class TestReserve: + def test_posts_reserve_to_data_request_endpoint_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KLKP-RES-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaKPBuilder(client).currency("EUR").invoice("INV-1") + + response = builder.reserve(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "Reserve" + sent = recorded_request(mock) + assert sent["Services"]["ServiceList"][0]["Name"] == "klarnakp" + assert response.key == "KLKP-RES-1" + mock.assert_all_consumed() + + +class TestCancelReservation: + def test_posts_cancel_reservation_to_data_request_endpoint_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KLKP-CAN-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaKPBuilder(client) + + response = builder.cancelReservation(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "CancelReservation" + assert response.key == "KLKP-CAN-1" + mock.assert_all_consumed() + + +class TestUpdateReservation: + def test_posts_update_reservation_to_data_request_endpoint_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KLKP-UPD-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaKPBuilder(client) + + response = builder.updateReservation(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "UpdateReservation" + assert response.key == "KLKP-UPD-1" + mock.assert_all_consumed() + + +class TestExtendReservation: + def test_posts_extend_reservation_to_data_request_endpoint_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KLKP-EXT-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaKPBuilder(client) + + response = builder.extendReservation(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "ExtendReservation" + assert response.key == "KLKP-EXT-1" + mock.assert_all_consumed() + + +class TestAddShippingInfo: + def test_posts_add_shipping_info_to_data_request_endpoint_and_parses_response(self): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "KLKP-SHP-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = KlarnaKPBuilder(client) + + response = builder.addShippingInfo(validate=False) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert recorded_action(mock) == "AddShippingInfo" + assert response.key == "KLKP-SHP-1" + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# pay() still works — sanity that the base Pay path is not regressed + + +def test_pay_dispatches_klarnakp_service_to_transaction_endpoint(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "KLKP-PAY-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + # required_fields("Pay") returns {} so no setters are needed — but we set + # them anyway so refactors that tighten required_fields don't break this. + builder = populate_required_fields(KlarnaKPBuilder(client), amount=42.00) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "klarnakp" + assert service["Action"] == "Pay" + assert response.key == "KLKP-PAY-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_knaken_builder.py b/tests/unit/builders/payments/test_knaken_builder.py new file mode 100644 index 0000000..c2076a2 --- /dev/null +++ b/tests/unit/builders/payments/test_knaken_builder.py @@ -0,0 +1,86 @@ +"""Unit coverage for :class:`KnakenBuilder`. + +Phase 7.22 — per-builder coverage. KnakenBuilder is a minimal subclass of +:class:`PaymentBuilder` that only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes. +Tests exercise every public surface and pin the allowed-parameter shape for +every action we care about. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.knaken_builder import KnakenBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wired_to_mock_buckaroo_succeeds(client): + builder = KnakenBuilder(client) + + assert isinstance(builder, KnakenBuilder) + assert isinstance(builder, PaymentBuilder) + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_knaken(client): + builder = KnakenBuilder(client) + + assert builder.get_service_name() == "Knaken" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "PayRemainder", "ExtraInfo", "UnknownAction"], +) +def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(client, action): + builder = KnakenBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = KnakenBuilder(client) + + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_knaken_service_to_transaction_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "KNK-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(KnakenBuilder(client), amount=42.50) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Knaken" + assert service["Action"] == "Pay" + assert response.key == "KNK-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_mbway_builder.py b/tests/unit/builders/payments/test_mbway_builder.py new file mode 100644 index 0000000..9863281 --- /dev/null +++ b/tests/unit/builders/payments/test_mbway_builder.py @@ -0,0 +1,99 @@ +"""Per-builder unit tests for :class:`MBWayBuilder`. + +Phase 7.23 — per-builder coverage. ``MBWayBuilder`` is a minimal subclass of +:class:`PaymentBuilder`: it overrides only :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`, and mixes in no capability classes. +Unlike :class:`CreditcardBuilder`, it doesn't declare a ``_serviceName`` class +attribute — the authoritative service name lives on +``get_service_name()`` and is what these tests pin. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.mbway_builder import MBWayBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client): + return MBWayBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_returns_payment_builder(builder): + assert isinstance(builder, MBWayBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_construction_binds_client(builder, client): + assert builder._client is client + + +# --------------------------------------------------------------------------- +# _serviceName — MBWayBuilder does NOT declare a class-level ``_serviceName`` + + +def test_mbway_builder_does_not_declare_class_service_name_attribute(): + """Pin that MBWayBuilder leaves ``_serviceName`` undeclared on the class. + + Unlike :class:`CreditcardBuilder`, ``MBWayBuilder`` relies solely on + ``get_service_name()``. If someone later adds the class attribute, this + test fails and they must decide whether both surfaces should agree. + """ + assert "_serviceName" not in MBWayBuilder.__dict__ + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_mbway(builder): + assert builder.get_service_name() == "MBWay" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "PayRemainder", "ExtraInfo", "UnknownAction"], +) +def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(builder, action): + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(builder): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_end_to_end_through_mock_buckaroo(builder, mock_strategy): + """pay() builds a Pay action against the MBWay service, sends it + through the HTTP client, and returns a parsed PaymentResponse.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + { + "Key": "MBWAY-KEY", + "Status": {"Code": {"Code": 190, "Description": "Success"}}, + "Services": [{"Name": "MBWay", "Action": "Pay"}], + }, + ) + ) + + response = populate_required_fields(builder, amount=12.34).pay() + + assert response.key == "MBWAY-KEY" diff --git a/tests/unit/builders/payments/test_multibanco_builder.py b/tests/unit/builders/payments/test_multibanco_builder.py new file mode 100644 index 0000000..8ca7dce --- /dev/null +++ b/tests/unit/builders/payments/test_multibanco_builder.py @@ -0,0 +1,95 @@ +"""Unit coverage for :class:`MultibancoBuilder`. + +Phase 7.24 — per-builder coverage. MultibancoBuilder is a minimal subclass of +:class:`PaymentBuilder`. It only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes +and declares no ``_serviceName`` class attribute. Tests pin the public +surface and the allowed-parameter shape for every action we care about. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.multibanco_builder import MultibancoBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_with_client_succeeds(client): + builder = MultibancoBuilder(client) + + assert isinstance(builder, MultibancoBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_multibanco_builder_does_not_declare_service_name_class_attribute(): + """MultibancoBuilder relies on :meth:`get_service_name` — no ``_serviceName`` attr. + + Pin the stub shape so a future refactor that introduces ``_serviceName`` + has to update the assertion consciously. + """ + assert "_serviceName" not in MultibancoBuilder.__dict__ + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_multibanco(client): + builder = MultibancoBuilder(client) + + assert builder.get_service_name() == "Multibanco" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +@pytest.mark.parametrize( + "action", + ["Pay", "Refund", "Capture", "Authorize", "UnknownAction"], +) +def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(client, action): + builder = MultibancoBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = MultibancoBuilder(client) + + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_multibanco_service_to_transaction_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "MLB-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(MultibancoBuilder(client), amount=12.34) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "Multibanco" + assert service["Action"] == "Pay" + assert response.key == "MLB-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_paybybank_builder.py b/tests/unit/builders/payments/test_paybybank_builder.py new file mode 100644 index 0000000..ad13166 --- /dev/null +++ b/tests/unit/builders/payments/test_paybybank_builder.py @@ -0,0 +1,109 @@ +"""Unit coverage for :class:`PayByBankBuilder`. + +Phase 7.25 — per-builder coverage. PayByBankBuilder mixes in +:class:`BankTransferCapabilities`, which composes +:class:`InstantRefundCapable` and :class:`FastCheckoutCapable`. Tests pin the +allowed-parameter shape for every action we care about, assert that every +capability-mixin method is present and callable, and drive ``pay`` through +``MockBuckaroo`` end to end. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.paybybank_builder import PayByBankBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wired_to_mock_buckaroo_succeeds(client): + builder = PayByBankBuilder(client) + + assert isinstance(builder, PayByBankBuilder) + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_paybybank(client): + builder = PayByBankBuilder(client) + + assert builder.get_service_name() == "PayByBank" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot supported actions + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = PayByBankBuilder(client) + + assert builder.get_allowed_service_parameters("Pay") == { + "issuer": { + "type": str, + "required": True, + "description": "PayByBank bank issuer code", + }, + } + + +def test_get_allowed_service_parameters_is_case_insensitive_for_pay(client): + """Source lower-cases the action before matching, so "pay" equals "Pay".""" + builder = PayByBankBuilder(client) + + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_defaults_to_pay(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = PayByBankBuilder(client) + + assert builder.get_allowed_service_parameters() == builder.get_allowed_service_parameters("Pay") + + +@pytest.mark.parametrize( + "action", + ["Refund", "Authorize", "Capture", "PayRemainder", "UnknownAction"], +) +def test_get_allowed_service_parameters_unsupported_action_returns_empty(client, action): + builder = PayByBankBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_paybybank_service_to_transaction_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "PBB-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(PayByBankBuilder(client), amount=23.45) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "PayByBank" + assert service["Action"] == "Pay" + assert response.key == "PBB-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_payconiq_builder.py b/tests/unit/builders/payments/test_payconiq_builder.py new file mode 100644 index 0000000..53ceb8c --- /dev/null +++ b/tests/unit/builders/payments/test_payconiq_builder.py @@ -0,0 +1,157 @@ +"""Unit tests for :class:`PayconiqBuilder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/payconiq_builder.py``. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.payconiq_builder import PayconiqBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( + BankTransferCapabilities, +) +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = PayconiqBuilder(client) + assert isinstance(builder, PayconiqBuilder) + assert isinstance(builder, PaymentBuilder) + assert isinstance(builder, BankTransferCapabilities) + + +def test_get_service_name_returns_payconiq(client): + assert PayconiqBuilder(client).get_service_name() == "payconiq" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + params = PayconiqBuilder(client).get_allowed_service_parameters("Pay") + assert params == { + "mobilenumber": { + "type": str, + "required": False, + "description": "Mobile number for Payconiq", + }, + "savetoken": { + "type": (str, bool), + "required": False, + "description": "Save payment token for future use", + }, + "isrecurring": { + "type": (str, bool), + "required": False, + "description": "Recurring payment flag", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = PayconiqBuilder(client) + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_payfastcheckout(client): + builder = PayconiqBuilder(client) + assert builder.get_allowed_service_parameters( + "PayFastCheckout" + ) == builder.get_allowed_service_parameters("Pay") + + +def test_get_allowed_service_parameters_instantrefund_returns_empty(client): + assert PayconiqBuilder(client).get_allowed_service_parameters("InstantRefund") == {} + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Cancel"]) +def test_get_allowed_service_parameters_other_actions_return_empty(client, action): + assert PayconiqBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_unknown_action_returns_default(client): + builder = PayconiqBuilder(client) + assert builder.get_allowed_service_parameters( + "SomethingElse" + ) == builder.get_allowed_service_parameters("Pay") + + +def test_capability_mixin_instant_refund(client): + builder = PayconiqBuilder(client) + assert hasattr(builder, "instantRefund") and callable(builder.instantRefund) + + +def test_capability_mixin_fast_checkout(client): + builder = PayconiqBuilder(client) + assert hasattr(builder, "payFastCheckout") and callable(builder.payFastCheckout) + + +def test_mobile_number_setter(client): + builder = PayconiqBuilder(client).mobile_number("+31612345678") + assert isinstance(builder, PayconiqBuilder) + + +def test_from_dict_with_mobile_number(client): + builder = PayconiqBuilder(client).from_dict( + { + "currency": "EUR", + "amount": 5.00, + "mobile_number": "+31612345678", + } + ) + assert isinstance(builder, PayconiqBuilder) + + +def test_from_dict_without_mobile_number(client): + builder = PayconiqBuilder(client).from_dict( + { + "currency": "EUR", + "amount": 5.00, + } + ) + assert isinstance(builder, PayconiqBuilder) + + +def test_payconiq_payFastCheckout_works(client, mock_strategy): + """PayconiqBuilder.payFastCheckout uses the inherited mixin method.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/transaction*", {"Key": "pcq-fc-1", "Status": {"Code": {"Code": 190}}} + ) + ) + builder = populate_required_fields(PayconiqBuilder(client)) + response = builder.payFastCheckout(validate=False) + assert response is not None + + +def test_payconiq_instantRefund_works(client, mock_strategy): + """PayconiqBuilder.instantRefund uses the inherited mixin method.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", "*/json/transaction*", {"Key": "pcq-ir-1", "Status": {"Code": {"Code": 190}}} + ) + ) + builder = populate_required_fields(PayconiqBuilder(client)) + response = builder.instantRefund(validate=False) + assert response is not None + + +def test_pay_end_to_end(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "payconiq-key-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(PayconiqBuilder(client), amount=25.00) + .mobile_number("+31600000000") + .pay() + ) + + assert response.key == "payconiq-key-123" diff --git a/tests/unit/builders/payments/test_payment_builder.py b/tests/unit/builders/payments/test_payment_builder.py new file mode 100644 index 0000000..db8dfa5 --- /dev/null +++ b/tests/unit/builders/payments/test_payment_builder.py @@ -0,0 +1,650 @@ +"""Tests for :class:`buckaroo.builders.payments.payment_builder.PaymentBuilder`. + +Exercises :class:`PaymentBuilder` via the shared ``make_test_builder`` helper. +Assertions read off the public API — ``PaymentRequest.to_dict()`` and recorded +HTTP calls — never builder internals. +""" + +from __future__ import annotations + +import json + +import pytest + +from buckaroo.exceptions._parameter_validation_error import ( + RequiredParameterMissingError, +) +from buckaroo.http.client import BuckarooApiError +from tests.support.builders import ( + make_test_builder, + populate_required_fields, +) +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# build(action="Pay") — basic shape + + +def test_build_pay_sets_service_name_and_action(): + builder = populate_required_fields( + make_test_builder(object(), service_name="ideal"), amount=10.50 + ) + + request = builder.build("Pay", validate=False).to_dict() + + service = request["Services"]["ServiceList"][0] + assert service["Name"] == "ideal" + assert service["Action"] == "Pay" + + +# --------------------------------------------------------------------------- +# build() — required service parameter validation + + +def test_build_pay_raises_when_required_service_parameter_missing(): + allowed = { + "Pay": { + "issuer": {"type": str, "required": True}, + } + } + builder = populate_required_fields( + make_test_builder(object(), service_name="ideal", allowed_params=allowed), + amount=10.50, + ) + + with pytest.raises(RequiredParameterMissingError) as exc: + builder.build("Pay", strict_validation=True) + + assert "issuer" in str(exc.value) + assert exc.value.parameter_name == "issuer" + + +def test_build_pay_with_validate_false_skips_required_parameter_check(): + allowed = { + "Pay": { + "issuer": {"type": str, "required": True}, + } + } + builder = populate_required_fields( + make_test_builder(object(), service_name="ideal", allowed_params=allowed), + amount=10.50, + ) + + request = builder.build("Pay", validate=False).to_dict() + + service = request["Services"]["ServiceList"][0] + assert service["Name"] == "ideal" + assert service["Action"] == "Pay" + + +def test_build_pay_filters_parameters_not_in_allowed_service_parameters(): + allowed = { + "Pay": { + "issuer": {"type": str, "required": False}, + } + } + builder = populate_required_fields( + make_test_builder(object(), service_name="ideal", allowed_params=allowed), + amount=10.50, + ) + builder.add_parameter("issuer", "INGBNL2A") + builder.add_parameter("rogue", "should-vanish") + + request = builder.build("Pay").to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + + names = [p["Name"] for p in params] + assert "Issuer" in names + assert "Rogue" not in names + + +# --------------------------------------------------------------------------- +# pay() — builds Pay request, posts to /json/transaction, returns PaymentResponse + + +def test_pay_posts_build_pay_request_to_transaction_endpoint(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "PAY-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + builder = populate_required_fields( + make_test_builder(client, service_name="ideal"), amount=10.50 + ) + response = builder.pay(validate=False) + + assert mock.calls[0]["method"] == "POST" + assert "/json/transaction" in mock.calls[0]["url"].lower() + + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "ideal" + assert service["Action"] == "Pay" + + assert response.key == "PAY-1" + mock.assert_all_consumed() + + +def test_post_transaction_uses_injected_client_and_returns_parsed_payment_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "T-99", "Invoice": "INV-1"}, + ) + ) + + builder = populate_required_fields( + make_test_builder(client, service_name="ideal"), amount=10.50 + ) + request = builder.build("Pay", validate=False) + + response = builder._post_transaction(request.to_dict()) + + assert response.key == "T-99" + assert response.invoice == "INV-1" + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# Error propagation + + +def test_post_transaction_propagates_buckaroo_error_from_http_client(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json("POST", "*/json/transaction*", {"error": "boom"}, status=500) + ) + + builder = populate_required_fields( + make_test_builder(client, service_name="ideal"), amount=10.50 + ) + + with pytest.raises(BuckarooApiError): + builder.pay(validate=False) + + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# Fluent setters (PaymentBuilder re-declares these over BaseBuilder) + + +FLUENT_SETTERS = [ + ("currency", "EUR", "Currency"), + ("amount", 12.34, "AmountDebit"), + ("description", "payment description", "Description"), + ("invoice", "INV-42", "Invoice"), + ("return_url", "https://example.com/ok", "ReturnURL"), + ("return_url_cancel", "https://example.com/cancel", "ReturnURLCancel"), + ("return_url_error", "https://example.com/error", "ReturnURLError"), + ("return_url_reject", "https://example.com/reject", "ReturnURLReject"), +] + + +@pytest.mark.parametrize("setter,value,dict_key", FLUENT_SETTERS) +def test_fluent_setter_returns_self_and_appears_in_request(setter, value, dict_key): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + result = getattr(builder, setter)(value) + assert result is builder + + request = builder.build(validate=False).to_dict() + assert request[dict_key] == value + + +def test_continue_on_incomplete_setter_returns_self_and_appears_in_request(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + assert builder.continue_on_incomplete("0") is builder + request = builder.build(validate=False).to_dict() + assert request["ContinueOnIncomplete"] == "0" + + +def test_client_ip_setter_returns_self_and_appears_in_request(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + assert builder.client_ip("203.0.113.7", ip_type=1) is builder + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 1, "Address": "203.0.113.7"} + + +# --------------------------------------------------------------------------- +# add_parameter + + +def test_add_parameter_flat_returns_self_and_capitalizes_name(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + result = builder.add_parameter("issuer", "INGBNL2A") + assert result is builder + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + assert params == [{"Name": "Issuer", "GroupType": "", "GroupID": "", "Value": "INGBNL2A"}] + + +def test_add_parameter_grouped_sets_group_type_and_group_id(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.add_parameter("firstName", "Jane", group_type="customer", group_id="7") + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + assert params == [ + {"Name": "Firstname", "GroupType": "Customer", "GroupID": "7", "Value": "Jane"} + ] + + +def test_add_parameter_boolean_values_are_lowercased_strings(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.add_parameter("enabled", True) + builder.add_parameter("disabled", False) + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + assert params[0]["Value"] == "true" + assert params[1]["Value"] == "false" + + +def test_add_parameter_with_list_of_dicts_adds_grouped_batch(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.add_parameter( + "articles", + [ + {"name": "Item A", "quantity": 2}, + {"name": "Item B", "quantity": 1}, + ], + ) + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + assert params == [ + {"Name": "Name", "GroupType": "Articles", "GroupID": "1", "Value": "Item A"}, + {"Name": "Quantity", "GroupType": "Articles", "GroupID": "1", "Value": "2"}, + {"Name": "Name", "GroupType": "Articles", "GroupID": "2", "Value": "Item B"}, + {"Name": "Quantity", "GroupType": "Articles", "GroupID": "2", "Value": "1"}, + ] + + +def test_add_parameter_with_list_of_non_dicts_is_ignored(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.add_parameter("articles", ["not-a-dict", 42]) + + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert "Parameters" not in service + + +# --------------------------------------------------------------------------- +# Validator convenience passthroughs + + +def test_is_parameter_allowed_delegates_to_validator(): + allowed = {"Pay": {"issuer": {"type": str, "required": False}}} + builder = make_test_builder(object(), allowed_params=allowed) + assert builder.is_parameter_allowed("issuer", "Pay") is True + assert builder.is_parameter_allowed("nope", "Pay") is False + + +def test_get_parameter_info_returns_allowed_params_for_action(): + allowed = {"Pay": {"issuer": {"type": str, "required": False}}} + builder = make_test_builder(object(), allowed_params=allowed) + assert builder.get_parameter_info("Pay") == allowed["Pay"] + + +def test_get_normalized_parameter_name_returns_canonical_name(): + allowed = {"Pay": {"issuer": {"type": str, "required": False}}} + builder = make_test_builder(object(), allowed_params=allowed) + assert builder.get_normalized_parameter_name("Issuer", "Pay") == "issuer" + assert builder.get_normalized_parameter_name("unknown", "Pay") == "" + + +# --------------------------------------------------------------------------- +# from_dict + + +def test_from_dict_populates_all_supported_core_fields_and_returns_self(): + builder = make_test_builder(object()) + data = { + "currency": "EUR", + "amount": 99.99, + "description": "hello", + "invoice": "INV-9", + "return_url": "https://example.com/ok", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + "continue_on_incomplete": "0", + "push_url": "https://example.com/push", + "push_url_failure": "https://example.com/push-fail", + "client_ip": "198.51.100.9", + } + + result = builder.from_dict(data) + assert result is builder + + request = builder.build(validate=False).to_dict() + assert request["Currency"] == "EUR" + assert request["AmountDebit"] == 99.99 + assert request["Description"] == "hello" + assert request["Invoice"] == "INV-9" + assert request["ReturnURL"] == "https://example.com/ok" + assert request["ReturnURLCancel"] == "https://example.com/cancel" + assert request["ReturnURLError"] == "https://example.com/error" + assert request["ReturnURLReject"] == "https://example.com/reject" + assert request["ContinueOnIncomplete"] == "0" + assert request["PushURL"] == "https://example.com/push" + assert request["PushURLFailure"] == "https://example.com/push-fail" + assert request["ClientIP"] == {"Type": 0, "Address": "198.51.100.9"} + + +def test_from_dict_client_ip_as_dict_uses_address_and_type(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.from_dict({"client_ip": {"address": "203.0.113.5", "type": 1}}) + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 1, "Address": "203.0.113.5"} + + +def test_from_dict_client_ip_empty_dict_uses_defaults(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.from_dict({"client_ip": {}}) + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 0, "Address": "0.0.0.0"} + + +def test_from_dict_service_parameters_top_level_scalar_becomes_flat_parameter(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.from_dict({"service_parameters": {"issuer": "INGBNL2A"}}) + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + {"Name": "Issuer", "GroupType": "", "GroupID": "", "Value": "INGBNL2A"} + ] + + +def test_from_dict_service_parameters_nested_dict_becomes_grouped_parameters(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.from_dict({"service_parameters": {"customer": {"firstName": "Jane"}}}) + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + {"Name": "Firstname", "GroupType": "Customer", "GroupID": "", "Value": "Jane"} + ] + + +def test_from_dict_ignores_client_ip_of_unsupported_type(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + builder.from_dict({"client_ip": 12345}) + request = builder.build(validate=False).to_dict() + # Falls through to PaymentRequest's default. + assert request["ClientIP"] == {"Type": 0, "Address": "0.0.0.0"} + + +# --------------------------------------------------------------------------- +# _validate_required_fields + + +def test_build_raises_when_required_core_field_missing(): + builder = make_test_builder(object()).currency("EUR") # missing everything else + with pytest.raises(ValueError, match="Missing required fields"): + builder.build(validate=False) + + +# --------------------------------------------------------------------------- +# refund + + +def test_refund_requires_original_transaction_key(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.refund() + + +def test_refund_full_swaps_debit_to_credit_and_adds_transaction_key(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "R-1"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-123"}) + + builder.refund(validate=False) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "TXN-123" + assert sent["AmountCredit"] == 10.50 + assert "AmountDebit" not in sent + mock.assert_all_consumed() + + +def test_refund_partial_uses_refund_amount_and_removes_debit(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "R-1"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-9", "refund_amount": 3.25}) + + builder.refund(validate=False) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "TXN-9" + assert sent["AmountCredit"] == 3.25 + assert "AmountDebit" not in sent + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# capture + + +def test_capture_requires_authorization_key(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + with pytest.raises(ValueError, match="Authorization key is required"): + builder.capture() + + +def test_capture_uses_key_argument_and_sets_original_transaction_key(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + builder.capture(original_transaction_key="AUTH-1", amount=4.0, validate=False) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "AUTH-1" + assert sent["AmountDebit"] == 4.0 + mock.assert_all_consumed() + + +def test_capture_reads_authorization_key_and_amount_from_payload(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"authorization_key": "AUTH-2", "capture_amount": 7.5}) + + builder.capture(validate=False) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "AUTH-2" + assert sent["AmountDebit"] == 7.5 + mock.assert_all_consumed() + + +def test_capture_without_amount_argument_or_payload_keeps_built_amount(): + """Covers the ``if capture_amount is not None`` False branch.""" + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"original_transaction_key": "AUTH-X"}) + + builder.capture(validate=False) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "AUTH-X" + # Amount stays as the built value from populate_required_fields. + assert sent["AmountDebit"] == 10.50 + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# cancel + + +def test_cancel_requires_transaction_key(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + with pytest.raises(ValueError, match="Transaction key is required"): + builder.cancel() + + +def test_cancel_removes_amounts_and_sets_transaction_key(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + builder.cancel(original_transaction_key="CANCEL-1") + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "CANCEL-1" + assert "AmountDebit" not in sent + assert "AmountCredit" not in sent + mock.assert_all_consumed() + + +def test_cancel_reads_transaction_key_from_payload(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"cancel_key": "CANCEL-2"}) + + builder.cancel() + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "CANCEL-2" + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# partial_refund — has a known bug, pinned as regression + + +def test_partial_refund_raises_when_amount_missing_or_non_positive(): + builder = populate_required_fields(make_test_builder(object()), amount=10.50) + with pytest.raises(ValueError, match="Partial refund amount must be greater than 0"): + builder.partial_refund() + + +def test_partial_refund_reads_original_transaction_key_from_payload(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-1"}) + + builder.partial_refund(amount=2.5) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "TXN-1" + assert sent["AmountCredit"] == 2.5 + assert "AmountDebit" not in sent + + +def test_partial_refund_uses_explicit_original_transaction_key_argument(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + builder.partial_refund(original_transaction_key="TXN-2", amount=3.75) + + sent = recorded_request(mock) + assert sent["OriginalTransactionKey"] == "TXN-2" + assert sent["AmountCredit"] == 3.75 + assert "AmountDebit" not in sent + + +def test_partial_refund_does_not_leak_state_into_subsequent_full_refund(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "p"})) + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "f"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + builder.partial_refund(original_transaction_key="TXN-A", amount=2.5) + builder.from_dict({"original_transaction_key": "TXN-B"}) + builder.refund() + + assert json.loads(mock.calls[0]["data"])["AmountCredit"] == 2.5 + full = json.loads(mock.calls[1]["data"]) + assert full["OriginalTransactionKey"] == "TXN-B" + assert full["AmountCredit"] == 10.50 + + +def test_partial_refund_restores_pre_existing_payload_keys(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "ok"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-orig", "refund_amount": 9.99}) + + builder.partial_refund(original_transaction_key="TXN-tmp", amount=2.5) + + assert builder._payload["original_transaction_key"] == "TXN-orig" + assert builder._payload["refund_amount"] == 9.99 + + +# --------------------------------------------------------------------------- +# _post_data_request and _post_transaction None-response branch + + +def test_post_data_request_posts_to_data_request_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest*", {"Key": "D-1"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + request_data = builder.build(validate=False).to_dict() + + response = builder._post_data_request(request_data) + + assert "/json/DataRequest" in mock.calls[0]["url"] + assert response.key == "D-1" + mock.assert_all_consumed() + + +def _patch_http_client_post_returning(client, value): + """Monkey-patch ``client.http_client.post`` to return ``value``.""" + client.http_client.post = lambda path, data: value + + +def test_post_transaction_returns_empty_payment_response_when_client_returns_none(): + _, client = wire_recording_http() + _patch_http_client_post_returning(client, None) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + response = builder._post_transaction({"anything": True}) + + # Empty PaymentResponse wraps {}; nothing parsed from data. + assert response.key is None + assert response.to_dict() == {} + + +def test_post_data_request_returns_empty_payment_response_when_client_returns_none(): + _, client = wire_recording_http() + _patch_http_client_post_returning(client, None) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + response = builder._post_data_request({"anything": True}) + + assert response.key is None + assert response.to_dict() == {} + + +# --------------------------------------------------------------------------- +# execute_action — generic action dispatcher + + +def test_execute_action_posts_with_requested_action_name(): + mock, client = wire_recording_http() + mock.queue(BuckarooMockRequest.json("POST", "*/json/transaction*", {"Key": "X-1"})) + builder = populate_required_fields(make_test_builder(client), amount=10.50) + + response = builder.execute_action("DummyAction", validate=False) + + sent = recorded_request(mock) + assert sent["Services"]["ServiceList"][0]["Action"] == "DummyAction" + assert response.key == "X-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_paypal_builder.py b/tests/unit/builders/payments/test_paypal_builder.py new file mode 100644 index 0000000..78824ee --- /dev/null +++ b/tests/unit/builders/payments/test_paypal_builder.py @@ -0,0 +1,99 @@ +"""Unit tests for :class:`PaypalBuilder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/paypal_builder.py``. The builder carries no +capability mixins and defines no action methods of its own, so the surface +under test is: + +- construction via ``BuckarooClient`` wired to :class:`MockBuckaroo` +- ``get_service_name()`` +- ``get_allowed_service_parameters(action)`` for both branches +- ``pay()`` end-to-end through the mock strategy +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.paypal_builder import PaypalBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = PaypalBuilder(client) + assert isinstance(builder, PaypalBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_paypal(client): + assert PaypalBuilder(client).get_service_name() == "paypal" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = PaypalBuilder(client) + + assert builder.get_allowed_service_parameters("Pay") == { + "buyerEmail": { + "type": str, + "required": False, + "description": "Buyer's email address.", + }, + "productName": { + "type": str, + "required": False, + "description": "Name of the product.", + }, + "billingAgreementDescription": { + "type": str, + "required": False, + "description": "Description of the billing agreement.", + }, + "pageStyle": { + "type": str, + "required": False, + "description": "Style of the payment page.", + }, + "startrecurrent": { + "type": str, + "required": False, + "description": "Start of recurrent payment.", + }, + "payPalOrderId": { + "type": str, + "required": False, + "description": "PayPal order ID.", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = PaypalBuilder(client) + + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert PaypalBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "paypal-key-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(PaypalBuilder(client), amount=42.50) + .add_parameter("buyerEmail", "buyer@example.test") + .pay() + ) + + assert response.key == "paypal-key-123" diff --git a/tests/unit/builders/payments/test_przelewy24_builder.py b/tests/unit/builders/payments/test_przelewy24_builder.py new file mode 100644 index 0000000..c6d6045 --- /dev/null +++ b/tests/unit/builders/payments/test_przelewy24_builder.py @@ -0,0 +1,105 @@ +"""Unit tests for :class:`Przelewy24Builder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/przelewy24_builder.py``. The builder carries no +capability mixins and defines no action methods of its own, so the surface +under test is: + +- construction via ``BuckarooClient`` wired to :class:`MockBuckaroo` +- ``get_service_name()`` +- ``get_allowed_service_parameters(action)`` for both branches +- ``pay()`` end-to-end through the mock strategy +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.przelewy24_builder import Przelewy24Builder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest + + +def test_construction_with_client_succeeds(client): + builder = Przelewy24Builder(client) + assert isinstance(builder, Przelewy24Builder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_przelewy24(client): + assert Przelewy24Builder(client).get_service_name() == "przelewy24" + + +def test_no_class_level_service_name_attribute(): + """Unlike :class:`CreditcardBuilder`, Przelewy24Builder doesn't declare a + ``_serviceName`` class attribute. The authoritative name is exposed via + ``get_service_name()``.""" + assert "_serviceName" not in Przelewy24Builder.__dict__ + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = Przelewy24Builder(client) + + assert builder.get_allowed_service_parameters("Pay") == { + "customerEmail": { + "type": str, + "required": True, + "description": "Customer email address", + }, + "customerFirstName": { + "type": str, + "required": True, + "description": "Customer first name", + }, + "customerLastName": { + "type": str, + "required": True, + "description": "Customer last name", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = Przelewy24Builder(client) + + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +def test_get_allowed_service_parameters_defaults_to_pay(client): + builder = Przelewy24Builder(client) + + assert builder.get_allowed_service_parameters() == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert Przelewy24Builder(client).get_allowed_service_parameters(action) == {} + + +def test_pay_dispatches_through_mock_buckaroo(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "p24-key-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields( + Przelewy24Builder(client), + currency="PLN", + amount=50.00, + description="P24 order", + invoice="INV-P24-1", + return_url="https://example.test/return", + return_url_cancel="https://example.test/cancel", + return_url_error="https://example.test/error", + return_url_reject="https://example.test/reject", + ).pay(validate=False) + + assert response.key == "p24-key-1" diff --git a/tests/unit/builders/payments/test_riverty_builder.py b/tests/unit/builders/payments/test_riverty_builder.py new file mode 100644 index 0000000..7571430 --- /dev/null +++ b/tests/unit/builders/payments/test_riverty_builder.py @@ -0,0 +1,113 @@ +"""Unit coverage for :class:`RivertyBuilder`. + +Riverty is a buy-now-pay-later method (formerly AfterPay). The builder has +no capability mixins and exposes a single cart-line-item oriented parameter +spec for ``Pay``. Per-action spec and end-to-end ``pay()`` via +:class:`MockBuckaroo` are pinned inline so drift is loud. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.riverty_builder import RivertyBuilder +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client: BuckarooClient) -> RivertyBuilder: + return RivertyBuilder(client) + + +def test_builder_instantiates_as_payment_builder(builder: RivertyBuilder) -> None: + assert isinstance(builder, RivertyBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_afterpay(builder: RivertyBuilder) -> None: + # Riverty is the rebrand of AfterPay; Buckaroo's API still uses the + # ``afterpay`` service name on the wire. + assert builder.get_service_name() == "afterpay" + + +def test_get_allowed_service_parameters_pay_snapshot(builder: RivertyBuilder) -> None: + assert builder.get_allowed_service_parameters("Pay") == { + "billingCustomer": { + "type": list, + "required": True, + "description": "Billing customer information", + }, + "shippingCustomer": { + "type": list, + "required": True, + "description": "Shipping customer information", + }, + "article": { + "type": list, + "required": True, + "description": "Riverty articles", + }, + } + + +def test_get_allowed_service_parameters_pay_case_insensitive( + builder: RivertyBuilder, +) -> None: + # Source lowercases the action before comparing. + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + assert builder.get_allowed_service_parameters("PAY") == builder.get_allowed_service_parameters( + "Pay" + ) + + +@pytest.mark.parametrize("action", ["Refund", "Authorize", "Capture", "CancelAuthorize", ""]) +def test_get_allowed_service_parameters_non_pay_actions_return_empty( + builder: RivertyBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay(builder: RivertyBuilder) -> None: + # Default ``action`` arg is ``"Pay"`` — no-arg call must match the Pay spec. + assert builder.get_allowed_service_parameters() == builder.get_allowed_service_parameters("Pay") + + +def test_pay_end_to_end_via_mock_buckaroo( + builder: RivertyBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "riverty-key", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(builder, amount=79.50) + .from_dict( + { + "service_parameters": { + "billingCustomer": {"firstName": "Jane", "lastName": "Doe"}, + "shippingCustomer": {"firstName": "Jane", "lastName": "Doe"}, + "article": [ + { + "identifier": "SKU-1", + "description": "Widget", + "quantity": 1, + "price": 79.50, + }, + ], + } + } + ) + .pay() + ) + + assert response.key == "riverty-key" diff --git a/tests/unit/builders/payments/test_sepadirectdebit_builder.py b/tests/unit/builders/payments/test_sepadirectdebit_builder.py new file mode 100644 index 0000000..fa615fb --- /dev/null +++ b/tests/unit/builders/payments/test_sepadirectdebit_builder.py @@ -0,0 +1,180 @@ +"""Unit coverage for :class:`SepaDirectDebitBuilder`. + +Phase 7.30 — per-builder coverage. SepaDirectDebit is a minimal subclass of +:class:`PaymentBuilder` that only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes. + +The Pay action exposes SEPA mandate-related parameters (``mandateReference``, +``mandateDate``, ``startRecurrent``, ``electronicSignature``) on top of the +usual customer account fields. Tests snapshot the full Pay spec inline and +explicitly verify the mandate fields are present. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.sepadirectdebit_builder import ( + SepaDirectDebitBuilder, +) +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client: BuckarooClient) -> SepaDirectDebitBuilder: + return SepaDirectDebitBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_instantiates_as_payment_builder(builder: SepaDirectDebitBuilder) -> None: + assert isinstance(builder, SepaDirectDebitBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_construction_binds_client(builder: SepaDirectDebitBuilder, client: BuckarooClient) -> None: + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_sepadirectdebit( + builder: SepaDirectDebitBuilder, +) -> None: + assert builder.get_service_name() == "SepaDirectDebit" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — Pay action snapshot + + +def test_get_allowed_service_parameters_pay_snapshot( + builder: SepaDirectDebitBuilder, +) -> None: + # Full inline snapshot of the Pay spec. If any field's metadata changes + # this test fails, forcing an explicit review of the SDK contract. + assert builder.get_allowed_service_parameters("Pay") == { + "customeraccountname": { + "type": str, + "required": True, + "description": "Customer account name", + }, + "customeriban": { + "type": str, + "required": True, + "description": "Customer IBAN", + }, + "customerbic": { + "type": str, + "required": False, + "description": "Customer BIC", + }, + "collectdate": { + "type": str, + "required": False, + "description": "Collect date", + }, + "mandateReference": { + "type": str, + "required": False, + "description": "Mandate reference", + }, + "mandateDate": { + "type": str, + "required": False, + "description": "Mandate date", + }, + "startRecurrent": { + "type": str, + "required": False, + "description": "Start recurrent", + }, + "electronicSignature": { + "type": str, + "required": False, + "description": "Electronic signature", + }, + } + + +def test_get_allowed_service_parameters_default_action_matches_pay( + builder: SepaDirectDebitBuilder, +) -> None: + # Covers the ``action: str = "Pay"`` default-argument branch. + assert builder.get_allowed_service_parameters() == builder.get_allowed_service_parameters("Pay") + + +def test_get_allowed_service_parameters_pay_case_insensitive( + builder: SepaDirectDebitBuilder, +) -> None: + # The source branches on ``action.lower() in ["pay"]`` — pin lowercase too. + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +@pytest.mark.parametrize( + "action", + ["Refund", "Authorize", "Capture", "PayRemainder", "ExtraInfo", "Unknown"], +) +def test_get_allowed_service_parameters_non_pay_actions_return_empty( + builder: SepaDirectDebitBuilder, action: str +) -> None: + # Source returns ``{}`` for every action except Pay; snapshot the + # non-Pay branch so a future per-action table doesn't silently regress. + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# Capability-only methods — SepaDirectDebit mixes in nothing + + +def test_does_not_mix_in_capability_methods( + builder: SepaDirectDebitBuilder, +) -> None: + for method in ( + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "payEncrypted", + "instantRefund", + "payFastCheckout", + ): + assert not hasattr(builder, method), ( + f"SepaDirectDebitBuilder unexpectedly exposes capability method {method!r}" + ) + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_sepadirectdebit_service_to_transaction_endpoint(): + mock, stub_client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "SDD-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = SepaDirectDebitBuilder(stub_client) + populate_required_fields(builder, amount=12.34) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "SepaDirectDebit" + assert service["Action"] == "Pay" + assert response.key == "SDD-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_sofort_builder.py b/tests/unit/builders/payments/test_sofort_builder.py new file mode 100644 index 0000000..51b7da5 --- /dev/null +++ b/tests/unit/builders/payments/test_sofort_builder.py @@ -0,0 +1,167 @@ +"""Unit tests for :class:`SofortBuilder`. + +Targets 100% line + branch coverage of +``buckaroo/builders/payments/sofort_builder.py``. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.sofort_builder import SofortBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.capabilities.bank_transfer_capabilities import ( + BankTransferCapabilities, +) +from buckaroo.builders.payments.capabilities.instant_refund_capable import InstantRefundCapable +from buckaroo.builders.payments.capabilities.fast_checkout_capable import FastCheckoutCapable +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +# -- Construction -- + + +def test_construction_with_client_succeeds(client): + builder = SofortBuilder(client) + assert isinstance(builder, SofortBuilder) + assert isinstance(builder, PaymentBuilder) + assert isinstance(builder, BankTransferCapabilities) + + +# -- Service name -- + + +def test_get_service_name_returns_sofort(client): + assert SofortBuilder(client).get_service_name() == "sofort" + + +# -- Allowed service parameters snapshots -- + + +def test_get_allowed_service_parameters_pay_snapshot(client): + params = SofortBuilder(client).get_allowed_service_parameters("Pay") + assert params == { + "countrycode": {"type": str, "required": False, "description": "Sofort country code"}, + "savetoken": { + "type": (str, bool), + "required": False, + "description": "Save payment token for future use", + }, + "isrecurring": { + "type": (str, bool), + "required": False, + "description": "Recurring payment flag", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = SofortBuilder(client) + assert builder.get_allowed_service_parameters("pay") == builder.get_allowed_service_parameters( + "Pay" + ) + + +def test_get_allowed_service_parameters_payfastcheckout(client): + builder = SofortBuilder(client) + assert builder.get_allowed_service_parameters( + "payFastCheckout" + ) == builder.get_allowed_service_parameters("Pay") + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Cancel"]) +def test_get_allowed_service_parameters_empty_actions(client, action): + assert SofortBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_instantrefund_empty(client): + assert SofortBuilder(client).get_allowed_service_parameters("instantRefund") == {} + + +def test_get_allowed_service_parameters_unknown_action_returns_defaults(client): + builder = SofortBuilder(client) + assert builder.get_allowed_service_parameters( + "SomeUnknown" + ) == builder.get_allowed_service_parameters("Pay") + + +# -- Capability mixin sanity -- + + +@pytest.mark.parametrize( + "mixin", [InstantRefundCapable, FastCheckoutCapable, BankTransferCapabilities] +) +def test_inherits_capability_mixin(client, mixin): + assert isinstance(SofortBuilder(client), mixin) + + +# -- country_code fluent setter -- + + +def test_country_code_setter_returns_self(client): + builder = SofortBuilder(client) + result = builder.country_code("NL") + assert result is builder + + +# -- from_dict with country_code -- + + +def test_from_dict_populates_country_code(client): + builder = SofortBuilder(client) + result = builder.from_dict({"country_code": "DE"}) + assert result is builder + + +def test_from_dict_without_country_code(client): + builder = SofortBuilder(client) + result = builder.from_dict({"currency": "EUR"}) + assert result is builder + + +def test_pay_fast_checkout_works(client, mock_strategy): + """SofortBuilder.payFastCheckout uses the inherited mixin method.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "sofort-fc-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(SofortBuilder(client)) + response = builder.payFastCheckout() + assert response is not None + + +def test_instant_refund_works(client, mock_strategy): + """SofortBuilder.instantRefund uses the inherited mixin method.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "sofort-ir-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(SofortBuilder(client)) + response = builder.instantRefund() + assert response is not None + + +# -- End-to-end pay -- + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "sofort-key-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(SofortBuilder(client), amount=25.00).country_code("NL").pay() + ) + + assert response.key == "sofort-key-123" diff --git a/tests/unit/builders/payments/test_swish_builder.py b/tests/unit/builders/payments/test_swish_builder.py new file mode 100644 index 0000000..911ca95 --- /dev/null +++ b/tests/unit/builders/payments/test_swish_builder.py @@ -0,0 +1,117 @@ +"""Unit coverage for :class:`SwishBuilder`. + +SwishBuilder is a minimal subclass of :class:`PaymentBuilder`. It defines no +capability mixins and no class-level ``_serviceName``; the only overrides are +``get_service_name()`` and ``get_allowed_service_parameters()``. These tests +pin that surface and drive a ``pay()`` round-trip through :class:`MockBuckaroo` +to hit every branch in ``swish_builder.py``. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.swish_builder import SwishBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +@pytest.fixture +def builder(client: BuckarooClient) -> SwishBuilder: + return SwishBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wires_client(builder: SwishBuilder, client: BuckarooClient) -> None: + assert isinstance(builder, SwishBuilder) + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_swish(builder: SwishBuilder) -> None: + assert builder.get_service_name() == "Swish" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every branch + + +def test_get_allowed_service_parameters_pay_is_empty_dict(builder: SwishBuilder) -> None: + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_pay_is_case_insensitive( + builder: SwishBuilder, +) -> None: + assert builder.get_allowed_service_parameters("pay") == {} + + +def test_get_allowed_service_parameters_defaults_to_pay(builder: SwishBuilder) -> None: + # Covers the ``action: str = "Pay"`` default-argument branch. + assert builder.get_allowed_service_parameters() == {} + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "ExtraInfo", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty_dict( + builder: SwishBuilder, action: str +) -> None: + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# Capability-only methods — Swish mixes in nothing + + +def test_does_not_expose_capability_only_methods(builder: SwishBuilder) -> None: + for method in ( + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "payEncrypted", + "instantRefund", + "payFastCheckout", + ): + assert not hasattr(builder, method), ( + f"SwishBuilder unexpectedly exposes capability method {method!r}" + ) + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_transaction_and_parses_response( + builder: SwishBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "swish-key-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields( + builder, + currency="SEK", + amount=49.99, + description="Swish order", + invoice="INV-SWISH-1", + return_url="https://example.test/return", + return_url_cancel="https://example.test/cancel", + return_url_error="https://example.test/error", + return_url_reject="https://example.test/reject", + ).pay() + + assert response.key == "swish-key-123" + assert response.status.code.code == 190 diff --git a/tests/unit/builders/payments/test_transfer_builder.py b/tests/unit/builders/payments/test_transfer_builder.py new file mode 100644 index 0000000..635d36a --- /dev/null +++ b/tests/unit/builders/payments/test_transfer_builder.py @@ -0,0 +1,99 @@ +"""Unit coverage for :class:`TransferBuilder`. + +Phase 7.33 — per-builder coverage. TransferBuilder is a minimal subclass of +:class:`PaymentBuilder`; it mixes in no capability classes and only overrides +:meth:`get_service_name` and :meth:`get_allowed_service_parameters`. Tests +snapshot the allowed-parameter shape for every action we care about and drive +one ``pay()`` round-trip through :class:`MockBuckaroo` for end-to-end coverage +of the inherited action path. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.transfer_builder import TransferBuilder +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client: BuckarooClient) -> TransferBuilder: + return TransferBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_instantiates_as_payment_builder(builder: TransferBuilder) -> None: + assert isinstance(builder, PaymentBuilder) + assert isinstance(builder, TransferBuilder) + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_transfer(builder: TransferBuilder) -> None: + assert builder.get_service_name() == "Transfer" + + +# --------------------------------------------------------------------------- +# _serviceName class attribute — TransferBuilder does NOT set one; the service +# identity is projected exclusively via get_service_name(). Pin that so a +# future refactor that introduces an out-of-sync class attribute trips here. + + +def test_service_name_class_attribute_not_set() -> None: + # ``_serviceName`` is an optional class-level attribute some builders use + # to declare the wire name. TransferBuilder relies solely on the + # ``get_service_name()`` override, so the attribute is absent on the class + # itself (it may exist on a distant ancestor — we only guard against + # Transfer accidentally introducing a drifting copy). + assert "_serviceName" not in TransferBuilder.__dict__ + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters + + +def test_get_allowed_service_parameters_pay_snapshot(builder: TransferBuilder) -> None: + spec = builder.get_allowed_service_parameters("Pay") + assert "customeremail" in spec + assert spec["customeremail"]["required"] is True + assert "customerfirstname" in spec + assert "customerlastname" in spec + assert "customergender" in spec + assert spec["customergender"]["required"] is False + assert "sendmail" in spec + assert spec["sendmail"]["required"] is False + assert "dateDue" in spec + assert spec["dateDue"]["required"] is False + assert "customerCountry" in spec + assert spec["customerCountry"]["required"] is False + + +def test_get_allowed_service_parameters_unsupported_action_returns_empty( + builder: TransferBuilder, +) -> None: + assert builder.get_allowed_service_parameters("Refund") == {} + + +def test_pay_dispatches_through_mock_buckaroo( + mock_strategy: MockBuckaroo, client: BuckarooClient +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "transfer-key-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields(TransferBuilder(client), amount=25.00).pay(validate=False) + + assert response.key == "transfer-key-1" diff --git a/tests/unit/builders/payments/test_trustly_builder.py b/tests/unit/builders/payments/test_trustly_builder.py new file mode 100644 index 0000000..5bf056b --- /dev/null +++ b/tests/unit/builders/payments/test_trustly_builder.py @@ -0,0 +1,116 @@ +"""Unit coverage for :class:`TrustlyBuilder`. + +Phase 7.34 — per-builder coverage. ``TrustlyBuilder`` is a thin subclass of +:class:`PaymentBuilder` that only overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters`; it mixes in no capability classes. +Tests pin every public surface and drive ``pay()`` end-to-end through +:class:`tests.support.mock_buckaroo.MockBuckaroo`. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.trustly_builder import TrustlyBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +@pytest.fixture +def builder(client: BuckarooClient) -> TrustlyBuilder: + return TrustlyBuilder(client) + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_wired_to_mock_buckaroo_succeeds(client, builder): + assert isinstance(builder, TrustlyBuilder) + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_trustly(builder): + assert builder.get_service_name() == "Trustly" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +def test_get_allowed_service_parameters_pay_snapshot(builder): + assert builder.get_allowed_service_parameters("Pay") == { + "customerFirstName": { + "type": str, + "required": True, + "description": "Customer first name", + }, + "customerLastName": { + "type": str, + "required": True, + "description": "Customer last name", + }, + "customerCountryCode": { + "type": str, + "required": True, + "description": "Customer country code", + }, + "consumeremail": { + "type": str, + "required": True, + "description": "Customer email", + }, + } + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(builder): + """Covers the ``action.lower()`` branch for lowercase input.""" + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +def test_get_allowed_service_parameters_defaults_to_pay(builder): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + assert builder.get_allowed_service_parameters() == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "ExtraInfo", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(builder, action): + assert builder.get_allowed_service_parameters(action) == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_trustly_service_to_transaction_endpoint_and_parses_response( + client, mock_strategy +): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "trustly-key-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(TrustlyBuilder(client), amount=42.00) + .add_parameter("customerFirstName", "Alice") + .add_parameter("customerLastName", "Example") + .add_parameter("customerCountryCode", "NL") + .add_parameter("consumeremail", "alice@example.test") + .pay() + ) + + assert response.key == "trustly-key-1" diff --git a/tests/unit/builders/payments/test_twint_builder.py b/tests/unit/builders/payments/test_twint_builder.py new file mode 100644 index 0000000..5b75f10 --- /dev/null +++ b/tests/unit/builders/payments/test_twint_builder.py @@ -0,0 +1,114 @@ +"""Unit coverage for :class:`TwintBuilder`. + +Twint is a minimal payment builder: no capability mixins, empty allowed-service- +parameter map for ``Pay``, ``"Twint"`` service name. These tests pin that +surface and drive a single ``pay()`` round-trip through :class:`MockBuckaroo` +for end-to-end coverage of the inherited action path. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.twint_builder import TwintBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +@pytest.fixture +def builder(client: BuckarooClient) -> TwintBuilder: + return TwintBuilder(client) + + +def test_instantiates_as_payment_builder(builder: TwintBuilder) -> None: + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_twint(builder: TwintBuilder) -> None: + assert builder.get_service_name() == "Twint" + + +def test_get_allowed_service_parameters_pay_is_empty_dict( + builder: TwintBuilder, +) -> None: + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_default_action_matches_pay( + builder: TwintBuilder, +) -> None: + # The ``action`` parameter defaults to ``"Pay"``; snapshot the default + # branch so a future override can't silently drift from explicit ``"Pay"``. + assert builder.get_allowed_service_parameters() == {} + + +def test_get_allowed_service_parameters_pay_is_case_insensitive( + builder: TwintBuilder, +) -> None: + # ``action.lower() in ["pay"]`` branch — confirm lowercase matches. + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Authorize", "Capture", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty( + builder: TwintBuilder, action: str +) -> None: + # Exercises the fall-through ``return {}`` branch. + assert builder.get_allowed_service_parameters(action) == {} + + +def test_has_inherited_pay_action_method(builder: TwintBuilder) -> None: + # Twint mixes in no capability classes; only the inherited ``pay`` action + # is available. Pin presence + callability so a refactor of the base class + # that hides ``pay`` surfaces here. + assert hasattr(builder, "pay") + assert callable(builder.pay) + + +def test_does_not_mix_in_capability_only_methods(builder: TwintBuilder) -> None: + # Despite the docstring referencing "bank transfer capabilities", the + # class body opts out of every capability mixin. Pin that none of the + # capability-only methods leak onto the builder. + for method in ( + "authorize", + "authorizeEncrypted", + "cancelAuthorize", + "payEncrypted", + "instantRefund", + "payFastCheckout", + ): + assert not hasattr(builder, method), ( + f"TwintBuilder unexpectedly exposes capability method {method!r}" + ) + + +def test_pay_posts_transaction_through_mock_strategy( + builder: TwintBuilder, mock_strategy: MockBuckaroo +) -> None: + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "twint-key", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields( + builder, + currency="CHF", + amount=25.5, + description="Twint payment", + invoice="INV-TWINT-1", + return_url="https://example.test/return", + return_url_cancel="https://example.test/cancel", + return_url_error="https://example.test/error", + return_url_reject="https://example.test/reject", + ).pay() + + assert response.key == "twint-key" + assert response.status.code.code == 190 diff --git a/tests/unit/builders/payments/test_voucher_builder.py b/tests/unit/builders/payments/test_voucher_builder.py new file mode 100644 index 0000000..f869773 --- /dev/null +++ b/tests/unit/builders/payments/test_voucher_builder.py @@ -0,0 +1,83 @@ +"""Per-builder unit tests for :class:`VoucherBuilder`. + +Covers construction, the class-level ``_serviceName`` contract, both branches +of ``get_service_name()`` (payload-driven override + ``'Vouchers'`` default), +the ``get_allowed_service_parameters`` snapshot for ``Pay`` (with case +handling), non-Pay actions returning ``{}``, and a ``pay()`` round-trip +through :class:`MockBuckaroo`. Phase 7.36. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.voucher_builder import VoucherBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construct_with_buckaroo_client_returns_payment_builder(client): + assert isinstance(VoucherBuilder(client), PaymentBuilder) + + +def test_class_does_not_declare_service_name_attribute(): + """Unlike ``CreditcardBuilder``, ``VoucherBuilder`` does not set + ``_serviceName`` on the class — the service name is derived dynamically + from ``_payload['voucher_name']`` with a ``'Vouchers'`` default. + """ + assert "_serviceName" not in VoucherBuilder.__dict__ + + +def test_get_service_name_defaults_to_vouchers_when_payload_empty(client): + assert VoucherBuilder(client).get_service_name() == "Vouchers" + + +def test_get_service_name_reads_voucher_name_from_payload(client): + builder = VoucherBuilder(client).from_dict({"voucher_name": "CustomVoucher"}) + assert builder.get_service_name() == "CustomVoucher" + + +ARTICLE_SPEC = { + "article": {"type": list, "required": True, "description": "Articles"}, +} + + +def test_get_allowed_service_parameters_pay_snapshot(client): + assert VoucherBuilder(client).get_allowed_service_parameters("Pay") == ARTICLE_SPEC + + +def test_get_allowed_service_parameters_defaults_to_pay_branch(client): + """Default ``action='Pay'`` exercises the ``list`` branch without args.""" + assert VoucherBuilder(client).get_allowed_service_parameters() == ARTICLE_SPEC + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + """The source lower-cases ``action`` before comparison.""" + assert VoucherBuilder(client).get_allowed_service_parameters("pay") == ARTICLE_SPEC + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "Cancel", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert VoucherBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "voucher-key-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = ( + populate_required_fields(VoucherBuilder(client), amount=25.00) + .add_parameter( + "article", + [{"Identifier": "A-1", "Description": "Coffee", "Quantity": 1}], + ) + .pay() + ) + + assert response.key == "voucher-key-1" diff --git a/tests/unit/builders/payments/test_wechatpay_builder.py b/tests/unit/builders/payments/test_wechatpay_builder.py new file mode 100644 index 0000000..b231748 --- /dev/null +++ b/tests/unit/builders/payments/test_wechatpay_builder.py @@ -0,0 +1,112 @@ +"""Unit coverage for :class:`WeChatPayBuilder`. + +Phase 7.37 — per-builder coverage. WeChatPayBuilder is a minimal subclass of +:class:`PaymentBuilder`: it overrides :meth:`get_service_name` and +:meth:`get_allowed_service_parameters` only, mixes in no capability classes, +and declares no ``_serviceName`` class attribute. Tests pin the public +surface, the allowed-parameter shape for every action we care about, and +end-to-end ``pay()`` via :class:`MockBuckaroo`. +""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.wechatpay_builder import WeChatPayBuilder +from tests.support.builders import populate_required_fields +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import recorded_request, wire_recording_http + + +# --------------------------------------------------------------------------- +# Construction + + +def test_construction_with_client_succeeds(client): + builder = WeChatPayBuilder(client) + + assert isinstance(builder, WeChatPayBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_wechatpay_builder_does_not_declare_service_name_class_attribute(): + """WeChatPayBuilder relies on :meth:`get_service_name` — no ``_serviceName`` attr. + + Pin the current shape so a future refactor that introduces + ``_serviceName`` has to update the assertion consciously. + """ + assert "_serviceName" not in WeChatPayBuilder.__dict__ + + +# --------------------------------------------------------------------------- +# get_service_name + + +def test_get_service_name_returns_wechatpay(client): + builder = WeChatPayBuilder(client) + + assert builder.get_service_name() == "WeChatPay" + + +# --------------------------------------------------------------------------- +# get_allowed_service_parameters — snapshot every supported action + + +def test_get_allowed_service_parameters_pay_returns_empty_dict(client): + builder = WeChatPayBuilder(client) + + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + """The builder lowercases the action name before matching.""" + builder = WeChatPayBuilder(client) + + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize( + "action", + ["Refund", "Capture", "Authorize", "UnknownAction"], +) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + """Covers the fall-through ``return {}`` branch for any non-pay action.""" + builder = WeChatPayBuilder(client) + + assert builder.get_allowed_service_parameters(action) == {} + + +def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): + """Covers the ``action: str = "Pay"`` default-argument branch.""" + builder = WeChatPayBuilder(client) + + assert builder.get_allowed_service_parameters() == {} + + +# --------------------------------------------------------------------------- +# End-to-end pay via MockBuckaroo + + +def test_pay_posts_wechatpay_service_to_transaction_endpoint_and_parses_response(): + mock, client = wire_recording_http() + mock.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "WCP-1", "Status": {"Code": {"Code": 190}}}, + ) + ) + builder = populate_required_fields(WeChatPayBuilder(client), amount=12.34) + + response = builder.pay(validate=False) + + assert "/json/transaction" in mock.calls[0]["url"].lower() + sent = recorded_request(mock) + service = sent["Services"]["ServiceList"][0] + assert service["Name"] == "WeChatPay" + assert service["Action"] == "Pay" + assert response.key == "WCP-1" + mock.assert_all_consumed() diff --git a/tests/unit/builders/payments/test_wero_builder.py b/tests/unit/builders/payments/test_wero_builder.py new file mode 100644 index 0000000..e74fa3b --- /dev/null +++ b/tests/unit/builders/payments/test_wero_builder.py @@ -0,0 +1,51 @@ +"""Unit tests for :class:`WeroBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.payments.wero_builder import WeroBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = WeroBuilder(client) + assert isinstance(builder, WeroBuilder) + assert isinstance(builder, PaymentBuilder) + + +def test_get_service_name_returns_wero(client): + assert WeroBuilder(client).get_service_name() == "Wero" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = WeroBuilder(client) + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = WeroBuilder(client) + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert WeroBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_pay_posts_transaction_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "wero-key-123", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields(WeroBuilder(client), amount=25.00).pay() + + assert response.key == "wero-key-123" diff --git a/tests/unit/builders/solutions/__init__.py b/tests/unit/builders/solutions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/builders/solutions/test_concrete_solutions_contract.py b/tests/unit/builders/solutions/test_concrete_solutions_contract.py new file mode 100644 index 0000000..dc99b28 --- /dev/null +++ b/tests/unit/builders/solutions/test_concrete_solutions_contract.py @@ -0,0 +1,59 @@ +"""Parametrized contract tests over :data:`SolutionMethodFactory._solution_methods`. + +Mirrors the payments concrete-builder contract suite. Every registered solution +builder must: + +* instantiate with a :class:`BuckarooClient`, +* return a non-empty ``str`` from :meth:`get_service_name`, +* return a ``dict`` from :meth:`get_allowed_service_parameters` for the + canonical action of the method. + +The canonical action for each registered method is encoded in +``CANONICAL_ACTIONS`` below — `subscription` uses ``CreateSubscription``, which +matches :meth:`SubscriptionBuilder.createSubscription`. +""" + +from __future__ import annotations + +from typing import Dict, Type + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.factories.solution_method_factory import SolutionMethodFactory + + +# Canonical action per registered solution method. Keys must match +# ``SolutionMethodFactory._solution_methods``. +CANONICAL_ACTIONS: Dict[str, str] = { + "subscription": "CreateSubscription", +} + + +@pytest.mark.parametrize( + "method,builder_class", + sorted(SolutionMethodFactory._solution_methods.items()), +) +def test_solution_builder_contract( + method: str, + builder_class: Type[SolutionBuilder], + client: BuckarooClient, +) -> None: + """Each registered builder instantiates and exposes the contract surface.""" + builder = builder_class(client) + + assert isinstance(builder, SolutionBuilder) + + name = builder.get_service_name() + assert isinstance(name, str) + assert name, f"{builder_class.__name__}.get_service_name() returned empty string" + + action = CANONICAL_ACTIONS[method] + allowed = builder.get_allowed_service_parameters(action) + assert isinstance(allowed, dict) + + +def test_canonical_actions_cover_every_registered_method() -> None: + """Guard: keep ``CANONICAL_ACTIONS`` in sync with the registry.""" + assert set(CANONICAL_ACTIONS) == set(SolutionMethodFactory._solution_methods) diff --git a/tests/unit/builders/solutions/test_default_builder.py b/tests/unit/builders/solutions/test_default_builder.py new file mode 100644 index 0000000..622749b --- /dev/null +++ b/tests/unit/builders/solutions/test_default_builder.py @@ -0,0 +1,42 @@ +"""Unit tests for :class:`DefaultBuilder` (solutions). + +Targets 100% line + branch coverage of +``buckaroo/builders/solutions/default_builder.py``. The solutions +``DefaultBuilder`` is a catch-all that unknown solution-method lookups fall +back to via :class:`SolutionMethodFactory`. It has no ``_serviceName`` +class attribute, no capability mixins, and no solution-specific action +methods. The surface under test is: + +- construction via ``BuckarooClient`` wired to :class:`MockBuckaroo` +- ``get_service_name()`` reading ``method`` from the payload (and falling + back to ``"Unknown"`` when absent) +- ``get_allowed_service_parameters(action)`` returning ``{}`` for every + action - the catch-all has no required params +- end-to-end build + execute through the mock strategy +""" + +from __future__ import annotations + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.builders.solutions.default_builder import DefaultBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder + + +def test_construction_with_client_succeeds(client: BuckarooClient) -> None: + builder = DefaultBuilder(client) + assert isinstance(builder, DefaultBuilder) + assert isinstance(builder, SolutionBuilder) + + +def test_get_service_name_defaults_to_unknown(client: BuckarooClient) -> None: + assert DefaultBuilder(client).get_service_name() == "Unknown" + + +def test_get_service_name_reads_method_from_payload(client: BuckarooClient) -> None: + builder = DefaultBuilder(client).from_dict({"method": "CustomSolution"}) + assert builder.get_service_name() == "CustomSolution" + + +def test_get_allowed_service_parameters_returns_empty(client: BuckarooClient) -> None: + assert DefaultBuilder(client).get_allowed_service_parameters("Pay") == {} + assert DefaultBuilder(client).get_allowed_service_parameters("Refund") == {} diff --git a/tests/unit/builders/solutions/test_solution_builder.py b/tests/unit/builders/solutions/test_solution_builder.py new file mode 100644 index 0000000..374290e --- /dev/null +++ b/tests/unit/builders/solutions/test_solution_builder.py @@ -0,0 +1,131 @@ +"""Unit tests for :class:`SolutionBuilder`. + +Covers the shared :class:`BaseBuilder` fluent surface exposed via +``SolutionBuilder``, plus the solution-specific override of +``required_fields`` (solutions have none). +""" + +from __future__ import annotations + +from typing import Any, Dict + +import pytest + +from buckaroo.builders.solutions.solution_builder import SolutionBuilder + + +class _DummySolutionBuilder(SolutionBuilder): + """Minimal concrete :class:`SolutionBuilder` for tests.""" + + _serviceName = "dummysolution" + + def get_service_name(self) -> str: + return "DummySolution" + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + return {} + + +@pytest.fixture +def builder() -> _DummySolutionBuilder: + return _DummySolutionBuilder(client=object()) + + +def test_fluent_setters_return_self(builder: _DummySolutionBuilder) -> None: + assert builder.currency("EUR") is builder + assert builder.amount(12.5) is builder + assert builder.description("desc") is builder + assert builder.invoice("INV-1") is builder + assert builder.return_url("https://x/return") is builder + assert builder.return_url_cancel("https://x/cancel") is builder + assert builder.return_url_error("https://x/error") is builder + assert builder.return_url_reject("https://x/reject") is builder + assert builder.continue_on_incomplete("0") is builder + assert builder.push_url("https://x/push") is builder + assert builder.push_url_failure("https://x/push-fail") is builder + assert builder.client_ip("1.2.3.4") is builder + assert builder.add_parameter("foo", "bar") is builder + + +def test_required_fields_is_empty_for_solutions(builder: _DummySolutionBuilder) -> None: + assert builder.required_fields() == {} + assert builder.required_fields("CreateSubscription") == {} + + +def test_build_without_any_fields_succeeds_for_solution( + builder: _DummySolutionBuilder, +) -> None: + """Solutions have no required fields; ``build()`` must not raise.""" + request = builder.build("CreateSubscription", validate=False) + + payload = request.to_dict() + assert payload["Services"] == { + "ServiceList": [ + {"Name": "DummySolution", "Action": "CreateSubscription"}, + ] + } + # Solution fields default to None but stay in top-level shape + assert payload["Currency"] is None + assert payload["AmountDebit"] is None + assert payload["ContinueOnIncomplete"] == "1" + + +def test_from_dict_populates_same_fields_as_payment_builders( + builder: _DummySolutionBuilder, +) -> None: + data = { + "currency": "EUR", + "amount": 42.0, + "description": "subscription start", + "invoice": "INV-9", + "return_url": "https://x/return", + "return_url_cancel": "https://x/cancel", + "return_url_error": "https://x/error", + "return_url_reject": "https://x/reject", + "continue_on_incomplete": "0", + "push_url": "https://x/push", + "push_url_failure": "https://x/push-fail", + "client_ip": {"address": "9.9.9.9", "type": 1}, + "service_parameters": { + "flat": "1", + "grouped": {"sub": "value"}, + }, + } + + assert builder.from_dict(data) is builder + + request = builder.build("CreateSubscription", validate=False) + payload = request.to_dict() + + assert payload["Currency"] == "EUR" + assert payload["AmountDebit"] == 42.0 + assert payload["Description"] == "subscription start" + assert payload["Invoice"] == "INV-9" + assert payload["ReturnURL"] == "https://x/return" + assert payload["ReturnURLCancel"] == "https://x/cancel" + assert payload["ReturnURLError"] == "https://x/error" + assert payload["ReturnURLReject"] == "https://x/reject" + assert payload["ContinueOnIncomplete"] == "0" + assert payload["PushURL"] == "https://x/push" + assert payload["PushURLFailure"] == "https://x/push-fail" + assert payload["ClientIP"] == {"Type": 1, "Address": "9.9.9.9"} + + service = payload["Services"]["ServiceList"][0] + assert service["Name"] == "DummySolution" + assert service["Action"] == "CreateSubscription" + + param_names = {p["Name"]: p for p in service["Parameters"]} + assert param_names["Flat"]["Value"] == "1" + assert param_names["Flat"]["GroupType"] == "" + assert param_names["Sub"]["Value"] == "value" + assert param_names["Sub"]["GroupType"] == "Grouped" + + +def test_build_emits_parameters_when_added(builder: _DummySolutionBuilder) -> None: + builder.add_parameter("token", "abc123") + request = builder.build("CreateSubscription", validate=False) + + service = request.to_dict()["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + {"Name": "Token", "GroupType": "", "GroupID": "", "Value": "abc123"}, + ] diff --git a/tests/unit/builders/solutions/test_subscription_builder.py b/tests/unit/builders/solutions/test_subscription_builder.py new file mode 100644 index 0000000..a4fbc47 --- /dev/null +++ b/tests/unit/builders/solutions/test_subscription_builder.py @@ -0,0 +1,58 @@ +"""Unit tests for :class:`SubscriptionBuilder`.""" + +from __future__ import annotations + +import pytest + +from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from tests.support.mock_request import BuckarooMockRequest +from tests.support.builders import populate_required_fields + + +def test_construction_with_client_succeeds(client): + builder = SubscriptionBuilder(client) + assert isinstance(builder, SubscriptionBuilder) + assert isinstance(builder, SolutionBuilder) + + +def test_get_service_name_returns_subscription(client): + assert SubscriptionBuilder(client).get_service_name() == "Subscription" + + +def test_get_allowed_service_parameters_pay_snapshot(client): + builder = SubscriptionBuilder(client) + assert builder.get_allowed_service_parameters("Pay") == {} + + +def test_get_allowed_service_parameters_pay_is_case_insensitive(client): + builder = SubscriptionBuilder(client) + assert builder.get_allowed_service_parameters("pay") == ( + builder.get_allowed_service_parameters("Pay") + ) + + +def test_get_allowed_service_parameters_create_subscription_returns_empty(client): + builder = SubscriptionBuilder(client) + assert builder.get_allowed_service_parameters("CreateSubscription") == {} + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "Authorize", "UnknownAction"]) +def test_get_allowed_service_parameters_non_pay_returns_empty(client, action): + assert SubscriptionBuilder(client).get_allowed_service_parameters(action) == {} + + +def test_create_subscription_posts_and_parses_response(client, mock_strategy): + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/DataRequest*", + {"Key": "sub-key-456", "Status": {"Code": {"Code": 190}}}, + ) + ) + + response = populate_required_fields( + SubscriptionBuilder(client), amount=9.99 + ).createSubscription() + + assert response.key == "sub-key-456" diff --git a/tests/unit/builders/test_base_builder.py b/tests/unit/builders/test_base_builder.py new file mode 100644 index 0000000..e82d599 --- /dev/null +++ b/tests/unit/builders/test_base_builder.py @@ -0,0 +1,692 @@ +"""Tests for :class:`buckaroo.builders.base_builder.BaseBuilder`. + +Exercises the base builder directly via a tiny concrete subclass — no +coupling to any real payment method and, importantly, no inheritance +from :class:`PaymentBuilder` (which shadows nearly every ``BaseBuilder`` +method with an identical copy). Tests assert through the public API +(``PaymentRequest.to_dict()``, returned ``Parameter`` objects) rather +than private attributes. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional +from unittest.mock import MagicMock + +import pytest + +from buckaroo.builders.base_builder import BaseBuilder +from buckaroo.exceptions._parameter_validation_error import ( + ParameterValidationError, +) +from tests.support.builders import populate_required_fields + + +# --------------------------------------------------------------------------- +# Helpers: concrete BaseBuilder subclass with no PaymentBuilder in the MRO. + + +class _ConcreteBaseBuilder(BaseBuilder): + """Minimal concrete :class:`BaseBuilder` for testing its own code paths.""" + + def __init__( + self, + client, + *, + service_name: str = "dummy", + allowed_params: Optional[Dict[str, Dict[str, Any]]] = None, + ) -> None: + super().__init__(client) + self._service_name = service_name + self._allowed = allowed_params or {} + + def get_service_name(self) -> str: + return self._service_name + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + return self._allowed.get(action, {}) + + +def _core_allowed_params() -> dict: + """Allowed params used by happy-path builds. Shape mirrors real builders.""" + return { + "Pay": { + "issuer": {"type": str, "required": False}, + "description": {"type": str, "required": False}, + }, + "Refund": {}, + "Capture": {}, + "DummyAction": {}, + } + + +# ``_ConcreteBaseBuilder`` extends :class:`BaseBuilder` directly so these tests +# hit the base-class methods; ``make_test_builder`` returns a +# :class:`PaymentBuilder` subclass, which would shadow nearly every method with +# an identical copy and mask base-class coverage. +def _make_builder( + *, + service_name: str = "dummy", + allowed: Optional[Dict[str, Dict[str, Any]]] = None, + client=None, +) -> _ConcreteBaseBuilder: + return _ConcreteBaseBuilder( + client or MagicMock(), + service_name=service_name, + allowed_params=allowed if allowed is not None else _core_allowed_params(), + ) + + +# --------------------------------------------------------------------------- +# Fluent setters: return self and populate the built request + + +FLUENT_SETTERS = [ + ("currency", "EUR", "Currency"), + ("amount", 12.34, "AmountDebit"), + ("description", "payment description", "Description"), + ("invoice", "INV-42", "Invoice"), + ("return_url", "https://example.com/ok", "ReturnURL"), + ("return_url_cancel", "https://example.com/cancel", "ReturnURLCancel"), + ("return_url_error", "https://example.com/error", "ReturnURLError"), + ("return_url_reject", "https://example.com/reject", "ReturnURLReject"), +] + + +@pytest.mark.parametrize("setter,value,dict_key", FLUENT_SETTERS) +def test_fluent_setter_returns_self(setter, value, dict_key): + builder = _make_builder() + result = getattr(builder, setter)(value) + assert result is builder + + +@pytest.mark.parametrize("setter,value,dict_key", FLUENT_SETTERS) +def test_fluent_setter_is_reflected_in_built_request(setter, value, dict_key): + builder = populate_required_fields(_make_builder(), amount=10.50) + # Overwrite the one under test with the parametrized value. + getattr(builder, setter)(value) + request = builder.build(validate=False).to_dict() + assert request[dict_key] == value + + +def test_client_ip_setter_returns_self_and_appears_in_request(): + builder = populate_required_fields(_make_builder(), amount=10.50) + result = builder.client_ip("203.0.113.7", ip_type=1) + assert result is builder + + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 1, "Address": "203.0.113.7"} + + +def test_continue_on_incomplete_setter_returns_self_and_appears_in_request(): + builder = populate_required_fields(_make_builder(), amount=10.50) + assert builder.continue_on_incomplete("0") is builder + request = builder.build(validate=False).to_dict() + assert request["ContinueOnIncomplete"] == "0" + + +def test_push_url_setters_return_self_and_appear_in_request(): + builder = populate_required_fields(_make_builder(), amount=10.50) + assert builder.push_url("https://example.com/push") is builder + assert builder.push_url_failure("https://example.com/push-fail") is builder + request = builder.build(validate=False).to_dict() + assert request["PushURL"] == "https://example.com/push" + assert request["PushURLFailure"] == "https://example.com/push-fail" + + +# --------------------------------------------------------------------------- +# from_dict + + +def test_from_dict_populates_all_supported_core_fields_and_returns_self(): + builder = _make_builder() + data = { + "currency": "EUR", + "amount": 99.99, + "description": "hello", + "invoice": "INV-9", + "return_url": "https://example.com/ok", + "return_url_cancel": "https://example.com/cancel", + "return_url_error": "https://example.com/error", + "return_url_reject": "https://example.com/reject", + "continue_on_incomplete": "0", + "push_url": "https://example.com/push", + "push_url_failure": "https://example.com/push-fail", + "client_ip": "198.51.100.9", + } + + result = builder.from_dict(data) + assert result is builder + + request = builder.build(validate=False).to_dict() + assert request["Currency"] == "EUR" + assert request["AmountDebit"] == 99.99 + assert request["Description"] == "hello" + assert request["Invoice"] == "INV-9" + assert request["ReturnURL"] == "https://example.com/ok" + assert request["ReturnURLCancel"] == "https://example.com/cancel" + assert request["ReturnURLError"] == "https://example.com/error" + assert request["ReturnURLReject"] == "https://example.com/reject" + assert request["ContinueOnIncomplete"] == "0" + assert request["PushURL"] == "https://example.com/push" + assert request["PushURLFailure"] == "https://example.com/push-fail" + assert request["ClientIP"] == {"Type": 0, "Address": "198.51.100.9"} + + +def test_from_dict_client_ip_dict_form_uses_address_and_type(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"client_ip": {"address": "203.0.113.5", "type": 1}}) + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 1, "Address": "203.0.113.5"} + + +def test_from_dict_client_ip_dict_form_uses_defaults_when_keys_missing(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"client_ip": {}}) + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 0, "Address": "0.0.0.0"} + + +def test_from_dict_service_parameters_top_level_scalar(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"service_parameters": {"issuer": "INGBNL2A"}}) + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + {"Name": "Issuer", "GroupType": "", "GroupID": "", "Value": "INGBNL2A"} + ] + + +def test_from_dict_service_parameters_nested_dict_becomes_grouped_parameters(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"service_parameters": {"customer": {"firstName": "Jane"}}}) + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + { + "Name": "Firstname", + "GroupType": "Customer", + "GroupID": "", + "Value": "Jane", + } + ] + + +def test_from_dict_ignores_unknown_field_silently(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"unknown_field": "surprise", "another_mystery": 123}) + request = builder.build(validate=False).to_dict() + + assert request["Currency"] == "EUR" + serialized = str(request) + assert "unknown_field" not in serialized + assert "surprise" not in serialized + + +# --------------------------------------------------------------------------- +# add_parameter + + +def test_add_parameter_flat_capitalizes_name_and_stringifies_value(): + builder = populate_required_fields(_make_builder(), amount=10.50) + result = builder.add_parameter("issuer", "INGBNL2A") + assert result is builder + + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + { + "Name": "Issuer", + "GroupType": "", + "GroupID": "", + "Value": "INGBNL2A", + } + ] + + +def test_add_parameter_grouped_sets_group_type_and_group_id(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.add_parameter("firstName", "Jane", group_type="customer", group_id="7") + + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Parameters"] == [ + { + "Name": "Firstname", + "GroupType": "Customer", + "GroupID": "7", + "Value": "Jane", + } + ] + + +def test_add_parameter_boolean_values_are_lowercased_strings(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.add_parameter("enabled", True) + builder.add_parameter("disabled", False) + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + assert params[0]["Value"] == "true" + assert params[1]["Value"] == "false" + + +def test_add_parameter_twice_same_name_appends_both_entries(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.add_parameter("issuer", "first") + builder.add_parameter("issuer", "second") + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + # Current documented behavior: both entries are appended; no dedup. + assert len(params) == 2 + assert [p["Value"] for p in params] == ["first", "second"] + + +def test_add_parameter_with_list_of_dicts_adds_grouped_batch(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.add_parameter( + "articles", + [ + {"name": "Item A", "quantity": 2}, + {"name": "Item B", "quantity": 1}, + ], + ) + + request = builder.build(validate=False).to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + + assert params == [ + {"Name": "Name", "GroupType": "Articles", "GroupID": "1", "Value": "Item A"}, + {"Name": "Quantity", "GroupType": "Articles", "GroupID": "1", "Value": "2"}, + {"Name": "Name", "GroupType": "Articles", "GroupID": "2", "Value": "Item B"}, + {"Name": "Quantity", "GroupType": "Articles", "GroupID": "2", "Value": "1"}, + ] + + +def test_add_parameter_with_list_of_non_dicts_is_ignored(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.add_parameter("articles", ["not-a-dict", 42]) + + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert "Parameters" not in service + + +# --------------------------------------------------------------------------- +# _validate_and_filter_service_parameters + + +def test_validate_and_filter_drops_non_allowed_parameters(): + builder = populate_required_fields( + _make_builder(allowed={"Pay": {"issuer": {"type": str, "required": False}}}), + amount=10.50, + ) + builder.add_parameter("issuer", "INGBNL2A") + builder.add_parameter("rogue", "should-vanish") + + # build() runs the filter by default. + request = builder.build().to_dict() + params = request["Services"]["ServiceList"][0]["Parameters"] + + names = [p["Name"] for p in params] + assert "Issuer" in names + assert "Rogue" not in names + + +# --------------------------------------------------------------------------- +# build() — ClientIP defaulting and service parameters wiring + + +def test_build_defaults_client_ip_when_unset(): + builder = populate_required_fields(_make_builder(), amount=10.50) + request = builder.build(validate=False).to_dict() + assert request["ClientIP"] == {"Type": 0, "Address": "0.0.0.0"} + + +def test_build_without_service_parameters_omits_parameters_key(): + builder = populate_required_fields(_make_builder(), amount=10.50) + request = builder.build(validate=False).to_dict() + service = request["Services"]["ServiceList"][0] + assert service["Name"] == "dummy" + assert service["Action"] == "Pay" + assert "Parameters" not in service + + +def test_build_uses_requested_action_name(): + builder = populate_required_fields(_make_builder(), amount=10.50) + request = builder.build(action="Refund", validate=False).to_dict() + assert request["Services"]["ServiceList"][0]["Action"] == "Refund" + + +def test_build_raises_when_required_field_missing(): + builder = _make_builder().currency("EUR") # missing everything else + with pytest.raises(ValueError, match="Missing required fields"): + builder.build(validate=False) + + +# --------------------------------------------------------------------------- +# Validator convenience passthroughs + + +def test_is_parameter_allowed_delegates_to_validator(): + builder = _make_builder(allowed={"Pay": {"issuer": {"type": str, "required": False}}}) + assert builder.is_parameter_allowed("issuer", "Pay") is True + assert builder.is_parameter_allowed("nope", "Pay") is False + + +def test_get_parameter_info_returns_allowed_params_for_action(): + allowed = {"Pay": {"issuer": {"type": str, "required": False}}} + builder = _make_builder(allowed=allowed) + assert builder.get_parameter_info("Pay") == allowed["Pay"] + + +def test_get_normalized_parameter_name_returns_canonical_name(): + builder = _make_builder(allowed={"Pay": {"issuer": {"type": str, "required": False}}}) + assert builder.get_normalized_parameter_name("Issuer", "Pay") == "issuer" + assert builder.get_normalized_parameter_name("unknown", "Pay") == "" + + +# --------------------------------------------------------------------------- +# required_fields + + +def test_required_fields_reflects_current_setter_state(): + builder = _make_builder().currency("EUR").amount(5.0) + fields = builder.required_fields() + assert fields["currency"] == "EUR" + assert fields["amount_debit"] == 5.0 + assert fields["description"] is None + + +# --------------------------------------------------------------------------- +# pay / refund / capture / cancel / execute_action / partial_refund / +# _post_data_request / _post_transaction + + +class _StubResponse: + def __init__(self, data): + self._data = data + + def to_dict(self): + return self._data + + +class _StubHttp: + def __init__(self, response): + self.response = response + self.calls = [] + + def post(self, path, data): + self.calls.append((path, data)) + return self.response + + +class _StubClient: + def __init__(self, http_client): + self.http_client = http_client + + +def _client_returning(response_payload): + http = _StubHttp(_StubResponse(response_payload) if response_payload is not None else None) + return _StubClient(http), http + + +def test_pay_posts_to_transaction_and_returns_payment_response(): + client, http = _client_returning({"Status": {"Code": {"Code": 190}}}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + response = builder.pay() + + assert http.calls[0][0] == "/json/transaction" + assert response.to_dict()["Status"]["Code"]["Code"] == 190 + + +def test_post_transaction_returns_empty_response_when_strategy_returns_none(): + client, http = _client_returning(None) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + response = builder.pay() + + assert response.to_dict() == {} + + +def test_post_data_request_posts_to_data_request_path(): + client, http = _client_returning({"ok": True}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + request_data = builder.build(validate=False).to_dict() + response = builder._post_data_request(request_data) + + assert http.calls[0][0] == "/json/DataRequest" + assert response.to_dict() == {"ok": True} + + +def test_post_data_request_returns_empty_response_when_strategy_returns_none(): + client, http = _client_returning(None) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + response = builder._post_data_request({"anything": True}) + assert response.to_dict() == {} + + +def test_refund_requires_original_transaction_key(): + builder = populate_required_fields(_make_builder(), amount=10.50) + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.refund() + + +def test_refund_full_swaps_debit_to_credit_and_adds_transaction_key(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-123"}) + + builder.refund() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "TXN-123" + assert sent["AmountCredit"] == 10.50 + assert "AmountDebit" not in sent + + +def test_refund_partial_uses_refund_amount_and_removes_debit(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-9", "refund_amount": 3.25}) + + builder.refund() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "TXN-9" + assert sent["AmountCredit"] == 3.25 + assert "AmountDebit" not in sent + + +def test_capture_requires_authorization_key(): + builder = populate_required_fields(_make_builder(), amount=10.50) + with pytest.raises(ValueError, match="Authorization key is required"): + builder.capture() + + +def test_capture_uses_key_argument_and_sets_original_transaction_key(): + client, http = _client_returning({}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.capture(original_transaction_key="AUTH-1", amount=4.0) + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "AUTH-1" + assert sent["AmountDebit"] == 4.0 + + +def test_capture_reads_authorization_key_from_payload(): + client, http = _client_returning({}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"authorization_key": "AUTH-2", "capture_amount": 7.5}) + + builder.capture() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "AUTH-2" + assert sent["AmountDebit"] == 7.5 + + +def test_capture_reads_original_transaction_key_from_payload_fallback(): + client, http = _client_returning({}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "AUTH-3"}) + + builder.capture() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "AUTH-3" + + +def test_cancel_requires_transaction_key(): + builder = populate_required_fields(_make_builder(), amount=10.50) + with pytest.raises(ValueError, match="Transaction key is required"): + builder.cancel() + + +def test_cancel_removes_amounts_and_sets_transaction_key(): + client, http = _client_returning({}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.cancel(original_transaction_key="CANCEL-1") + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "CANCEL-1" + assert "AmountDebit" not in sent + assert "AmountCredit" not in sent + + +def test_cancel_reads_cancel_key_from_payload(): + client, http = _client_returning({}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"cancel_key": "CANCEL-2"}) + + builder.cancel() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "CANCEL-2" + + +def test_cancel_reads_original_transaction_key_from_payload_fallback(): + client, http = _client_returning({}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "CANCEL-3"}) + + builder.cancel() + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "CANCEL-3" + + +def test_partial_refund_raises_when_amount_missing_or_non_positive(): + builder = populate_required_fields(_make_builder(), amount=10.50) + with pytest.raises(ValueError, match="Partial refund amount must be greater than 0"): + builder.partial_refund() + + builder2 = populate_required_fields(_make_builder(), amount=10.50) + with pytest.raises(ValueError, match="Partial refund amount must be greater than 0"): + builder2.partial_refund(amount=0) + + +def test_partial_refund_uses_explicit_original_transaction_key_argument(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.partial_refund(original_transaction_key="TXN-1", amount=2.5) + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "TXN-1" + assert sent["AmountCredit"] == 2.5 + assert "AmountDebit" not in sent + + +def test_partial_refund_reads_original_transaction_key_from_payload(): + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-2"}) + + builder.partial_refund(amount=3.75) + + _, sent = http.calls[0] + assert sent["OriginalTransactionKey"] == "TXN-2" + assert sent["AmountCredit"] == 3.75 + assert "AmountDebit" not in sent + + +def test_partial_refund_does_not_leak_state_into_subsequent_full_refund(): + """A partial refund must not silently turn a later full refund into a partial.""" + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + builder.partial_refund(original_transaction_key="TXN-A", amount=2.5) + builder.from_dict({"original_transaction_key": "TXN-B"}) + builder.refund() + + _, partial_sent = http.calls[0] + _, full_sent = http.calls[1] + assert partial_sent["AmountCredit"] == 2.5 + assert full_sent["OriginalTransactionKey"] == "TXN-B" + assert full_sent["AmountCredit"] == 10.50 + + +def test_partial_refund_restores_pre_existing_payload_keys(): + """Pre-existing payload values for the stashed keys survive partial_refund.""" + client, http = _client_returning({"Status": "ok"}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + builder.from_dict({"original_transaction_key": "TXN-orig", "refund_amount": 9.99}) + + builder.partial_refund(original_transaction_key="TXN-tmp", amount=2.5) + + assert builder._payload["original_transaction_key"] == "TXN-orig" + assert builder._payload["refund_amount"] == 9.99 + + +def test_execute_action_posts_with_requested_action(): + client, http = _client_returning({"done": True}) + builder = populate_required_fields(_make_builder(client=client), amount=10.50) + + response = builder.execute_action("DummyAction", validate=False) + + path, sent = http.calls[0] + assert path == "/json/transaction" + assert sent["Services"]["ServiceList"][0]["Action"] == "DummyAction" + assert response.to_dict() == {"done": True} + + +def test_from_dict_ignores_client_ip_of_unsupported_type(): + builder = populate_required_fields(_make_builder(), amount=10.50) + builder.from_dict({"client_ip": 12345}) + request = builder.build(validate=False).to_dict() + # Falls through to PaymentRequest's default. + assert request["ClientIP"] == {"Type": 0, "Address": "0.0.0.0"} + + +def test_build_with_strict_validation_raises_on_unknown_parameter(): + builder = populate_required_fields( + _make_builder(allowed={"Pay": {"issuer": {"type": str, "required": False}}}), + amount=10.50, + ) + builder.add_parameter("rogue", "x") + + with pytest.raises(ParameterValidationError): + builder.build(strict_validation=True) + + +# --------------------------------------------------------------------------- +# Abstract-stub contract. These methods have no default behavior; a subclass +# that forgets to override them must fail loudly rather than return ``None``. + + +def test_get_service_name_stub_raises_not_implemented(): + builder = _make_builder() + with pytest.raises(NotImplementedError): + BaseBuilder.get_service_name(builder) + + +def test_get_allowed_service_parameters_stub_raises_not_implemented(): + builder = _make_builder() + with pytest.raises(NotImplementedError): + BaseBuilder.get_allowed_service_parameters(builder) diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/config/test_buckaroo_config.py b/tests/unit/config/test_buckaroo_config.py new file mode 100644 index 0000000..216a2a8 --- /dev/null +++ b/tests/unit/config/test_buckaroo_config.py @@ -0,0 +1,341 @@ +"""Tests for buckaroo.config.buckaroo_config.""" + +import pytest + +from buckaroo.config.buckaroo_config import ( + ApiVersion, + BuckarooConfig, + ConfigBuilder, + DefaultConfig, + Environment, + ProductionConfig, + TestConfig as _TestConfig, + create_config_from_mode, + create_production_config, + create_test_config, +) + + +# --- Enums --- + + +def test_environment_values(): + assert Environment.TEST.value == "test" + assert Environment.LIVE.value == "live" + assert Environment("test") is Environment.TEST + assert Environment("live") is Environment.LIVE + + +def test_api_version_values(): + assert ApiVersion.V1.value == "v1" + assert ApiVersion.V2.value == "v2" + + +# --- BuckarooConfig defaults & validation --- + + +def test_defaults(): + cfg = BuckarooConfig() + assert cfg.environment is Environment.TEST + assert cfg.api_version is ApiVersion.V1 + assert cfg.timeout == 30 + assert cfg.retry_attempts == 3 + assert cfg.retry_delay == 1.0 + assert cfg.logging_enabled is True + assert cfg.verify_ssl is True + assert cfg.custom_endpoint is None + assert cfg.user_agent == "BuckarooSDK-Python/1.0.0" + assert cfg.max_redirects == 5 + + +@pytest.mark.parametrize( + "kwargs,msg", + [ + ({"timeout": 0}, "Timeout must be greater than 0"), + ({"timeout": -1}, "Timeout must be greater than 0"), + ({"retry_attempts": -1}, "Retry attempts must be 0 or greater"), + ({"retry_delay": -0.1}, "Retry delay must be 0 or greater"), + ({"max_redirects": -1}, "Max redirects must be 0 or greater"), + ], +) +def test_validation_errors(kwargs, msg): + with pytest.raises(ValueError, match=msg): + BuckarooConfig(**kwargs) + + +# --- api_endpoint --- + + +@pytest.mark.parametrize( + "env,host", + [ + (Environment.TEST, "testcheckout.buckaroo.nl"), + (Environment.LIVE, "checkout.buckaroo.nl"), + ], +) +def test_api_endpoint_switches_on_environment(env, host): + cfg = BuckarooConfig(environment=env) + assert cfg.api_endpoint.startswith("https://") + assert host in cfg.api_endpoint + + +def test_custom_endpoint_overrides(): + cfg = BuckarooConfig(custom_endpoint="https://example.test") + assert cfg.api_endpoint == "https://example.test" + + +def test_is_test_and_is_live_flags(): + t = BuckarooConfig(environment=Environment.TEST) + assert t.is_test_environment is True + assert t.is_live_environment is False + live = BuckarooConfig(environment=Environment.LIVE) + assert live.is_test_environment is False + assert live.is_live_environment is True + + +# --- Headers --- + + +def test_get_request_headers_keys_and_values(): + cfg = BuckarooConfig(user_agent="UA/1") + headers = cfg.get_request_headers() + assert headers == { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "UA/1", + } + + +# --- to_dict / from_dict --- + + +def test_to_dict_contains_documented_keys(): + cfg = BuckarooConfig() + d = cfg.to_dict() + for key in ( + "environment", + "api_version", + "api_endpoint", + "timeout", + "retry_attempts", + "retry_delay", + "logging_enabled", + "verify_ssl", + "custom_endpoint", + "user_agent", + "max_redirects", + "is_test", + "is_live", + ): + assert key in d + + +def test_from_dict_round_trip_preserves_fields(): + original = BuckarooConfig( + environment=Environment.LIVE, + api_version=ApiVersion.V2, + timeout=45, + retry_attempts=7, + retry_delay=2.5, + logging_enabled=False, + verify_ssl=False, + custom_endpoint="https://x.test", + user_agent="UA/9", + max_redirects=9, + ) + restored = BuckarooConfig.from_dict(original.to_dict()) + assert restored.environment is Environment.LIVE + assert restored.api_version is ApiVersion.V2 + assert restored.timeout == 45 + assert restored.retry_attempts == 7 + assert restored.retry_delay == 2.5 + assert restored.logging_enabled is False + assert restored.verify_ssl is False + assert restored.custom_endpoint == "https://x.test" + assert restored.user_agent == "UA/9" + assert restored.max_redirects == 9 + + +def test_from_dict_accepts_enum_instances_and_ignores_extras(): + cfg = BuckarooConfig.from_dict( + { + "environment": Environment.LIVE, + "api_version": ApiVersion.V2, + "timeout": 12, + "bogus_key": "ignored", + } + ) + assert cfg.environment is Environment.LIVE + assert cfg.api_version is ApiVersion.V2 + assert cfg.timeout == 12 + + +def test_copy_applies_changes(): + cfg = BuckarooConfig() + copied = cfg.copy(timeout=99, environment=Environment.LIVE) + assert copied.timeout == 99 + assert copied.environment is Environment.LIVE + # original untouched + assert cfg.timeout == 30 + assert cfg.environment is Environment.TEST + + +# --- Presets --- + + +def test_default_config_matches_base_defaults(): + d = DefaultConfig() + assert d.environment is Environment.TEST + assert d.timeout == 30 + assert d.retry_attempts == 3 + + +def test_test_config_preset(): + t = _TestConfig() + assert t.environment is Environment.TEST + assert t.timeout == 10 + assert t.retry_attempts == 1 + assert t.retry_delay == 0.5 + assert t.logging_enabled is False + + +def test_production_config_preset(): + p = ProductionConfig() + assert p.environment is Environment.LIVE + assert p.timeout == 60 + assert p.retry_attempts == 5 + assert p.retry_delay == 2.0 + assert p.logging_enabled is True + assert p.verify_ssl is True + + +# --- ConfigBuilder --- + + +def test_config_builder_fluent_chain(): + cfg = ( + ConfigBuilder() + .environment(Environment.LIVE) + .api_version(ApiVersion.V2) + .timeout(45) + .retry_attempts(4) + .retry_delay(1.5) + .enable_logging() + .enable_ssl_verification() + .custom_endpoint("https://custom.test") + .user_agent("Agent/2") + .max_redirects(7) + .build() + ) + assert cfg.environment is Environment.LIVE + assert cfg.api_version is ApiVersion.V2 + assert cfg.timeout == 45 + assert cfg.retry_attempts == 4 + assert cfg.retry_delay == 1.5 + assert cfg.logging_enabled is True + assert cfg.verify_ssl is True + assert cfg.custom_endpoint == "https://custom.test" + assert cfg.user_agent == "Agent/2" + assert cfg.max_redirects == 7 + + +def test_config_builder_test_environment_shortcut(): + cfg = ConfigBuilder().test_environment().build() + assert cfg.environment is Environment.TEST + + +def test_config_builder_live_environment_shortcut(): + cfg = ConfigBuilder().live_environment().build() + assert cfg.environment is Environment.LIVE + + +def test_config_builder_disable_toggles(): + cfg = ConfigBuilder().disable_logging().disable_ssl_verification().build() + assert cfg.logging_enabled is False + assert cfg.verify_ssl is False + + +def test_config_builder_empty_build_yields_defaults(): + cfg = ConfigBuilder().build() + assert cfg.environment is Environment.TEST + assert cfg.timeout == 30 + + +# --- Mode helpers --- + + +def test_create_test_config_no_kwargs(): + cfg = create_test_config() + assert cfg.environment is Environment.TEST + assert cfg.timeout == 10 + + +def test_create_production_config_no_kwargs(): + cfg = create_production_config() + assert cfg.environment is Environment.LIVE + assert cfg.timeout == 60 + + +def test_create_test_config_with_kwargs_overrides_preset(): + cfg = create_test_config(timeout=25, retry_attempts=4) + assert isinstance(cfg, BuckarooConfig) + assert cfg.environment is Environment.TEST + assert cfg.timeout == 25 + assert cfg.retry_attempts == 4 + # Preset values not overridden remain intact. + assert cfg.retry_delay == 0.5 + assert cfg.logging_enabled is False + + +def test_create_production_config_with_kwargs_overrides_preset(): + cfg = create_production_config(timeout=120, retry_attempts=2) + assert isinstance(cfg, BuckarooConfig) + assert cfg.environment is Environment.LIVE + assert cfg.timeout == 120 + assert cfg.retry_attempts == 2 + assert cfg.retry_delay == 2.0 + assert cfg.verify_ssl is True + + +def test_test_config_accepts_overrides_directly(): + t = _TestConfig(timeout=15, retry_attempts=2) + assert t.environment is Environment.TEST + assert t.timeout == 15 + assert t.retry_attempts == 2 + + +def test_production_config_accepts_overrides_directly(): + p = ProductionConfig(timeout=90) + assert p.environment is Environment.LIVE + assert p.timeout == 90 + + +def test_test_config_environment_is_locked(): + # Even if a caller tries to override the environment, presets stay locked. + t = _TestConfig(environment=Environment.LIVE) + assert t.environment is Environment.TEST + + +def test_production_config_environment_is_locked(): + p = ProductionConfig(environment=Environment.TEST) + assert p.environment is Environment.LIVE + + +@pytest.mark.parametrize( + "mode,expected_env,host", + [ + ("test", Environment.TEST, "testcheckout.buckaroo.nl"), + ("TEST", Environment.TEST, "testcheckout.buckaroo.nl"), + ("live", Environment.LIVE, "checkout.buckaroo.nl"), + ("LIVE", Environment.LIVE, "checkout.buckaroo.nl"), + ], +) +def test_create_config_from_mode_valid(mode, expected_env, host): + cfg = create_config_from_mode(mode) + assert cfg.environment is expected_env + assert host in cfg.api_endpoint + + +def test_create_config_from_mode_invalid_raises(): + with pytest.raises(ValueError, match="Invalid mode"): + create_config_from_mode("invalid") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..198fdae --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,42 @@ +"""Shared fixtures for unit tests.""" + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from tests.support.mock_buckaroo import MockBuckaroo + + +@pytest.fixture +def client(mock_strategy: MockBuckaroo) -> BuckarooClient: + """BuckarooClient wired to ``mock_strategy`` — no real HTTP.""" + c = BuckarooClient("store_key", "secret_key", mode="test") + c.http_client.http_strategy = mock_strategy + return c + + +_BUCKAROO_ENV_VARS = ( + "BUCKAROO_STORE_KEY", + "BUCKAROO_SECRET_KEY", + "BUCKAROO_MODE", + "BUCKAROO_LOG_LEVEL", + "BUCKAROO_LOG_DESTINATION", + "BUCKAROO_LOG_FILE", + "BUCKAROO_LOG_MASK_SENSITIVE", + "BUCKAROO_TIMEOUT", + "BUCKAROO_RETRY_ATTEMPTS", +) + + +@pytest.fixture(autouse=True) +def _clean_buckaroo_env(monkeypatch): + """Start every test with a clean BUCKAROO_* environment.""" + for name in _BUCKAROO_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def env_credentials(monkeypatch): + """Set default BUCKAROO_STORE_KEY / BUCKAROO_SECRET_KEY and return monkeypatch for chaining.""" + monkeypatch.setenv("BUCKAROO_STORE_KEY", "sk") + monkeypatch.setenv("BUCKAROO_SECRET_KEY", "ss") + return monkeypatch diff --git a/tests/unit/exceptions/__init__.py b/tests/unit/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/exceptions/test__authentication_error.py b/tests/unit/exceptions/test__authentication_error.py new file mode 100644 index 0000000..4947f0d --- /dev/null +++ b/tests/unit/exceptions/test__authentication_error.py @@ -0,0 +1,23 @@ +import pytest + +from buckaroo.exceptions._authentication_error import AuthenticationError +from buckaroo.exceptions._buckaroo_error import BuckarooError + + +def test_is_subclass_of_buckaroo_error(): + assert issubclass(AuthenticationError, BuckarooError) + + +def test_caught_by_buckaroo_error_except_block(): + try: + raise AuthenticationError("bad creds") + except BuckarooError as caught: + assert isinstance(caught, AuthenticationError) + else: + pytest.fail("AuthenticationError was not caught by except BuckarooError") + + +def test_constructor_args_round_trip(): + err = AuthenticationError("invalid signature", 401) + assert err.args == ("invalid signature", 401) + assert str(err) == str(BuckarooError("invalid signature", 401)) diff --git a/tests/unit/exceptions/test__buckaroo_error.py b/tests/unit/exceptions/test__buckaroo_error.py new file mode 100644 index 0000000..d04bee2 --- /dev/null +++ b/tests/unit/exceptions/test__buckaroo_error.py @@ -0,0 +1,34 @@ +"""Tests for buckaroo.exceptions._buckaroo_error.BuckarooError.""" + +import pytest + +from buckaroo.exceptions._buckaroo_error import BuckarooError + + +def test_is_subclass_of_exception(): + assert issubclass(BuckarooError, Exception) + + +def test_can_be_raised_and_caught(): + with pytest.raises(BuckarooError): + raise BuckarooError("boom") + + +def test_message_only_round_trip(): + err = BuckarooError("something went wrong") + + assert err.args == ("something went wrong",) + assert str(err) == "something went wrong" + + +def test_no_args_round_trip(): + err = BuckarooError() + + assert err.args == () + assert str(err) == "" + + +def test_positional_http_status_round_trip(): + err = BuckarooError("server exploded", 500) + + assert err.args == ("server exploded", 500) diff --git a/tests/unit/exceptions/test__parameter_validation_error.py b/tests/unit/exceptions/test__parameter_validation_error.py new file mode 100644 index 0000000..6b35b8f --- /dev/null +++ b/tests/unit/exceptions/test__parameter_validation_error.py @@ -0,0 +1,93 @@ +import pytest + +from buckaroo.exceptions._buckaroo_error import BuckarooError +from buckaroo.exceptions._parameter_validation_error import ( + ParameterValidationError, + RequiredParameterMissingError, +) + + +def test_parameter_validation_error_is_subclass_of_buckaroo_error(): + assert issubclass(ParameterValidationError, BuckarooError) + + +def test_required_parameter_missing_error_is_subclass_of_parameter_validation_error(): + assert issubclass(RequiredParameterMissingError, ParameterValidationError) + + +def test_required_parameter_missing_error_is_subclass_of_buckaroo_error(): + assert issubclass(RequiredParameterMissingError, BuckarooError) + + +def test_parameter_validation_error_message_only(): + err = ParameterValidationError("bad parameter") + assert str(err) == "bad parameter" + assert err.parameter_name is None + assert err.expected_type is None + assert err.action is None + assert err.service_name is None + + +def test_parameter_validation_error_all_fields(): + err = ParameterValidationError( + "bad parameter", + parameter_name="issuer", + expected_type="str", + action="Pay", + service_name="ideal", + ) + assert str(err) == "bad parameter" + assert err.parameter_name == "issuer" + assert err.expected_type == "str" + assert err.action == "Pay" + assert err.service_name == "ideal" + + +def test_parameter_validation_error_caught_as_buckaroo_error(): + try: + raise ParameterValidationError("boom") + except BuckarooError as caught: + assert isinstance(caught, ParameterValidationError) + else: + pytest.fail("ParameterValidationError was not caught by except BuckarooError") + + +def test_required_parameter_missing_error_minimal(): + err = RequiredParameterMissingError("issuer") + assert err.parameter_name == "issuer" + assert err.action is None + assert err.service_name is None + assert str(err) == "Required parameter 'issuer' is missing" + + +def test_required_parameter_missing_error_with_service_and_action(): + err = RequiredParameterMissingError("issuer", action="Pay", service_name="ideal") + msg = str(err) + assert "issuer" in msg + assert "ideal" in msg + assert "Pay" in msg + assert msg == "Required parameter 'issuer' is missing for ideal Pay action" + assert err.parameter_name == "issuer" + assert err.action == "Pay" + assert err.service_name == "ideal" + + +def test_required_parameter_missing_error_with_only_action(): + err = RequiredParameterMissingError("issuer", action="Pay") + assert str(err) == "Required parameter 'issuer' is missing for Pay action" + + +def test_required_parameter_missing_error_with_only_service(): + err = RequiredParameterMissingError("issuer", service_name="ideal") + assert str(err) == "Required parameter 'issuer' is missing for ideal" + + +def test_required_parameter_missing_error_caught_as_parameter_validation_error(): + try: + raise RequiredParameterMissingError("issuer", action="Pay", service_name="ideal") + except ParameterValidationError as caught: + assert isinstance(caught, RequiredParameterMissingError) + else: + pytest.fail( + "RequiredParameterMissingError was not caught by except ParameterValidationError" + ) diff --git a/tests/unit/factories/__init__.py b/tests/unit/factories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/factories/test_builder_factory.py b/tests/unit/factories/test_builder_factory.py new file mode 100644 index 0000000..f761597 --- /dev/null +++ b/tests/unit/factories/test_builder_factory.py @@ -0,0 +1,91 @@ +import pytest + +from buckaroo.factories.builder_factory import BuilderFactory + + +ABSTRACT_METHODS = ( + "create_builder", + "register_method", + "get_available_methods", + "is_method_supported", + "detect_method_from_payload", +) + + +def test_cannot_instantiate_abstract_base(): + with pytest.raises(TypeError): + BuilderFactory() + + +def test_declares_expected_abstract_methods(): + assert BuilderFactory.__abstractmethods__ == frozenset(ABSTRACT_METHODS) + + +@pytest.mark.parametrize("name", ABSTRACT_METHODS) +def test_method_is_abstract_classmethod(name): + attr = BuilderFactory.__dict__[name] + assert isinstance(attr, classmethod), f"{name} must be a classmethod" + assert getattr(attr.__func__, "__isabstractmethod__", False), ( + f"{name} must be marked @abstractmethod" + ) + + +# super() delegation on every override intentional: it executes the ABC's +# `pass` bodies, keeping them covered without pragmas. +class _FullFactory(BuilderFactory): + _registry = {} + + @classmethod + def create_builder(cls, method, client): + super().create_builder(method, client) + return ("builder", method, client) + + @classmethod + def register_method(cls, method, builder_class): + super().register_method(method, builder_class) + cls._registry[method] = builder_class + + @classmethod + def get_available_methods(cls): + super().get_available_methods() + return list(cls._registry) + + @classmethod + def is_method_supported(cls, method): + super().is_method_supported(method) + return method in cls._registry + + @classmethod + def detect_method_from_payload(cls, payload): + super().detect_method_from_payload(payload) + return payload["method"] + + +def test_concrete_subclass_instantiates_and_methods_callable(): + factory = _FullFactory() + assert isinstance(factory, BuilderFactory) + + _FullFactory.register_method("ideal", object) + assert _FullFactory.is_method_supported("ideal") is True + assert _FullFactory.is_method_supported("missing") is False + assert _FullFactory.get_available_methods() == ["ideal"] + assert _FullFactory.create_builder("ideal", "client-sentinel") == ( + "builder", + "ideal", + "client-sentinel", + ) + assert _FullFactory.detect_method_from_payload({"method": "ideal"}) == "ideal" + + +@pytest.mark.parametrize("missing", ABSTRACT_METHODS) +def test_partial_subclass_missing_one_method_raises(missing): + attrs = { + name: classmethod(lambda cls, *a, **kw: None) + for name in ABSTRACT_METHODS + if name != missing + } + Partial = type("Partial", (BuilderFactory,), attrs) + + with pytest.raises(TypeError) as excinfo: + Partial() + assert missing in str(excinfo.value) diff --git a/tests/unit/factories/test_payment_method_factory.py b/tests/unit/factories/test_payment_method_factory.py new file mode 100644 index 0000000..861541c --- /dev/null +++ b/tests/unit/factories/test_payment_method_factory.py @@ -0,0 +1,151 @@ +import logging + +import pytest + +from buckaroo.builders.payments.default_builder import DefaultBuilder +from buckaroo.builders.payments.ideal_builder import IdealBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.factories.payment_method_factory import PaymentMethodFactory + + +@pytest.fixture(autouse=True) +def _registry_snapshot(): + snapshot = dict(PaymentMethodFactory._payment_methods) + try: + yield snapshot + finally: + PaymentMethodFactory._payment_methods.clear() + PaymentMethodFactory._payment_methods.update(snapshot) + + +@pytest.fixture +def client(): + return object() + + +# Tripwire: all registry keys must be lowercase so create_builder() can find them. +def test_all_registry_keys_are_lowercase(): + non_lowercase = {k for k in PaymentMethodFactory._payment_methods if k != k.lower()} + assert non_lowercase == set() + + +@pytest.mark.parametrize( + "method, builder_class", + list(PaymentMethodFactory._payment_methods.items()), +) +def test_create_builder_returns_registered_class_instance(method, builder_class, client): + builder = PaymentMethodFactory.create_builder(method, client) + assert isinstance(builder, builder_class) + + +@pytest.mark.parametrize( + "method, builder_class", + list(PaymentMethodFactory._payment_methods.items()), +) +def test_create_builder_returns_payment_builder_subclass(method, builder_class, client): + builder = PaymentMethodFactory.create_builder(method, client) + assert isinstance(builder, PaymentBuilder) + + +@pytest.mark.parametrize("method", list(PaymentMethodFactory._payment_methods.keys())) +def test_is_method_supported_true_for_every_registered_method(method): + assert PaymentMethodFactory.is_method_supported(method) is True + + +def test_get_available_methods_lists_every_registry_key(): + available = PaymentMethodFactory.get_available_methods() + assert isinstance(available, list) + assert set(available) == set(PaymentMethodFactory._payment_methods.keys()) + + +def test_create_builder_is_case_insensitive(client): + builder = PaymentMethodFactory.create_builder("IDEAL", client) + assert isinstance(builder, IdealBuilder) + + +def test_create_builder_unknown_falls_back_to_default_and_warns(client, caplog): + with caplog.at_level(logging.WARNING): + builder = PaymentMethodFactory.create_builder("unknown", client) + + assert isinstance(builder, DefaultBuilder) + assert any( + "Unsupported payment method" in r.getMessage() and "unknown" in r.getMessage() + for r in caplog.records + ) + + +def test_is_method_supported_false_for_unknown(): + assert PaymentMethodFactory.is_method_supported("XXX") is False + + +class _CustomBuilder(PaymentBuilder): + def get_service_name(self): + return "custom" + + def get_allowed_service_parameters(self, action="Pay"): + return {} + + +def test_register_method_adds_new_entry(client): + PaymentMethodFactory.register_method("custom", _CustomBuilder) + assert PaymentMethodFactory.is_method_supported("custom") is True + builder = PaymentMethodFactory.create_builder("custom", client) + assert isinstance(builder, _CustomBuilder) + + +def test_register_method_overrides_existing_entry(client): + PaymentMethodFactory.register_method("ideal", _CustomBuilder) + builder = PaymentMethodFactory.create_builder("ideal", client) + assert isinstance(builder, _CustomBuilder) + + +def test_register_method_lowercases_key(client): + PaymentMethodFactory.register_method("MiXeD", _CustomBuilder) + assert PaymentMethodFactory.is_method_supported("mixed") is True + assert isinstance(PaymentMethodFactory.create_builder("MIXED", client), _CustomBuilder) + + +def test_detect_from_explicit_method_field(): + assert PaymentMethodFactory.detect_method_from_payload({"method": "ideal"}) == "ideal" + + +def test_detect_from_method_field_is_lowercased(): + assert PaymentMethodFactory.detect_method_from_payload({"method": "IDEAL"}) == "ideal" + + +def test_detect_from_service_list_single_registered(): + payload = {"Services": {"ServiceList": [{"Name": "creditcard"}]}} + assert PaymentMethodFactory.detect_method_from_payload(payload) == "creditcard" + + +def test_detect_from_service_list_skips_unknown_and_picks_first_registered(): + payload = { + "Services": { + "ServiceList": [{"Name": "unknown"}, {"Name": "ideal"}], + } + } + assert PaymentMethodFactory.detect_method_from_payload(payload) == "ideal" + + +def test_detect_method_field_beats_service_list(): + payload = { + "method": "ideal", + "Services": {"ServiceList": [{"Name": "creditcard"}]}, + } + assert PaymentMethodFactory.detect_method_from_payload(payload) == "ideal" + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"Services": {"ServiceList": []}}, + {"Services": {"ServiceList": [{"Name": "unknown"}]}}, + ], + ids=["empty", "empty_service_list", "only_unknown_service"], +) +def test_detect_unresolvable_payload_returns_default_and_warns(payload, caplog): + with caplog.at_level(logging.WARNING): + result = PaymentMethodFactory.detect_method_from_payload(payload) + assert result == "default" + assert any("Cannot determine payment method" in r.getMessage() for r in caplog.records) diff --git a/tests/unit/factories/test_solution_method_factory.py b/tests/unit/factories/test_solution_method_factory.py new file mode 100644 index 0000000..552500a --- /dev/null +++ b/tests/unit/factories/test_solution_method_factory.py @@ -0,0 +1,116 @@ +import logging + +import pytest + +from buckaroo.factories.solution_method_factory import SolutionMethodFactory +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder +from buckaroo.builders.solutions.default_builder import DefaultBuilder + + +@pytest.fixture(autouse=True) +def _snapshot_registry(): + snapshot = SolutionMethodFactory._solution_methods.copy() + try: + yield snapshot + finally: + SolutionMethodFactory._solution_methods.clear() + SolutionMethodFactory._solution_methods.update(snapshot) + + +@pytest.fixture +def client(): + return object() + + +@pytest.mark.parametrize( + "method,builder_class", + list(SolutionMethodFactory._solution_methods.items()), +) +def test_create_builder_returns_registered_class(method, builder_class, client): + builder = SolutionMethodFactory.create_builder(method, client) + assert isinstance(builder, builder_class) + assert isinstance(builder, SolutionBuilder) + + +@pytest.mark.parametrize( + "method", + list(SolutionMethodFactory._solution_methods.keys()), +) +def test_is_method_supported_true_for_registered(method): + assert SolutionMethodFactory.is_method_supported(method) is True + + +def test_get_available_methods_lists_all_registered_keys(): + assert set(SolutionMethodFactory.get_available_methods()) == set( + SolutionMethodFactory._solution_methods.keys() + ) + + +def test_create_builder_is_case_insensitive(client): + builder = SolutionMethodFactory.create_builder("SUBSCRIPTION", client) + assert isinstance(builder, SubscriptionBuilder) + + +def test_create_builder_unknown_method_falls_back_to_default_with_warning(client, caplog): + with caplog.at_level(logging.WARNING): + builder = SolutionMethodFactory.create_builder("unknown", client) + + assert isinstance(builder, DefaultBuilder) + assert any( + "Unsupported payment method: unknown" in record.getMessage() + and "DefaultBuilder" in record.getMessage() + for record in caplog.records + ) + + +def test_is_method_supported_false_for_unknown(): + assert SolutionMethodFactory.is_method_supported("xxx") is False + + +class _CustomSolutionBuilder(SolutionBuilder): + def get_service_name(self): + return "Custom" + + def get_allowed_service_parameters(self, action="Pay"): + return {} + + +def test_register_method_adds_new_entry(client): + SolutionMethodFactory.register_method("custom", _CustomSolutionBuilder) + assert SolutionMethodFactory.is_method_supported("custom") is True + assert isinstance( + SolutionMethodFactory.create_builder("custom", client), _CustomSolutionBuilder + ) + + +def test_register_method_lowercases_and_overrides(client): + SolutionMethodFactory.register_method("SUBSCRIPTION", _CustomSolutionBuilder) + assert isinstance( + SolutionMethodFactory.create_builder("subscription", client), + _CustomSolutionBuilder, + ) + + +def test_detect_method_from_payload_returns_method_lowercased(): + assert ( + SolutionMethodFactory.detect_method_from_payload({"method": "subscription"}) + == "subscription" + ) + + +def test_detect_method_from_payload_lowercases_uppercase_method(): + assert ( + SolutionMethodFactory.detect_method_from_payload({"method": "SUBSCRIPTION"}) + == "subscription" + ) + + +# SolutionMethodFactory.detect_method_from_payload deliberately does NOT warn +# on fallback — diverges from PaymentMethodFactory. Locked in here. +@pytest.mark.parametrize("payload", [{}, {"other": "thing"}], ids=["empty", "missing_method_key"]) +def test_detect_method_from_payload_fallback_is_silent(payload, caplog): + with caplog.at_level(logging.WARNING): + result = SolutionMethodFactory.detect_method_from_payload(payload) + assert result == "default" + assert caplog.records == [] diff --git a/tests/unit/http/__init__.py b/tests/unit/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/http/conftest.py b/tests/unit/http/conftest.py new file mode 100644 index 0000000..714ff19 --- /dev/null +++ b/tests/unit/http/conftest.py @@ -0,0 +1,146 @@ +"""Shared HMAC-signing vectors for :mod:`tests.unit.http.test_client`. + +The ``hmac_vectors`` fixture supplies tuples that pin the byte-for-byte +derivation performed by ``BuckarooHttpClient._generate_hmac_signature``. + +Vector shape:: + + (label, store_key, secret_key, method, url, content, timestamp, + nonce, expected_encoded_url, expected_content_b64, expected_signature) + +Only ``nonce`` is unobservable when calling the client (UUID4 is generated +internally). Tests parse the nonce out of the returned ``Authorization`` +header, then re-derive the signature and compare against +``expected_signature`` using the vector's fixed nonce equivalent — or, more +practically, assert the client's signature matches a locally recomputed one +using the parsed nonce. The fixed-nonce ``expected_signature`` is the +canonical byte check for the derivation formula itself. + +Sources +------- +Vectors are Python-only, frozen 2026-04-15. PHP byte-level parity requires +encoder alignment (PHP ``urlencode`` encodes space as ``+`` whereas Python +``urllib.parse.quote`` uses ``%20``; PHP ``JSON_PRESERVE_ZERO_FRACTION`` +keeps ``10.0`` as ``10.0`` while ``json.dumps`` drops it to ``10``). See +Epic 3 notes. ASCII-URL + integer-amount inputs are structurally identical +between SDKs and line up with +``BuckarooSDK_PHP/tests/Unit/Handlers/HMAC/GeneratorTest.php::`` +``test_generates_deterministic_hmac_with_fixed_nonce_and_timestamp``; we +still freeze the Python output here since PHP is not executed in this +suite. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def hmac_vectors(): + """Return frozen HMAC derivation vectors. + + Each tuple is:: + + (label, store_key, secret_key, method, url, content, timestamp, + nonce, expected_encoded_url, expected_content_b64, + expected_signature) + """ + return [ + # 1. POST, empty body, canonical Buckaroo endpoint. Structurally + # matches PHP test_generates_deterministic_hmac_with_fixed_nonce_and_timestamp + # (ASCII URL, no JSON body) — signature frozen from Python. + ( + "post_empty_body", + "test_website_key", + "secret_key_123", + "POST", + "https://testcheckout.buckaroo.nl/json/Transaction", + "", + "1234567890", + "test-nonce-12345", + "testcheckout.buckaroo.nl%2fjson%2ftransaction", + "", + "9ibLr6D5q3R23p0BHD9d0Zw/kftvz4O81+cCotkPX0c=", + ), + # 2. POST, raw string body. PHP's base64Data() short-circuits strings + # through md5() with no JSON encoding; Python treats the body + # as-is. Both SDKs therefore agree on content digest for pure + # strings. Frozen from Python. + ( + "post_string_body", + "test_website_key", + "secret_key_123", + "POST", + "https://testcheckout.buckaroo.nl/json/Transaction", + "raw-string-data", + "1234567890", + "test-nonce-12345", + "testcheckout.buckaroo.nl%2fjson%2ftransaction", + "w/j4rjsN8ba7EffGylXjVQ==", + "U6/pxRfIFH8xgZDq3VaNA82YHPtxHxWJvYMayYIJF94=", + ), + # 3. POST, integer-amount JSON body. Because the amount is int (10), + # Python json.dumps and PHP json_encode with + # JSON_PRESERVE_ZERO_FRACTION produce identical bytes + # (`{"amount":10,"currency":"EUR"}`). Still frozen from Python. + ( + "post_json_int_amount", + "test_website_key", + "secret_key_123", + "POST", + "https://testcheckout.buckaroo.nl/json/Transaction", + '{"amount":10,"currency":"EUR"}', + "1234567890", + "test-nonce-12345", + "testcheckout.buckaroo.nl%2fjson%2ftransaction", + "7ZsdtWbYvIvtRjqRuBI4kw==", + "GWYW3Jco2spWn9ePsKOd52SCbPyjVaAz8cvLljY/5mw=", + ), + # 4. POST, UTF-8 body with CJK codepoints. Python json.dumps with + # ensure_ascii=False matches PHP JSON_UNESCAPED_UNICODE for the + # raw string we feed in. Python-only frozen output. + ( + "post_json_unicode", + "test_website_key", + "secret_key_123", + "POST", + "https://example.com/api", + '{"description":"Payment 支付","amount":15}', + "1234567890", + "test-nonce-12345", + "example.com%2fapi", + "m2G/qZLlxSuH0IXNIBo6/g==", + "sshs5MG1ucciAE2hmnSLDtgZfJYYLq3BioewtUWEI+0=", + ), + # 5. GET, empty body — exercises method sensitivity in string_to_sign. + ( + "get_empty_body", + "test_website_key", + "secret_key_123", + "GET", + "https://example.com/api", + "", + "1234567890", + "test-nonce-12345", + "example.com%2fapi", + "", + "emiJMhAQGfao80k4kplOqZscCC0kZ9a1h6FhBIixQtE=", + ), + # 6. POST, mixed-case host and path. The Python quote(...).lower() + # must lowercase after percent-encoding. Python-only; PHP would + # encode spaces as '+' (not used here), so ASCII-only URL keeps + # the SDKs aligned on this vector. + ( + "post_mixedcase_url", + "test_website_key", + "secret_key_123", + "POST", + "http://API.Example.COM/Path", + "", + "1234567890", + "test-nonce-12345", + "api.example.com%2fpath", + "", + "v+djuZQm7pGRteROmd9VtvkRE8AqtqXdYp0Sa1ERyT4=", + ), + ] diff --git a/tests/unit/http/strategies/__init__.py b/tests/unit/http/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/http/strategies/test_curl_strategy.py b/tests/unit/http/strategies/test_curl_strategy.py new file mode 100644 index 0000000..e04c0c2 --- /dev/null +++ b/tests/unit/http/strategies/test_curl_strategy.py @@ -0,0 +1,454 @@ +"""Unit tests for buckaroo.http.strategies.curl_strategy.""" + +import subprocess +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from buckaroo.http.strategies.curl_strategy import CurlStrategy +from buckaroo.http.strategies.http_strategy import HttpResponse + + +def _completed(stdout="", stderr="", returncode=0): + """Build a stand-in for subprocess.CompletedProcess.""" + return SimpleNamespace(stdout=stdout, stderr=stderr, returncode=returncode) + + +class TestDefaults: + def test_init_sets_default_configuration(self): + strategy = CurlStrategy() + + assert strategy._timeout == 30 + assert strategy._verify_ssl is True + assert strategy._retry_attempts == 3 + assert strategy._default_headers == {} + + +class TestConfigure: + def test_configure_stores_all_options(self): + strategy = CurlStrategy() + + strategy.configure( + timeout=60, + verify_ssl=False, + retry_attempts=5, + default_headers={"X-Foo": "bar"}, + ) + + assert strategy._timeout == 60 + assert strategy._verify_ssl is False + assert strategy._retry_attempts == 5 + assert strategy._default_headers == {"X-Foo": "bar"} + + def test_configure_with_no_args_applies_defaults(self): + strategy = CurlStrategy() + strategy._timeout = 99 + strategy._verify_ssl = False + strategy._retry_attempts = 7 + strategy._default_headers = {"stale": "yes"} + + strategy.configure() + + assert strategy._timeout == 30 + assert strategy._verify_ssl is True + assert strategy._retry_attempts == 3 + assert strategy._default_headers == {} + + +class TestBuildCurlCommand: + def test_includes_required_flags_and_method(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="post", url="https://api.test/path") + + assert cmd[0] == "curl" + assert "-X" in cmd + assert cmd[cmd.index("-X") + 1] == "POST" + assert "--location" in cmd + assert "--silent" in cmd + assert "--show-error" in cmd + assert "--fail-with-body" in cmd + assert "--include" in cmd + assert "--max-time" in cmd + assert cmd[cmd.index("--max-time") + 1] == "30" + + def test_custom_timeout_is_stringified(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="GET", url="https://x", timeout=45) + + assert cmd[cmd.index("--max-time") + 1] == "45" + + def test_adds_insecure_when_verify_ssl_false(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="GET", url="https://x", verify_ssl=False) + + assert "--insecure" in cmd + + def test_omits_insecure_when_verify_ssl_true(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="GET", url="https://x", verify_ssl=True) + + assert "--insecure" not in cmd + + def test_merges_default_headers_with_per_call_headers(self): + strategy = CurlStrategy() + strategy.configure(default_headers={"X-Default": "d", "X-Shared": "default"}) + + cmd = strategy._build_curl_command( + method="GET", + url="https://x", + headers={"X-Call": "c", "X-Shared": "perCall"}, + ) + + header_values = [cmd[i + 1] for i, arg in enumerate(cmd) if arg == "-H"] + assert "X-Default: d" in header_values + assert "X-Call: c" in header_values + # per-call wins over default for overlapping key + assert "X-Shared: perCall" in header_values + assert "X-Shared: default" not in header_values + + def test_headers_omitted_when_neither_default_nor_per_call_provided(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="GET", url="https://x") + + assert "-H" not in cmd + + @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) + def test_data_attached_for_write_methods(self, method): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method=method, url="https://x", data='{"a":1}') + + assert "--data" in cmd + assert cmd[cmd.index("--data") + 1] == '{"a":1}' + + @pytest.mark.parametrize("method", ["GET", "DELETE"]) + def test_data_omitted_for_read_methods(self, method): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method=method, url="https://x", data='{"a":1}') + + assert "--data" not in cmd + + def test_data_omitted_when_data_is_none_even_for_post(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="POST", url="https://x", data=None) + + assert "--data" not in cmd + + def test_url_is_last_argument(self): + strategy = CurlStrategy() + strategy.configure(default_headers={"X-Default": "d"}) + + cmd = strategy._build_curl_command( + method="POST", + url="https://api.test/path", + headers={"X-Call": "c"}, + data="body", + verify_ssl=False, + ) + + assert cmd[-1] == "https://api.test/path" + + def test_lowercase_method_is_uppercased(self): + strategy = CurlStrategy() + + cmd = strategy._build_curl_command(method="get", url="https://x") + + assert cmd[cmd.index("-X") + 1] == "GET" + + +class TestParseCurlOutput: + def test_splits_on_crlf_crlf_and_parses_status_and_headers(self): + strategy = CurlStrategy() + stdout = ( + 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Req-Id: abc\r\n\r\n{"ok": true}' + ) + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/json" + assert response.headers["X-Req-Id"] == "abc" + assert response.text == '{"ok": true}' + assert response.success is True + + def test_falls_back_to_lf_lf_separator(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 201 Created\nLocation: /new\n\nbody-here" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.status_code == 201 + assert response.headers.get("Location") == "/new" + assert response.text == "body-here" + assert response.success is True + + def test_no_separator_treats_all_output_as_body(self): + strategy = CurlStrategy() + + response = strategy._parse_curl_output(_completed(stdout="just-a-body", returncode=0)) + + assert response.status_code == 200 + assert response.headers == {} + assert response.text == "just-a-body" + assert response.success is True + + def test_no_separator_with_nonzero_returncode_uses_returncode_as_status(self): + strategy = CurlStrategy() + + response = strategy._parse_curl_output(_completed(stdout="garbled", returncode=7)) + + assert response.status_code == 7 + assert response.headers == {} + assert response.text == "garbled" + assert response.success is False + + def test_unparseable_status_line_uses_returncode(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 NOTANUMBER Weird\r\n\r\nbody" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=42)) + + assert response.status_code == 42 + assert response.text == "body" + assert response.success is False + + def test_unparseable_status_line_with_zero_returncode_falls_back_to_500(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 NOTANUMBER Weird\r\n\r\nbody" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.status_code == 500 + assert response.text == "body" + assert response.success is False + + def test_status_line_without_second_token_falls_back_to_returncode(self): + strategy = CurlStrategy() + # "HTTP/" is present but split() yields only one element → IndexError path + stdout = "HTTP/1.1\r\n\r\nbody" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=9)) + + assert response.status_code == 9 + assert response.text == "body" + + def test_header_section_without_http_marker_leaves_status_zero(self): + strategy = CurlStrategy() + # No "HTTP/" on the status line → skip status parse, still parse K:V headers + stdout = "Server: nginx\r\nContent-Type: text/plain\r\n\r\nbody-text" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.status_code == 0 + assert response.headers.get("Content-Type") == "text/plain" + assert response.text == "body-text" + assert response.success is False + + def test_header_lines_without_colon_are_skipped(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nNotAHeaderLine\r\n\r\nbody" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.headers == {"Content-Type": "text/plain"} + assert response.text == "body" + + def test_empty_stdout_returns_response_with_returncode_as_status(self): + strategy = CurlStrategy() + + response = strategy._parse_curl_output(_completed(stdout="", stderr="", returncode=0)) + + assert isinstance(response, HttpResponse) + assert response.status_code == 0 + assert response.headers == {} + assert response.text == "" + assert response.success is True + + def test_empty_stdout_with_nonzero_returncode_is_unsuccessful(self): + strategy = CurlStrategy() + + response = strategy._parse_curl_output(_completed(stdout="", stderr="boom", returncode=2)) + + assert response.status_code == 2 + assert response.text == "" + assert response.success is False + + def test_nonzero_exit_with_empty_body_replaces_body_with_stderr(self): + strategy = CurlStrategy() + # whitespace-only body and a stderr message → body becomes stderr + stdout = "HTTP/1.1 000 \r\n\r\n " + + response = strategy._parse_curl_output( + _completed(stdout=stdout, stderr="curl: (6) Could not resolve host", returncode=6) + ) + + assert response.text == "curl: (6) Could not resolve host" + + def test_nonzero_exit_with_empty_body_and_no_stderr_uses_fallback_message(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 000 \r\n\r\n" + + response = strategy._parse_curl_output(_completed(stdout=stdout, stderr="", returncode=6)) + + assert response.text == "Curl failed with exit code 6" + + def test_success_flag_follows_2xx_status(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 299 Custom\r\n\r\nok" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.success is True + + def test_success_flag_false_for_300_range(self): + strategy = CurlStrategy() + stdout = "HTTP/1.1 301 Moved\r\nLocation: /x\r\n\r\n" + + response = strategy._parse_curl_output(_completed(stdout=stdout, returncode=0)) + + assert response.success is False + + +class TestRequestHappyPath: + def test_returns_parsed_response_on_first_success(self): + strategy = CurlStrategy() + completed = _completed( + stdout="HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello", + returncode=0, + ) + + with patch("subprocess.run", return_value=completed) as run_mock: + response = strategy.request( + method="POST", + url="https://api.test/path", + headers={"X-Trace": "1"}, + data="payload", + timeout=10, + verify_ssl=False, + ) + + assert response.status_code == 200 + assert response.text == "hello" + assert response.success is True + run_mock.assert_called_once() + args, kwargs = run_mock.call_args + cmd = args[0] + assert cmd[0] == "curl" + assert cmd[-1] == "https://api.test/path" + assert "--insecure" in cmd + assert "--data" in cmd + assert kwargs["timeout"] == 10 + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["check"] is False + + def test_uses_configured_timeout_when_call_omits_it(self): + strategy = CurlStrategy() + strategy.configure(timeout=25) + completed = _completed(stdout="HTTP/1.1 200 OK\r\n\r\n", returncode=0) + + with patch("subprocess.run", return_value=completed) as run_mock: + strategy.request(method="GET", url="https://x") + + _args, kwargs = run_mock.call_args + assert kwargs["timeout"] == 25 + + +class TestRequestRetryLoop: + def test_timeout_expired_retries_up_to_retry_attempts_then_raises(self): + strategy = CurlStrategy() + strategy.configure(retry_attempts=3) + + with patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="curl", timeout=5), + ) as run_mock: + with pytest.raises(Exception) as excinfo: + strategy.request(method="GET", url="https://x", timeout=5) + + assert run_mock.call_count == 3 + assert "Request timeout after 5 seconds" in str(excinfo.value) + + def test_subprocess_error_retries_and_raises_curl_command_failed(self): + strategy = CurlStrategy() + strategy.configure(retry_attempts=2) + + with patch( + "subprocess.run", + side_effect=subprocess.SubprocessError("spawn failed"), + ) as run_mock: + with pytest.raises(Exception) as excinfo: + strategy.request(method="GET", url="https://x") + + assert run_mock.call_count == 2 + assert "Curl command failed: spawn failed" in str(excinfo.value) + + def test_generic_exception_retries_and_raises_request_failed(self): + strategy = CurlStrategy() + strategy.configure(retry_attempts=2) + + with patch("subprocess.run", side_effect=RuntimeError("weird thing")) as run_mock: + with pytest.raises(Exception) as excinfo: + strategy.request(method="GET", url="https://x") + + assert run_mock.call_count == 2 + assert "Request failed: weird thing" in str(excinfo.value) + + def test_recovers_if_later_attempt_succeeds(self): + strategy = CurlStrategy() + strategy.configure(retry_attempts=3) + completed = _completed(stdout="HTTP/1.1 200 OK\r\n\r\nok", returncode=0) + + side_effects = [ + subprocess.TimeoutExpired(cmd="curl", timeout=5), + completed, + ] + + with patch("subprocess.run", side_effect=side_effects) as run_mock: + response = strategy.request(method="GET", url="https://x", timeout=5) + + assert run_mock.call_count == 2 + assert response.status_code == 200 + assert response.text == "ok" + + def test_zero_retry_attempts_raises_fallback_exception(self): + strategy = CurlStrategy() + strategy.configure(retry_attempts=0) + + with patch("subprocess.run") as run_mock: + with pytest.raises(Exception) as excinfo: + strategy.request(method="GET", url="https://x") + + run_mock.assert_not_called() + assert "Request failed after all retry attempts" in str(excinfo.value) + + +class TestIsAvailable: + def test_returns_true_when_curl_on_path(self): + strategy = CurlStrategy() + + with patch("shutil.which", return_value="/usr/bin/curl") as which_mock: + assert strategy.is_available() is True + + which_mock.assert_called_once_with("curl") + + def test_returns_false_when_curl_missing(self): + strategy = CurlStrategy() + + with patch("shutil.which", return_value=None) as which_mock: + assert strategy.is_available() is False + + which_mock.assert_called_once_with("curl") + + +class TestGetName: + def test_returns_curl(self): + assert CurlStrategy().get_name() == "curl" diff --git a/tests/unit/http/strategies/test_http_strategy.py b/tests/unit/http/strategies/test_http_strategy.py new file mode 100644 index 0000000..2cef586 --- /dev/null +++ b/tests/unit/http/strategies/test_http_strategy.py @@ -0,0 +1,146 @@ +"""Unit tests for buckaroo.http.strategies.http_strategy.""" + +import pytest + +from buckaroo.http.strategies.http_strategy import HttpResponse, HttpStrategy + + +class TestHttpResponseFields: + def test_stores_constructor_values(self): + response = HttpResponse( + status_code=201, + headers={"Content-Type": "application/json"}, + text='{"ok": true}', + success=True, + ) + + assert response.status_code == 201 + assert response.headers == {"Content-Type": "application/json"} + assert response.text == '{"ok": true}' + assert response.success is True + + def test_success_flag_honors_caller_input_even_when_status_suggests_failure(self): + response = HttpResponse( + status_code=500, + headers={}, + text="", + success=True, + ) + + assert response.success is True + + def test_success_flag_honors_caller_input_even_when_status_suggests_success(self): + response = HttpResponse( + status_code=200, + headers={}, + text="", + success=False, + ) + + assert response.success is False + + +class TestHttpResponseJson: + def test_parses_valid_json_text(self): + response = HttpResponse( + status_code=200, + headers={}, + text='{"key": "value", "n": 7}', + success=True, + ) + + assert response.json() == {"key": "value", "n": 7} + + def test_empty_text_returns_empty_dict(self): + response = HttpResponse( + status_code=204, + headers={}, + text="", + success=True, + ) + + assert response.json() == {} + + def test_invalid_json_returns_raw_content(self): + response = HttpResponse( + status_code=200, + headers={}, + text="not json", + success=False, + ) + + assert response.json() == {"raw_content": "not json"} + + +class TestHttpStrategyAbstract: + def test_direct_instantiation_raises_type_error(self): + with pytest.raises(TypeError): + HttpStrategy() + + @pytest.mark.parametrize( + "method_name", + ["configure", "request", "is_available", "get_name"], + ) + def test_method_is_abstract(self, method_name): + method = HttpStrategy.__dict__[method_name] + assert getattr(method, "__isabstractmethod__", False) is True + + def test_abstract_method_set_is_exactly_the_four_declared(self): + assert HttpStrategy.__abstractmethods__ == frozenset( + {"configure", "request", "is_available", "get_name"} + ) + + def test_subclass_missing_any_method_cannot_instantiate(self): + class PartialStrategy(HttpStrategy): + def configure(self, **kwargs): + pass + + def is_available(self): + return True + + def get_name(self): + return "partial" + + with pytest.raises(TypeError): + PartialStrategy() + + def test_concrete_subclass_implementing_all_four_methods_instantiates(self): + class FakeStrategy(HttpStrategy): + def __init__(self): + self.configured_with = None + + def configure(self, **kwargs): + self.configured_with = kwargs + + def request( + self, + method, + url, + headers=None, + data=None, + timeout=None, + verify_ssl=True, + ): + return HttpResponse( + status_code=200, + headers={}, + text="{}", + success=True, + ) + + def is_available(self): + return True + + def get_name(self): + return "fake" + + strategy = FakeStrategy() + + strategy.configure(timeout=5) + response = strategy.request("GET", "https://example.test") + + assert isinstance(strategy, HttpStrategy) + assert strategy.configured_with == {"timeout": 5} + assert strategy.is_available() is True + assert strategy.get_name() == "fake" + assert response.status_code == 200 diff --git a/tests/unit/http/strategies/test_requests_strategy.py b/tests/unit/http/strategies/test_requests_strategy.py new file mode 100644 index 0000000..9d1fb22 --- /dev/null +++ b/tests/unit/http/strategies/test_requests_strategy.py @@ -0,0 +1,383 @@ +"""Unit tests for buckaroo.http.strategies.requests_strategy.""" + +import builtins +import importlib +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from buckaroo.http.strategies import requests_strategy as rs_module +from buckaroo.http.strategies.http_strategy import HttpResponse +from buckaroo.http.strategies.requests_strategy import RequestsStrategy + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fake_response(status_code=200, text="ok", headers=None): + resp = MagicMock() + resp.status_code = status_code + resp.text = text + resp.headers = headers or {"Content-Type": "application/json"} + return resp + + +# --------------------------------------------------------------------------- +# get_name / is_available +# --------------------------------------------------------------------------- + + +class TestGetName: + def test_returns_requests(self): + assert RequestsStrategy().get_name() == "requests" + + +class TestIsAvailable: + def test_reflects_flag_when_true(self, monkeypatch): + monkeypatch.setattr(rs_module, "REQUESTS_AVAILABLE", True) + assert RequestsStrategy().is_available() is True + + def test_reflects_flag_when_false(self, monkeypatch): + monkeypatch.setattr(rs_module, "REQUESTS_AVAILABLE", False) + assert RequestsStrategy().is_available() is False + + +# --------------------------------------------------------------------------- +# configure() +# --------------------------------------------------------------------------- + + +class TestConfigureDefaults: + def test_creates_session_and_mounts_adapter_with_retry(self, monkeypatch): + strategy = RequestsStrategy() + + mounted = {} + session_instance = MagicMock() + session_instance.headers = {} + + def mount(prefix, adapter): + mounted[prefix] = adapter + + session_instance.mount.side_effect = mount + + session_cls = MagicMock(return_value=session_instance) + adapter_cls = MagicMock(return_value=MagicMock(name="adapter")) + retry_cls = MagicMock(return_value=MagicMock(name="retry")) + + monkeypatch.setattr(rs_module.requests, "Session", session_cls) + monkeypatch.setattr(rs_module, "HTTPAdapter", adapter_cls) + monkeypatch.setattr(rs_module, "Retry", retry_cls) + + strategy.configure() + + assert strategy.session is session_instance + assert strategy._retry_attempts == 3 + assert strategy._retry_delay == 1.0 + + retry_cls.assert_called_once_with( + total=3, + backoff_factor=1.0, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["POST", "GET", "PUT", "DELETE"], + ) + adapter_cls.assert_called_once_with(max_retries=retry_cls.return_value) + assert session_instance.mount.call_count == 2 + assert "http://" in mounted and "https://" in mounted + + +class TestConfigureWithKwargs: + def test_applies_custom_retry_and_default_headers(self, monkeypatch): + strategy = RequestsStrategy() + + session_instance = MagicMock() + session_instance.headers = MagicMock() + + session_cls = MagicMock(return_value=session_instance) + adapter_cls = MagicMock(return_value=MagicMock()) + retry_cls = MagicMock(return_value=MagicMock()) + + monkeypatch.setattr(rs_module.requests, "Session", session_cls) + monkeypatch.setattr(rs_module, "HTTPAdapter", adapter_cls) + monkeypatch.setattr(rs_module, "Retry", retry_cls) + + strategy.configure( + retry_attempts=7, + retry_delay=2.5, + default_headers={"X-Test": "1"}, + ) + + assert strategy._retry_attempts == 7 + assert strategy._retry_delay == 2.5 + retry_cls.assert_called_once_with( + total=7, + backoff_factor=2.5, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["POST", "GET", "PUT", "DELETE"], + ) + session_instance.headers.update.assert_called_once_with({"X-Test": "1"}) + + +class TestConfigureRetryFallback: + def test_falls_back_to_plain_max_retries_when_retry_raises_typeerror(self, monkeypatch): + strategy = RequestsStrategy() + + session_instance = MagicMock() + session_instance.headers = {} + session_cls = MagicMock(return_value=session_instance) + + adapter_cls = MagicMock(return_value=MagicMock()) + + def retry_raises(*args, **kwargs): + raise TypeError("bad kwargs for this urllib3 version") + + monkeypatch.setattr(rs_module.requests, "Session", session_cls) + monkeypatch.setattr(rs_module, "HTTPAdapter", adapter_cls) + monkeypatch.setattr(rs_module, "Retry", retry_raises) + + strategy.configure(retry_attempts=5) + + adapter_cls.assert_called_once_with(max_retries=5) + assert session_instance.mount.call_count == 2 + + +class TestConfigureWithoutRequests: + def test_raises_import_error_with_install_hint(self, monkeypatch): + monkeypatch.setattr(rs_module, "REQUESTS_AVAILABLE", False) + strategy = RequestsStrategy() + + with pytest.raises(ImportError) as excinfo: + strategy.configure() + + assert "requests" in str(excinfo.value) + assert "pip install requests" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# request() +# --------------------------------------------------------------------------- + + +class TestRequestLazyConfigure: + def test_calls_configure_when_session_is_none(self, monkeypatch): + strategy = RequestsStrategy() + + session_instance = MagicMock() + session_instance.request.return_value = _fake_response() + + def fake_configure(**kwargs): + strategy.session = session_instance + + monkeypatch.setattr(strategy, "configure", fake_configure) + + result = strategy.request("GET", "https://example.com") + + assert strategy.session is session_instance + assert isinstance(result, HttpResponse) + assert result.success is True + + +class TestRequestTimeout: + def test_defaults_timeout_to_30_when_none(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com") + + kwargs = strategy.session.request.call_args.kwargs + assert kwargs["timeout"] == 30 + + def test_passes_explicit_timeout_through(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com", timeout=12) + + kwargs = strategy.session.request.call_args.kwargs + assert kwargs["timeout"] == 12 + + +class TestRequestVerifySsl: + def test_passes_verify_true_by_default(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com") + + assert strategy.session.request.call_args.kwargs["verify"] is True + + def test_passes_verify_false_when_disabled(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com", verify_ssl=False) + + assert strategy.session.request.call_args.kwargs["verify"] is False + + +class TestRequestHeadersAndBody: + def test_forwards_headers(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com", headers={"X-A": "1"}) + + assert strategy.session.request.call_args.kwargs["headers"] == {"X-A": "1"} + + def test_defaults_headers_to_empty_dict_when_none(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com") + + assert strategy.session.request.call_args.kwargs["headers"] == {} + + def test_includes_data_when_provided(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("POST", "https://example.com", data="payload") + + assert strategy.session.request.call_args.kwargs["data"] == "payload" + + def test_omits_data_key_when_not_provided(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response() + + strategy.request("GET", "https://example.com") + + assert "data" not in strategy.session.request.call_args.kwargs + + +class TestRequestStatusMapping: + @pytest.mark.parametrize( + "status_code,expected_success", + [ + (200, True), + (201, True), + (299, True), + (301, False), + (400, False), + (404, False), + (500, False), + (503, False), + ], + ) + def test_success_flag_is_2xx_only(self, status_code, expected_success): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.return_value = _fake_response( + status_code=status_code, + text="body", + headers={"X-H": "v"}, + ) + + response = strategy.request("GET", "https://example.com") + + assert response.status_code == status_code + assert response.success is expected_success + assert response.text == "body" + assert response.headers == {"X-H": "v"} + + +class TestRequestExceptionMapping: + def test_timeout_is_wrapped_with_seconds_message(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.side_effect = rs_module.requests.exceptions.Timeout("slow") + + with pytest.raises(Exception) as excinfo: + strategy.request("GET", "https://example.com", timeout=7) + + assert str(excinfo.value) == "Request timeout after 7 seconds" + + def test_timeout_none_produces_clean_message(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.side_effect = rs_module.requests.exceptions.Timeout("slow") + + with pytest.raises(Exception) as excinfo: + strategy.request("GET", "https://example.com", timeout=None) + + assert str(excinfo.value) == "Request timeout" + + def test_connection_error_is_wrapped_with_fixed_message(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.side_effect = rs_module.requests.exceptions.ConnectionError("down") + + with pytest.raises(Exception) as excinfo: + strategy.request("GET", "https://example.com") + + assert str(excinfo.value) == ("Connection error - check your internet connection") + + def test_generic_request_exception_is_wrapped_with_prefix(self): + strategy = RequestsStrategy() + strategy.session = MagicMock() + strategy.session.request.side_effect = rs_module.requests.exceptions.RequestException( + "boom" + ) + + with pytest.raises(Exception) as excinfo: + strategy.request("GET", "https://example.com") + + assert str(excinfo.value) == "Request failed: boom" + + +# --------------------------------------------------------------------------- +# Import-time branches +# --------------------------------------------------------------------------- + + +MODULE_PATH = "buckaroo.http.strategies.requests_strategy" + + +def _reload_with_blocked(block_names): + """Reload the module with specific imports blocked, return the reloaded module.""" + original_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name in block_names or any(name.startswith(b + ".") for b in block_names): + raise ImportError(f"blocked: {name}") + # Also handle `from x import y` style where fromlist decides target + return original_import(name, globals, locals, fromlist, level) + + saved = sys.modules.pop(MODULE_PATH, None) + try: + with patch.object(builtins, "__import__", side_effect=fake_import): + module = importlib.import_module(MODULE_PATH) + return module + finally: + if saved is not None: + sys.modules[MODULE_PATH] = saved + + +class TestImportFallbacks: + def test_falls_back_to_requests_packages_urllib3_when_urllib3_missing(self): + module = _reload_with_blocked({"urllib3"}) + + assert module.REQUESTS_AVAILABLE is True + # Retry came from requests.packages.urllib3, which is the urllib3 Retry class + from requests.packages.urllib3.util.retry import Retry as FallbackRetry + + assert module.Retry is FallbackRetry + + def test_sets_flag_false_and_defines_dummies_when_requests_missing(self): + module = _reload_with_blocked({"requests"}) + + assert module.REQUESTS_AVAILABLE is False + # Dummy stand-ins should be defined as plain classes + assert isinstance(module.HTTPAdapter, type) + assert isinstance(module.Retry, type) + assert module.HTTPAdapter() is not None + assert module.Retry() is not None diff --git a/tests/unit/http/strategies/test_strategy_factory.py b/tests/unit/http/strategies/test_strategy_factory.py new file mode 100644 index 0000000..384070a --- /dev/null +++ b/tests/unit/http/strategies/test_strategy_factory.py @@ -0,0 +1,140 @@ +"""Unit tests for buckaroo.http.strategies.strategy_factory.""" + +import pytest + +from buckaroo.http.strategies.strategy_factory import HttpStrategyFactory +from buckaroo.http.strategies.requests_strategy import RequestsStrategy +from buckaroo.http.strategies.curl_strategy import CurlStrategy + + +# --------------------------------------------------------------------------- +# Auto-detect (no preferred strategy) +# --------------------------------------------------------------------------- + + +def test_create_strategy_auto_returns_requests_when_available(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + strategy = HttpStrategyFactory.create_strategy() + + assert isinstance(strategy, RequestsStrategy) + + +def test_create_strategy_auto_falls_back_to_curl_when_requests_unavailable(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: False) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + strategy = HttpStrategyFactory.create_strategy() + + assert isinstance(strategy, CurlStrategy) + + +def test_create_strategy_auto_raises_when_nothing_available(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: False) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: False) + + with pytest.raises(RuntimeError) as exc_info: + HttpStrategyFactory.create_strategy() + + message = str(exc_info.value) + assert "requests" in message + assert "curl" in message + + +# --------------------------------------------------------------------------- +# Explicit selection +# --------------------------------------------------------------------------- + + +def test_create_strategy_explicit_requests_when_available(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + strategy = HttpStrategyFactory.create_strategy("requests") + + assert isinstance(strategy, RequestsStrategy) + + +def test_create_strategy_explicit_curl_when_available(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + strategy = HttpStrategyFactory.create_strategy("curl") + + assert isinstance(strategy, CurlStrategy) + + +def test_create_strategy_explicit_requests_unavailable_raises_with_available(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: False) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + with pytest.raises(RuntimeError) as exc_info: + HttpStrategyFactory.create_strategy("requests") + + message = str(exc_info.value) + assert "requests" in message + assert "curl" in message + + +def test_create_strategy_bogus_name_raises(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + with pytest.raises(RuntimeError) as exc_info: + HttpStrategyFactory.create_strategy("bogus") + + assert "bogus" in str(exc_info.value) + + +def test_create_strategy_case_insensitive(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + strategy = HttpStrategyFactory.create_strategy("REQUESTS") + + assert isinstance(strategy, RequestsStrategy) + + +# --------------------------------------------------------------------------- +# Availability introspection +# --------------------------------------------------------------------------- + + +def test_get_available_strategies_both_available(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + assert HttpStrategyFactory.get_available_strategies() == ["requests", "curl"] + + +def test_get_available_strategies_only_curl(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: False) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: True) + + assert HttpStrategyFactory.get_available_strategies() == ["curl"] + + +def test_get_available_strategies_none(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: False) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: False) + + assert HttpStrategyFactory.get_available_strategies() == [] + + +def test_is_strategy_available_requests(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: False) + + assert HttpStrategyFactory.is_strategy_available("requests") is True + + +def test_is_strategy_available_curl_false(monkeypatch): + monkeypatch.setattr(RequestsStrategy, "is_available", lambda self: True) + monkeypatch.setattr(CurlStrategy, "is_available", lambda self: False) + + assert HttpStrategyFactory.is_strategy_available("curl") is False + + +def test_is_strategy_available_bogus(monkeypatch): + assert HttpStrategyFactory.is_strategy_available("bogus") is False diff --git a/tests/unit/http/test_client.py b/tests/unit/http/test_client.py new file mode 100644 index 0000000..144b0db --- /dev/null +++ b/tests/unit/http/test_client.py @@ -0,0 +1,752 @@ +"""HMAC-signing tests for :class:`buckaroo.http.client.BuckarooHttpClient`. + +Pins the byte-for-byte output of ``_generate_hmac_signature``. Since the +client generates its nonce internally via ``uuid.uuid4()`` we parse the +nonce out of the returned ``Authorization`` header and re-derive the +signature from public inputs; the ``hmac_vectors`` fixture supplies a +fixed-nonce canonical value so the derivation formula itself is pinned. + +No ``unittest.mock.patch`` used. A :class:`tests.support.mock_buckaroo.MockBuckaroo` +instance is injected wherever a client is constructed, even though these +tests never actually issue a request — the client only needs a valid +strategy to finish initializing. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac as _hmac +from urllib.parse import quote + +import pytest + +from buckaroo.config.buckaroo_config import BuckarooConfig +from buckaroo.exceptions._authentication_error import AuthenticationError +from buckaroo.http.client import BuckarooApiError +from buckaroo.http.client import BuckarooHttpClient +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from tests.support.recording_mock import RecordingMock + + +# --------------------------------------------------------------------------- +# Helpers + + +def _make_client( + store_key: str = "test_store_key", + secret_key: str = "test_secret_key", +) -> BuckarooHttpClient: + """Build a client wired to a MockBuckaroo strategy (never dispatched).""" + client = BuckarooHttpClient( + store_key=store_key, + secret_key=secret_key, + config=BuckarooConfig(), + ) + client.http_strategy = MockBuckaroo() + return client + + +def _parse_auth(header_value: str) -> tuple[str, str, str, str]: + """Return ``(store_key, signature, nonce, timestamp)`` from an Authorization header.""" + assert header_value.startswith("hmac "), header_value + payload = header_value[len("hmac ") :] + parts = payload.split(":") + assert len(parts) == 4, parts + return parts[0], parts[1], parts[2], parts[3] + + +def _recompute_signature( + store_key: str, + secret_key: str, + method: str, + url: str, + content: str, + timestamp: str, + nonce: str, +) -> str: + """Re-derive the Buckaroo HMAC signature from public inputs.""" + if url.startswith("https://"): + url = url[8:] + elif url.startswith("http://"): + url = url[7:] + encoded_url = quote(url, safe="").lower() + + if content: + content_b64 = base64.b64encode(hashlib.md5(content.encode("utf-8")).digest()).decode( + "utf-8" + ) + else: + content_b64 = "" + + string_to_sign = f"{store_key}{method}{encoded_url}{timestamp}{nonce}{content_b64}" + return base64.b64encode( + _hmac.new( + secret_key.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).digest() + ).decode("utf-8") + + +# --------------------------------------------------------------------------- +# Tests + + +class TestHmacVectors: + """Pin derivation against frozen vectors (fixed nonce, fixed timestamp).""" + + def test_hmac_vectors_match_recomputed_signature(self, hmac_vectors): + for ( + label, + store_key, + secret_key, + method, + url, + content, + timestamp, + nonce, + expected_encoded_url, + expected_content_b64, + expected_signature, + ) in hmac_vectors: + # Encoded URL component is a vector-level invariant. + if url.startswith("https://"): + stripped = url[8:] + elif url.startswith("http://"): + stripped = url[7:] + else: + stripped = url + assert quote(stripped, safe="").lower() == expected_encoded_url, label + + # Content digest component is a vector-level invariant. + if content: + actual_b64 = base64.b64encode(hashlib.md5(content.encode("utf-8")).digest()).decode( + "utf-8" + ) + else: + actual_b64 = "" + assert actual_b64 == expected_content_b64, label + + # Signature derivation formula is a vector-level invariant. + recomputed = _recompute_signature( + store_key, + secret_key, + method, + url, + content, + timestamp, + nonce, + ) + assert recomputed == expected_signature, label + + +class TestHmacHeaderFormat: + """Pin structure of the returned auth headers.""" + + def test_authorization_header_has_four_colon_components(self): + client = _make_client(store_key="store_abc", secret_key="secret_xyz") + headers = client._generate_hmac_signature( + method="POST", + url="https://testcheckout.buckaroo.nl/json/Transaction", + content="", + timestamp="1700000000", + ) + + auth = headers["Authorization"] + assert auth.startswith("hmac ") + store, signature, nonce, timestamp = _parse_auth(auth) + assert store == "store_abc" + assert signature # non-empty base64 + assert nonce # non-empty uuid + assert timestamp == "1700000000" + + def test_all_three_headers_present(self): + client = _make_client(store_key="store_abc") + headers = client._generate_hmac_signature( + method="POST", + url="https://example.com/api", + content="", + timestamp="1700000000", + ) + + assert set(headers.keys()) == { + "Authorization", + "X-Buckaroo-Timestamp", + "X-Buckaroo-Store-Key", + } + assert headers["X-Buckaroo-Timestamp"] == "1700000000" + assert headers["X-Buckaroo-Store-Key"] == "store_abc" + + +class TestHmacDeterminism: + """Client-returned signatures must match re-derivation under parsed nonce.""" + + def test_signature_matches_local_derivation_for_fixed_inputs(self): + client = _make_client(store_key="test_store_key", secret_key="test_secret_key") + headers = client._generate_hmac_signature( + method="POST", + url="https://testcheckout.buckaroo.nl/json/Transaction", + content='{"amount":10,"currency":"EUR"}', + timestamp="1234567890", + ) + + _store, signature, nonce, _ts = _parse_auth(headers["Authorization"]) + expected = _recompute_signature( + store_key="test_store_key", + secret_key="test_secret_key", + method="POST", + url="https://testcheckout.buckaroo.nl/json/Transaction", + content='{"amount":10,"currency":"EUR"}', + timestamp="1234567890", + nonce=nonce, + ) + assert signature == expected + + def test_each_invocation_yields_fresh_nonce(self): + client = _make_client() + h1 = client._generate_hmac_signature("POST", "https://x/a", "", "1") + h2 = client._generate_hmac_signature("POST", "https://x/a", "", "1") + _, _, nonce1, _ = _parse_auth(h1["Authorization"]) + _, _, nonce2, _ = _parse_auth(h2["Authorization"]) + assert nonce1 != nonce2 + + +class TestHmacSensitivity: + """Flipping any signing input must change the signature.""" + + @staticmethod + def _sig_for(client, method, url, content, timestamp): + h = client._generate_hmac_signature(method, url, content, timestamp) + _, signature, nonce, _ = _parse_auth(h["Authorization"]) + return signature, nonce + + def test_signature_changes_when_method_changes(self): + client = _make_client() + sig_post, nonce = self._sig_for(client, "POST", "https://example.com/api", "", "1700000000") + # Re-derive GET using the SAME nonce so only method differs. + get_expected = _recompute_signature( + "test_store_key", + "test_secret_key", + "GET", + "https://example.com/api", + "", + "1700000000", + nonce, + ) + assert sig_post != get_expected + + def test_signature_changes_when_url_changes(self): + client = _make_client() + sig_a, nonce = self._sig_for(client, "POST", "https://example.com/a", "", "1700000000") + sig_b_expected = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/b", + "", + "1700000000", + nonce, + ) + assert sig_a != sig_b_expected + + def test_signature_changes_when_content_changes(self): + client = _make_client() + sig_empty, nonce = self._sig_for( + client, "POST", "https://example.com/api", "", "1700000000" + ) + sig_with_body = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + '{"x":1}', + "1700000000", + nonce, + ) + assert sig_empty != sig_with_body + + def test_signature_changes_when_timestamp_changes(self): + client = _make_client() + sig_t1, nonce = self._sig_for(client, "POST", "https://example.com/api", "", "1700000000") + sig_t2 = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + "", + "1700000001", + nonce, + ) + assert sig_t1 != sig_t2 + + def test_signature_changes_when_secret_key_changes(self): + client_a = _make_client(secret_key="secret_a") + # Use local re-derivation to remove nonce as a variable. + _, sig_a, nonce_a, _ = _parse_auth( + client_a._generate_hmac_signature("POST", "https://example.com/api", "", "1700000000")[ + "Authorization" + ] + ) + sig_b_same_nonce = _recompute_signature( + "test_store_key", + "secret_b", + "POST", + "https://example.com/api", + "", + "1700000000", + nonce_a, + ) + assert sig_a != sig_b_same_nonce + + def test_signature_changes_when_store_key_changes(self): + client_a = _make_client(store_key="store_a") + _, sig_a, nonce_a, _ = _parse_auth( + client_a._generate_hmac_signature("POST", "https://example.com/api", "", "1700000000")[ + "Authorization" + ] + ) + sig_b_same_nonce = _recompute_signature( + "store_b", + "test_secret_key", + "POST", + "https://example.com/api", + "", + "1700000000", + nonce_a, + ) + assert sig_a != sig_b_same_nonce + + +class TestHmacContentEdgeCases: + """Empty-body variants must produce byte-identical content digests.""" + + def test_empty_string_and_default_content_are_equivalent(self): + client = _make_client() + explicit_empty = client._generate_hmac_signature( + "POST", "https://example.com/api", "", "1700000000" + ) + default = client._generate_hmac_signature( + "POST", "https://example.com/api", timestamp="1700000000" + ) + _, sig1, nonce1, _ = _parse_auth(explicit_empty["Authorization"]) + _, sig2, nonce2, _ = _parse_auth(default["Authorization"]) + + # Re-derive both under a shared nonce; both must collapse to the + # same signature (proof content component is '' in both paths). + rederived_1 = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + "", + "1700000000", + nonce1, + ) + rederived_2 = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + "", + "1700000000", + nonce2, + ) + assert sig1 == rederived_1 + assert sig2 == rederived_2 + + def test_non_ascii_utf8_body_is_stable(self): + client = _make_client() + body = '{"description":"Payment 支付 💳","amount":15}' + h1 = client._generate_hmac_signature("POST", "https://example.com/api", body, "1700000000") + _, sig1, nonce1, _ = _parse_auth(h1["Authorization"]) + expected = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + body, + "1700000000", + nonce1, + ) + assert sig1 == expected + + # Second call with same inputs + parsed nonce yields identical sig. + h2 = client._generate_hmac_signature("POST", "https://example.com/api", body, "1700000000") + _, sig2, nonce2, _ = _parse_auth(h2["Authorization"]) + expected2 = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + body, + "1700000000", + nonce2, + ) + assert sig2 == expected2 + + +class TestHmacUrlScheme: + """Both https:// and http:// schemes must strip the protocol identically.""" + + def test_http_url_strips_protocol_for_signing(self): + client = _make_client() + body = "" + ts = "1700000000" + + # Generate once to obtain a real nonce from the client. + h_http = client._generate_hmac_signature("POST", "http://example.com/api", body, ts) + _, sig_http, nonce, _ = _parse_auth(h_http["Authorization"]) + + # Re-derive both http and https with the SAME nonce; they must match + # because protocol stripping makes the signed URL identical. + rederived_http = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "http://example.com/api", + body, + ts, + nonce, + ) + rederived_https = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + body, + ts, + nonce, + ) + assert sig_http == rederived_http + assert rederived_http == rederived_https + + def test_url_without_scheme_signs_verbatim(self): + client = _make_client() + headers = client._generate_hmac_signature("POST", "example.com/api", "", "1700000000") + _, sig, nonce, _ = _parse_auth(headers["Authorization"]) + + expected = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "example.com/api", + "", + "1700000000", + nonce, + ) + assert sig == expected + + +class TestHmacTimestampDefault: + """When ``timestamp`` is omitted the header must still round-trip.""" + + def test_default_timestamp_is_current_unix_seconds_string(self): + import time as _time + + before = int(_time.time()) + client = _make_client() + headers = client._generate_hmac_signature("POST", "https://example.com/api", "") + after = int(_time.time()) + + ts_header = headers["X-Buckaroo-Timestamp"] + assert ts_header.isdigit() + assert before <= int(ts_header) <= after + + _, signature, nonce, auth_ts = _parse_auth(headers["Authorization"]) + assert auth_ts == ts_header + expected = _recompute_signature( + "test_store_key", + "test_secret_key", + "POST", + "https://example.com/api", + "", + ts_header, + nonce, + ) + assert signature == expected + + +# --------------------------------------------------------------------------- +# Sanity: parametrized walk over the full vector set through the real client. + + +@pytest.mark.parametrize( + "vector_index", + list(range(6)), + ids=[ + "post_empty_body", + "post_string_body", + "post_json_int_amount", + "post_json_unicode", + "get_empty_body", + "post_mixedcase_url", + ], +) +def test_hmac_client_output_matches_vector_under_parsed_nonce(hmac_vectors, vector_index): + ( + _label, + store_key, + secret_key, + method, + url, + content, + timestamp, + _fixed_nonce, + _expected_encoded_url, + _expected_content_b64, + _expected_signature, + ) = hmac_vectors[vector_index] + + client = _make_client(store_key=store_key, secret_key=secret_key) + headers = client._generate_hmac_signature(method, url, content, timestamp) + + parsed_store, signature, parsed_nonce, parsed_ts = _parse_auth(headers["Authorization"]) + assert parsed_store == store_key + assert parsed_ts == timestamp + assert headers["X-Buckaroo-Store-Key"] == store_key + assert headers["X-Buckaroo-Timestamp"] == timestamp + + recomputed = _recompute_signature( + store_key, secret_key, method, url, content, timestamp, parsed_nonce + ) + assert signature == recomputed + + +# --------------------------------------------------------------------------- +# Error mapping + request orchestration (Issue #19) +# +# Builds clients with a MockBuckaroo strategy injected directly so we can +# control exact responses and observed request shapes. No unittest.mock. + + +def _make_client_with_mock(mock: MockBuckaroo) -> BuckarooHttpClient: + """Build a client and replace its strategy with the given mock.""" + client = BuckarooHttpClient( + store_key="test_store_key", + secret_key="test_secret_key", + config=BuckarooConfig(), + ) + client.http_strategy = mock + return client + + +class TestResponseParsing: + """`_parse_response` semantics through the public client surface.""" + + def test_valid_json_2xx_returns_parsed_dict(self): + mock = RecordingMock() + mock.queue(BuckarooMockRequest.json("POST", "*/json/Transaction*", {"Key": "abc"})) + client = _make_client_with_mock(mock) + + response = client.post("/json/Transaction", data={"a": 1}) + + assert response.success is True + assert response.status_code == 200 + assert response.data == {"Key": "abc"} + + def test_empty_body_2xx_yields_empty_dict(self): + from buckaroo.http.strategies.http_strategy import HttpResponse + + class EmptyBodyMock(MockBuckaroo): + def request(self, method, url, headers=None, data=None, timeout=None, verify_ssl=True): + return HttpResponse(status_code=200, headers={}, text="", success=True) + + client = _make_client_with_mock(EmptyBodyMock()) + + response = client.post("/json/Transaction", data={"a": 1}) + + assert response.success is True + assert response.data == {} + + def test_malformed_json_2xx_raises_buckaroo_api_error(self): + from buckaroo.http.strategies.http_strategy import HttpResponse + + class GarbageBodyMock(MockBuckaroo): + def request(self, method, url, headers=None, data=None, timeout=None, verify_ssl=True): + return HttpResponse(status_code=200, headers={}, text="not-json{", success=True) + + client = _make_client_with_mock(GarbageBodyMock()) + + with pytest.raises(BuckarooApiError): + client.post("/json/Transaction", data={"a": 1}) + + +class TestAuthenticationStatusCodes: + """401 and 403 must surface as :class:`AuthenticationError`.""" + + def test_401_raises_authentication_error_about_keys(self): + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest.json("POST", "*/json/Transaction*", {"err": "no"}, status=401) + ) + client = _make_client_with_mock(mock) + + with pytest.raises(AuthenticationError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + message = str(exc.value).lower() + assert "store" in message and "secret" in message + + def test_403_raises_authentication_error_about_permissions(self): + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest.json("POST", "*/json/Transaction*", {"err": "no"}, status=403) + ) + client = _make_client_with_mock(mock) + + with pytest.raises(AuthenticationError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + assert "permission" in str(exc.value).lower() + + +class TestNonAuthErrorStatusCodes: + """All other non-2xx responses must surface as :class:`BuckarooApiError`.""" + + @pytest.mark.parametrize("status", [400, 404, 422, 500, 503]) + def test_non_2xx_raises_buckaroo_api_error_carrying_status_and_body(self, status): + body = {"Code": status, "Message": f"err-{status}"} + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest.json("POST", "*/json/Transaction*", body, status=status)) + client = _make_client_with_mock(mock) + + with pytest.raises(BuckarooApiError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + assert not isinstance(exc.value, AuthenticationError) + + assert str(status) in str(exc.value) + assert exc.value.status_code == status + assert f"err-{status}" in str(exc.value.error_data) or f"err-{status}" in ( + exc.value.response.text if exc.value.response else "" + ) + + +class TestStrategyExceptionMapping: + """Transport-level exceptions must be re-raised as :class:`BuckarooApiError`.""" + + def test_timeout_exception_becomes_buckaroo_api_error(self): + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest("POST", "*/json/Transaction*").with_exception( + TimeoutError("connection timeout after 30s") + ) + ) + client = _make_client_with_mock(mock) + + with pytest.raises(BuckarooApiError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + assert isinstance(exc.value.__cause__, TimeoutError) + assert "Request failed" in str(exc.value) + + def test_connection_exception_becomes_buckaroo_api_error(self): + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest("POST", "*/json/Transaction*").with_exception( + ConnectionError("connection refused by host") + ) + ) + client = _make_client_with_mock(mock) + + with pytest.raises(BuckarooApiError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + assert isinstance(exc.value.__cause__, ConnectionError) + assert "Request failed" in str(exc.value) + + def test_authentication_error_from_strategy_propagates_unchanged(self): + original = AuthenticationError("strategy-side auth failure") + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest("POST", "*/json/Transaction*").with_exception(original)) + client = _make_client_with_mock(mock) + + with pytest.raises(AuthenticationError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + assert exc.value is original + + def test_buckaroo_api_error_from_strategy_propagates_unchanged(self): + original = BuckarooApiError("strategy-side api failure") + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest("POST", "*/json/Transaction*").with_exception(original)) + client = _make_client_with_mock(mock) + + with pytest.raises(BuckarooApiError) as exc: + client.post("/json/Transaction", data={"a": 1}) + + assert exc.value is original + + +class TestRequestOrchestration: + """post/get delegate to the strategy with the right URL, method, headers.""" + + def test_post_passes_authorization_header_to_strategy(self): + mock = RecordingMock() + mock.queue(BuckarooMockRequest.json("POST", "*/json/Transaction*", {})) + client = _make_client_with_mock(mock) + + client.post("/json/Transaction", data={"a": 1}) + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "POST" + assert "Authorization" in call["headers"] + assert call["headers"]["Authorization"].startswith("hmac ") + + def test_get_passes_authorization_header_to_strategy(self): + mock = RecordingMock() + mock.queue(BuckarooMockRequest.json("GET", "*/json/Transaction*", {})) + client = _make_client_with_mock(mock) + + client.get("/json/Transaction") + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "GET" + assert "Authorization" in call["headers"] + + def test_endpoint_without_leading_slash_is_normalised(self): + mock = RecordingMock() + mock.queue(BuckarooMockRequest.json("POST", "*/json/Transaction", {})) + client = _make_client_with_mock(mock) + + client.post("json/Transaction", data={"a": 1}) + + url = mock.calls[0]["url"] + assert url == "https://testcheckout.buckaroo.nl/json/Transaction" + assert "//json" not in url.split("://", 1)[1] + + def test_endpoint_with_leading_slash_does_not_double_up(self): + mock = RecordingMock() + mock.queue(BuckarooMockRequest.json("POST", "*/json/Transaction", {})) + client = _make_client_with_mock(mock) + + client.post("/json/Transaction", data={"a": 1}) + + url = mock.calls[0]["url"] + assert url == "https://testcheckout.buckaroo.nl/json/Transaction" + + def test_get_passes_params_in_query_string_with_no_body(self): + mock = RecordingMock() + mock.queue(BuckarooMockRequest.json("GET", "*/json/Spec*", {})) + client = _make_client_with_mock(mock) + + client.get("/json/Spec", params={"name": "ideal", "v": "1"}) + + call = mock.calls[0] + assert call["data"] is None + assert "name=ideal" in call["url"] + assert "v=1" in call["url"] + assert call["url"].split("?", 1)[0] == "https://testcheckout.buckaroo.nl/json/Spec" + + +class TestApiErrorSymbol: + """`BuckarooApiError` is the public exception type for HTTP failures.""" + + def test_buckaroo_api_error_is_exposed_from_client_module(self): + from buckaroo.http import client as client_module + + assert hasattr(client_module, "BuckarooApiError") diff --git a/tests/unit/http/test_response.py b/tests/unit/http/test_response.py new file mode 100644 index 0000000..c151fcb --- /dev/null +++ b/tests/unit/http/test_response.py @@ -0,0 +1,341 @@ +"""Unit tests for buckaroo.http.client.BuckarooResponse. + +BuckarooResponse wraps an HttpResponse and exposes predicates/parsers for +Buckaroo-specific fields. These tests construct HttpResponse instances +directly — no HTTP client, no mocks. +""" + +import pytest + +from buckaroo.http.client import BuckarooResponse +from buckaroo.http.strategies.http_strategy import HttpResponse + + +def make_response(status_code=200, text="", headers=None, success=True): + """Build an HttpResponse with sensible defaults.""" + return HttpResponse( + status_code=status_code, + headers=headers or {}, + text=text, + success=success, + ) + + +class TestSuccessPredicate: + @pytest.mark.parametrize( + "status_code,expected", + [ + (199, False), + (200, True), + (299, True), + (300, False), + (500, False), + ], + ) + def test_success_is_true_only_for_2xx(self, status_code, expected): + response = BuckarooResponse(make_response(status_code=status_code)) + + assert response.success is expected + + +class TestDataParsing: + def test_parses_valid_json_body(self): + response = BuckarooResponse(make_response(text='{"Key": "abc", "n": 7}')) + + assert response.data == {"Key": "abc", "n": 7} + + def test_empty_body_returns_empty_dict(self): + response = BuckarooResponse(make_response(text="")) + + assert response.data == {} + + def test_invalid_json_raises_buckaroo_api_error(self): + from buckaroo.http.client import BuckarooApiError + + with pytest.raises(BuckarooApiError): + BuckarooResponse(make_response(text="not-json")) + + def test_json_alias_returns_same_data(self): + response = BuckarooResponse(make_response(text='{"a": 1}')) + + assert response.json() == response.data + + def test_data_returns_empty_dict_when_parsed_value_is_falsy(self): + # Force _data to a falsy value to hit the `or {}` branch in the getter. + response = BuckarooResponse(make_response(text="")) + response._data = None + + assert response.data == {} + + +class TestPassThroughAttributes: + def test_status_code_passes_through(self): + response = BuckarooResponse(make_response(status_code=418)) + + assert response.status_code == 418 + + def test_text_passes_through(self): + response = BuckarooResponse(make_response(text='{"x": 1}')) + + assert response.text == '{"x": 1}' + + def test_headers_pass_through(self): + headers = {"Content-Type": "application/json", "X-Trace": "abc"} + response = BuckarooResponse(make_response(headers=headers)) + + assert response.headers == headers + + +class TestIsSuccessfulPayment: + def test_returns_false_when_http_failed(self): + response = BuckarooResponse( + make_response(status_code=500, text='{"Status": {"Code": 190}}') + ) + + assert response.is_successful_payment() is False + + @pytest.mark.parametrize("code", [190, 490, 491, 492, 790, 791, 792, 793]) + def test_true_for_each_buckaroo_success_code(self, code): + response = BuckarooResponse(make_response(text=f'{{"Status": {{"Code": {code}}}}}')) + + assert response.is_successful_payment() is True + + def test_false_for_non_success_buckaroo_code(self): + response = BuckarooResponse(make_response(text='{"Status": {"Code": 491000}}')) + + assert response.is_successful_payment() is False + + def test_handles_nested_code_dict_shape(self): + response = BuckarooResponse( + make_response(text='{"Status": {"Code": {"Code": 190, "Description": "Success"}}}') + ) + + assert response.is_successful_payment() is True + + def test_returns_success_when_no_status_field(self): + # HTTP 2xx but no "Status" in body — falls through to self.success. + response = BuckarooResponse(make_response(text='{"Other": "field"}')) + + assert response.is_successful_payment() is True + + def test_true_when_status_code_missing_from_status(self): + response = BuckarooResponse(make_response(text='{"Status": {"Other": 1}}')) + + # Status present but no "Code" key — falls through to self.success. + assert response.is_successful_payment() is True + + def test_false_when_code_is_unknown_type(self): + response = BuckarooResponse(make_response(text='{"Status": {"Code": "oops"}}')) + + assert response.is_successful_payment() is False + + def test_true_when_status_is_falsy(self): + # Status present but falsy — skips the Buckaroo-code branch. + response = BuckarooResponse(make_response(text='{"Status": null}')) + + assert response.is_successful_payment() is True + + def test_true_when_data_is_empty_but_http_ok(self): + # No _data at all -> falls through to self.success. + response = BuckarooResponse(make_response(text="")) + + assert response.is_successful_payment() is True + + +class TestGetStatusCode: + def test_returns_simple_int_code(self): + response = BuckarooResponse(make_response(text='{"Status": {"Code": 190}}')) + + assert response.get_status_code() == 190 + + def test_flattens_nested_code_dict(self): + response = BuckarooResponse( + make_response(text='{"Status": {"Code": {"Code": 490, "Description": "Failed"}}}') + ) + + assert response.get_status_code() == 490 + + def test_returns_none_when_data_missing(self): + response = BuckarooResponse(make_response(text="")) + + assert response.get_status_code() is None + + def test_returns_none_when_status_missing(self): + response = BuckarooResponse(make_response(text='{"Other": 1}')) + + assert response.get_status_code() is None + + def test_returns_none_when_status_falsy(self): + response = BuckarooResponse(make_response(text='{"Status": null}')) + + assert response.get_status_code() is None + + def test_returns_none_when_code_missing(self): + response = BuckarooResponse(make_response(text='{"Status": {"Other": 1}}')) + + assert response.get_status_code() is None + + def test_returns_none_for_unknown_code_type(self): + response = BuckarooResponse(make_response(text='{"Status": {"Code": "string-code"}}')) + + assert response.get_status_code() is None + + +class TestGetStatusMessage: + def test_prefers_subcode_description(self): + body = ( + '{"Status": {"Code": {"Description": "Code desc"}, ' + '"SubCode": {"Description": "Sub desc"}}}' + ) + response = BuckarooResponse(make_response(text=body)) + + assert response.get_status_message() == "Sub desc" + + def test_falls_back_to_code_description_when_subcode_none(self): + body = '{"Status": {"Code": {"Description": "Code desc"}, "SubCode": null}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_status_message() == "Code desc" + + def test_empty_when_subcode_none_and_code_has_no_description(self): + body = '{"Status": {"Code": {"Other": 1}, "SubCode": null}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_status_message() == "" + + def test_empty_when_subcode_none_and_code_is_int(self): + body = '{"Status": {"Code": 190, "SubCode": null}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_status_message() == "" + + def test_empty_when_subcode_is_not_dict(self): + # SubCode present but not a dict — falls through to trailing "". + body = '{"Status": {"Code": 190, "SubCode": "not-a-dict"}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_status_message() == "" + + def test_subcode_dict_missing_description(self): + body = '{"Status": {"Code": 190, "SubCode": {"Other": 1}}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_status_message() == "" + + def test_empty_when_data_missing(self): + response = BuckarooResponse(make_response(text="")) + + assert response.get_status_message() == "" + + def test_empty_when_status_missing(self): + response = BuckarooResponse(make_response(text='{"Other": 1}')) + + assert response.get_status_message() == "" + + def test_empty_when_status_falsy(self): + response = BuckarooResponse(make_response(text='{"Status": null}')) + + assert response.get_status_message() == "" + + +class TestGetPaymentKey: + def test_returns_key_from_data(self): + response = BuckarooResponse(make_response(text='{"Key": "abc-123"}')) + + assert response.get_payment_key() == "abc-123" + + def test_returns_none_when_key_missing(self): + response = BuckarooResponse(make_response(text='{"Other": 1}')) + + assert response.get_payment_key() is None + + def test_returns_none_when_data_missing(self): + response = BuckarooResponse(make_response(text="")) + response._data = None + + assert response.get_payment_key() is None + + +class TestGetTransactionKey: + def test_returns_key_from_services_list(self): + body = '{"Services": [{"TransactionKey": "txn-1"}]}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_transaction_key() == "txn-1" + + def test_returns_key_from_services_dict_service_list(self): + body = '{"Services": {"ServiceList": [{"TransactionKey": "txn-dict"}]}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_transaction_key() == "txn-dict" + + def test_returns_none_when_services_missing(self): + response = BuckarooResponse(make_response(text='{"Other": 1}')) + + assert response.get_transaction_key() is None + + def test_returns_none_when_services_list_empty(self): + response = BuckarooResponse(make_response(text='{"Services": []}')) + + assert response.get_transaction_key() is None + + def test_returns_none_when_services_dict_service_list_empty(self): + body = '{"Services": {"ServiceList": []}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_transaction_key() is None + + def test_returns_none_when_services_dict_has_no_service_list(self): + body = '{"Services": {"Other": 1}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_transaction_key() is None + + def test_returns_none_when_data_missing(self): + response = BuckarooResponse(make_response(text="")) + response._data = None + + assert response.get_transaction_key() is None + + +class TestGetRedirectUrl: + def test_returns_redirect_url_when_present(self): + body = '{"RequiredAction": {"RedirectURL": "https://pay.example/redir"}}' + response = BuckarooResponse(make_response(text=body)) + + assert response.get_redirect_url() == "https://pay.example/redir" + + def test_returns_none_when_required_action_missing(self): + response = BuckarooResponse(make_response(text='{"Other": 1}')) + + assert response.get_redirect_url() is None + + def test_returns_none_when_required_action_has_no_redirect_url(self): + response = BuckarooResponse(make_response(text='{"RequiredAction": {"Other": 1}}')) + + assert response.get_redirect_url() is None + + def test_returns_none_when_data_missing(self): + response = BuckarooResponse(make_response(text="")) + response._data = None + + assert response.get_redirect_url() is None + + +class TestToDict: + def test_includes_status_success_data_and_headers(self): + headers = {"Content-Type": "application/json"} + response = BuckarooResponse( + make_response( + status_code=201, + text='{"Key": "k"}', + headers=headers, + ) + ) + + assert response.to_dict() == { + "status_code": 201, + "success": True, + "data": {"Key": "k"}, + "headers": headers, + } diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/models/test_payment_request.py b/tests/unit/models/test_payment_request.py new file mode 100644 index 0000000..bf47660 --- /dev/null +++ b/tests/unit/models/test_payment_request.py @@ -0,0 +1,226 @@ +"""Tests for buckaroo.models.payment_request DTOs.""" + +from buckaroo.models.payment_request import ( + ClientIP, + Parameter, + PaymentRequest, + Service, + ServiceList, +) + + +def _make_request(**overrides): + defaults = dict( + currency="EUR", + amount_debit=10.50, + description="Order 42", + invoice="INV-42", + return_url="https://example.com/return", + return_url_cancel="https://example.com/cancel", + return_url_error="https://example.com/error", + return_url_reject="https://example.com/reject", + ) + defaults.update(overrides) + return PaymentRequest(**defaults) + + +class TestParameter: + def test_to_dict_emits_all_fields_with_defaults(self): + assert Parameter(name="issuer", value="ABNANL2A").to_dict() == { + "Name": "issuer", + "GroupType": "", + "GroupID": "", + "Value": "ABNANL2A", + } + + def test_to_dict_preserves_group_metadata(self): + assert Parameter( + name="street", value="Main", group_type="Address", group_id="1" + ).to_dict() == { + "Name": "street", + "GroupType": "Address", + "GroupID": "1", + "Value": "Main", + } + + +class TestClientIP: + def test_defaults(self): + assert ClientIP().to_dict() == {"Type": 0, "Address": "0.0.0.0"} + + def test_custom_values(self): + assert ClientIP(type=1, address="127.0.0.1").to_dict() == { + "Type": 1, + "Address": "127.0.0.1", + } + + +class TestService: + def test_to_dict_without_parameters(self): + assert Service(name="ideal").to_dict() == {"Name": "ideal", "Action": "Pay"} + + def test_to_dict_with_none_parameters_omits_parameters_key(self): + result = Service(name="ideal", action="Refund", parameters=None).to_dict() + assert result == {"Name": "ideal", "Action": "Refund"} + + def test_to_dict_with_dict_parameters_merges_into_service(self): + assert Service( + name="creditcard", + parameters={"Name": "visa", "Version": 0}, + ).to_dict() == { + "Name": "visa", + "Action": "Pay", + "Version": 0, + } + + def test_to_dict_with_list_of_parameters_serializes_each(self): + params = [ + Parameter(name="description", value="QR Payment"), + Parameter(name="amount", value="5.00"), + ] + assert Service(name="idealqr", parameters=params).to_dict() == { + "Name": "idealqr", + "Action": "Pay", + "Parameters": [ + { + "Name": "description", + "GroupType": "", + "GroupID": "", + "Value": "QR Payment", + }, + { + "Name": "amount", + "GroupType": "", + "GroupID": "", + "Value": "5.00", + }, + ], + } + + def test_to_dict_with_empty_dict_parameters_skips_merge(self): + # Falsy parameters (empty dict) bypass the merge branch. + assert Service(name="ideal", parameters={}).to_dict() == { + "Name": "ideal", + "Action": "Pay", + } + + def test_add_parameter_accepts_parameter_instance(self): + service = Service(name="idealqr") + param = Parameter(name="description", value="QR") + service.add_parameter(param) + assert service.parameters == [param] + + def test_add_parameter_coerces_dict_input(self): + service = Service(name="idealqr") + service.add_parameter({"name": "amount", "value": "5.00"}) + assert service.parameters == [Parameter(name="amount", value="5.00")] + + def test_add_parameter_appends_to_existing_list(self): + service = Service(name="idealqr", parameters=[Parameter(name="a", value="1")]) + service.add_parameter({"name": "b", "value": "2"}) + assert service.parameters == [ + Parameter(name="a", value="1"), + Parameter(name="b", value="2"), + ] + + def test_add_parameter_prefers_buckaroo_cased_keys(self): + service = Service(name="idealqr") + service.add_parameter( + { + "Name": "amount", + "Value": "5.00", + "GroupType": "Order", + "GroupID": "1", + } + ) + assert service.parameters == [ + Parameter(name="amount", value="5.00", group_type="Order", group_id="1") + ] + + def test_add_parameter_raises_on_dict_form_parameters(self): + service = Service(name="ideal", parameters={"Issuer": "ABNANL2A"}) + import pytest + + with pytest.raises(TypeError, match="simple key-value parameters"): + service.add_parameter(Parameter(name="x", value="y")) + + +class TestServiceList: + def test_to_dict_wraps_each_service(self): + services = ServiceList(services=[Service(name="ideal"), Service(name="paypal")]) + assert services.to_dict() == { + "ServiceList": [ + {"Name": "ideal", "Action": "Pay"}, + {"Name": "paypal", "Action": "Pay"}, + ] + } + + def test_to_dict_empty(self): + assert ServiceList(services=[]).to_dict() == {"ServiceList": []} + + def test_add_appends_service_and_returns_self_for_chaining(self): + sl = ServiceList(services=[]) + ideal = Service(name="ideal") + paypal = Service(name="paypal") + result = sl.add(ideal).add(paypal) + assert result is sl + assert sl.services == [ideal, paypal] + + +class TestPaymentRequest: + def test_to_dict_minimal_emits_default_client_ip(self): + request = _make_request() + result = request.to_dict() + assert result == { + "Currency": "EUR", + "AmountDebit": 10.50, + "Description": "Order 42", + "Invoice": "INV-42", + "ReturnURL": "https://example.com/return", + "ReturnURLCancel": "https://example.com/cancel", + "ReturnURLError": "https://example.com/error", + "ReturnURLReject": "https://example.com/reject", + "ContinueOnIncomplete": "1", + "ClientIP": {"Type": 0, "Address": "0.0.0.0"}, + } + + def test_post_init_assigns_default_client_ip(self): + assert _make_request().client_ip == ClientIP() + + def test_to_dict_preserves_explicit_client_ip(self): + request = _make_request(client_ip=ClientIP(type=1, address="10.0.0.1")) + assert request.to_dict()["ClientIP"] == {"Type": 1, "Address": "10.0.0.1"} + + def test_to_dict_includes_push_urls_when_set(self): + request = _make_request( + push_url="https://example.com/push", + push_url_failure="https://example.com/push-fail", + ) + result = request.to_dict() + assert result["PushURL"] == "https://example.com/push" + assert result["PushURLFailure"] == "https://example.com/push-fail" + + def test_to_dict_omits_push_urls_when_absent(self): + result = _make_request().to_dict() + assert "PushURL" not in result + assert "PushURLFailure" not in result + + def test_to_dict_includes_services_when_set(self): + services = ServiceList(services=[Service(name="ideal")]) + result = _make_request(services=services).to_dict() + assert result["Services"] == {"ServiceList": [{"Name": "ideal", "Action": "Pay"}]} + + def test_to_dict_omits_services_when_absent(self): + assert "Services" not in _make_request().to_dict() + + def test_to_dict_omits_client_ip_when_falsy(self): + request = _make_request() + request.client_ip = None + assert "ClientIP" not in request.to_dict() + + +class TestServiceUnsupportedParameters: + def test_to_dict_ignores_parameters_of_unsupported_type(self): + # A truthy value that is neither list nor dict should be ignored. + service = Service(name="ideal", parameters="unexpected") + assert service.to_dict() == {"Name": "ideal", "Action": "Pay"} diff --git a/tests/unit/models/test_payment_response.py b/tests/unit/models/test_payment_response.py new file mode 100644 index 0000000..6013be0 --- /dev/null +++ b/tests/unit/models/test_payment_response.py @@ -0,0 +1,505 @@ +"""Tests for buckaroo.models.payment_response.""" + +import pytest + +from buckaroo.models.payment_response import ( + BuckarooStatusCode, + PaymentResponse, + RequiredAction, + Service, + ServiceParameter, + Status, + StatusCode, +) + + +PENDING_CODES = ( + BuckarooStatusCode.PENDING_INPUT, + BuckarooStatusCode.PENDING_PROCESSING, + BuckarooStatusCode.PENDING_CONSUMER, + BuckarooStatusCode.AWAITING_TRANSFER, +) +CANCELLED_CODES = ( + BuckarooStatusCode.CANCELLED_BY_USER, + BuckarooStatusCode.CANCELLED_BY_MERCHANT, +) +FAILED_CODES = ( + BuckarooStatusCode.FAILED, + BuckarooStatusCode.VALIDATION_FAILURE, + BuckarooStatusCode.TECHNICAL_FAILURE, + BuckarooStatusCode.REJECTED, + BuckarooStatusCode.REJECTED_BY_USER, + BuckarooStatusCode.REJECTED_TECHNICAL, +) + + +# --- StatusCode.from_dict --- + + +def test_status_code_from_full_dict(): + sc = StatusCode.from_dict({"Code": 190, "Description": "Success"}) + assert sc.code == 190 + assert sc.description == "Success" + + +def test_status_code_from_partial_dict_missing_description(): + sc = StatusCode.from_dict({"Code": 490}) + assert sc.code == 490 + assert sc.description == "" + + +def test_status_code_from_empty_dict(): + sc = StatusCode.from_dict({}) + assert sc.code == 0 + assert sc.description == "" + + +def test_status_code_from_none(): + sc = StatusCode.from_dict(None) + assert sc.code == 0 + assert sc.description == "" + + +def test_status_code_from_integer(): + sc = StatusCode.from_dict(490) + assert sc.code == 490 + assert sc.description == "" + + +def test_status_code_from_unexpected_type(): + sc = StatusCode.from_dict("nope") + assert sc.code == 0 + assert sc.description == "" + + +# --- Status.from_dict --- + + +def test_status_from_full_dict(): + status = Status.from_dict( + { + "Code": {"Code": 190, "Description": "Success"}, + "SubCode": {"Code": 1, "Description": "Sub"}, + "DateTime": "2024-01-01T00:00:00", + } + ) + assert status.code.code == 190 + assert status.sub_code.code == 1 + assert status.datetime == "2024-01-01T00:00:00" + + +def test_status_from_dict_with_null_sub_code(): + status = Status.from_dict( + { + "Code": {"Code": 190, "Description": "Success"}, + "SubCode": None, + } + ) + assert status.code.code == 190 + assert status.sub_code.code == 0 + assert status.datetime == "" + + +def test_status_from_empty_dict(): + status = Status.from_dict({}) + assert status.code.code == 0 + assert status.sub_code.code == 0 + assert status.datetime == "" + + +def test_status_from_none(): + status = Status.from_dict(None) + assert status.code.code == 0 + + +# --- RequiredAction.from_dict --- + + +def test_required_action_from_full_dict(): + ra = RequiredAction.from_dict( + { + "RedirectURL": "https://example.com/pay", + "RequestedInformation": {"field": "foo"}, + "PayRemainderDetails": {"remainder": 10}, + "Name": "Redirect", + "TypeDeprecated": 1, + } + ) + assert ra.redirect_url == "https://example.com/pay" + assert ra.requested_information == {"field": "foo"} + assert ra.pay_remainder_details == {"remainder": 10} + assert ra.name == "Redirect" + assert ra.type_deprecated == 1 + + +def test_required_action_from_empty_dict(): + ra = RequiredAction.from_dict({}) + assert ra.redirect_url is None + assert ra.requested_information is None + assert ra.pay_remainder_details is None + assert ra.name == "" + assert ra.type_deprecated == 0 + + +def test_required_action_from_none(): + ra = RequiredAction.from_dict(None) + assert ra.redirect_url is None + assert ra.name == "" + + +# --- ServiceParameter.from_dict --- + + +def test_service_parameter_from_full_dict(): + sp = ServiceParameter.from_dict({"Name": "TransactionId", "Value": "abc"}) + assert sp.name == "TransactionId" + assert sp.value == "abc" + + +def test_service_parameter_from_empty_dict(): + sp = ServiceParameter.from_dict({}) + assert sp.name == "" + assert sp.value is None + + +def test_service_parameter_from_none(): + sp = ServiceParameter.from_dict(None) + assert sp.name == "" + assert sp.value is None + + +# --- Service.from_dict --- + + +def test_service_from_full_dict(): + svc = Service.from_dict( + { + "Name": "ideal", + "Action": "Pay", + "Parameters": [ + {"Name": "TransactionId", "Value": "tx1"}, + {"Name": "IssuerId", "Value": "ABNANL2A"}, + ], + } + ) + assert svc.name == "ideal" + assert svc.action == "Pay" + assert len(svc.parameters) == 2 + assert svc.parameters[0].name == "TransactionId" + assert svc.parameters[0].value == "tx1" + + +def test_service_from_dict_without_parameters(): + svc = Service.from_dict({"Name": "ideal"}) + assert svc.name == "ideal" + assert svc.action is None + assert svc.parameters == [] + + +def test_service_from_dict_with_empty_parameters_list(): + svc = Service.from_dict({"Name": "ideal", "Parameters": []}) + assert svc.parameters == [] + + +def test_service_from_empty_dict(): + svc = Service.from_dict({}) + assert svc.name == "" + assert svc.parameters == [] + + +def test_service_from_none(): + svc = Service.from_dict(None) + assert svc.name == "" + assert svc.parameters == [] + + +# --- PaymentResponse basic construction --- + + +def test_payment_response_from_empty_dict_does_not_raise(): + resp = PaymentResponse({}) + assert resp.status is None + assert resp.required_action is None + assert resp.services == [] + assert resp.is_pending() is False + assert resp.is_successful() is False + assert resp.is_cancelled() is False + assert resp.is_failed() is False + assert resp.requires_action() is False + assert resp.get_redirect_url() is None + assert resp.get_transaction_id() is None + assert resp.get_service_parameter("anything") is None + + +def test_payment_response_from_none(): + resp = PaymentResponse(None) + assert resp.to_dict() == {} + assert resp.status is None + + +def test_payment_response_is_successful_uses_raw_flag(): + resp = PaymentResponse({"is_successful_payment": True}) + assert resp.is_successful() is True + + +def test_payment_response_parses_basic_fields(): + resp = PaymentResponse( + { + "status_code": 200, + "success": True, + "headers": {"X-Test": "1"}, + "transaction_key": "txkey", + "buckaroo_status_code": 190, + "buckaroo_status_message": "Success", + "redirect_url": "https://legacy.example/", + "data": { + "Key": "KEY1", + "PaymentKey": "PK1", + "Invoice": "INV-1", + "ServiceCode": "ideal", + "IsTest": True, + "Currency": "EUR", + "AmountDebit": 12.50, + "AmountCredit": 0, + "TransactionType": "C021", + "MutationType": 1, + "CustomParameters": {"a": 1}, + "AdditionalParameters": {"b": 2}, + "RequestErrors": None, + "RelatedTransactions": [], + "ConsumerMessage": "Thanks", + "Order": "ORD-1", + "IssuingCountry": "NL", + "StartRecurrent": True, + "Recurring": True, + "CustomerName": "Jane", + "PayerHash": "hash", + }, + } + ) + assert resp.status_code == 200 + assert resp.success is True + assert resp.headers == {"X-Test": "1"} + assert resp.key == "KEY1" + assert resp.payment_key == "PK1" + assert resp.invoice == "INV-1" + assert resp.service_code == "ideal" + assert resp.is_test is True + assert resp.currency == "EUR" + assert resp.amount_debit == 12.50 + assert resp.amount_credit == 0 + assert resp.transaction_type == "C021" + assert resp.mutation_type == 1 + assert resp.custom_parameters == {"a": 1} + assert resp.additional_parameters == {"b": 2} + assert resp.request_errors is None + assert resp.related_transactions == [] + assert resp.consumer_message == "Thanks" + assert resp.order == "ORD-1" + assert resp.issuing_country == "NL" + assert resp.start_recurrent is True + assert resp.recurring is True + assert resp.customer_name == "Jane" + assert resp.payer_hash == "hash" + assert resp.transaction_key == "txkey" + assert resp.buckaroo_status_code == 190 + assert resp.buckaroo_status_message == "Success" + assert resp.redirect_url == "https://legacy.example/" + assert resp.to_dict()["status_code"] == 200 + + +# --- Status predicates parametrised over enum --- + + +@pytest.mark.parametrize("code", PENDING_CODES) +def test_is_pending_true_for_pending_codes(code): + resp = _response_with_status_code(code) + assert resp.is_pending() is True + assert resp.is_cancelled() is False + assert resp.is_failed() is False + + +@pytest.mark.parametrize("code", CANCELLED_CODES) +def test_is_cancelled_true_for_cancelled_codes(code): + resp = _response_with_status_code(code) + assert resp.is_cancelled() is True + assert resp.is_pending() is False + assert resp.is_failed() is False + + +@pytest.mark.parametrize("code", FAILED_CODES) +def test_is_failed_true_for_failed_codes(code): + resp = _response_with_status_code(code) + assert resp.is_failed() is True + assert resp.is_pending() is False + assert resp.is_cancelled() is False + + +def test_success_code_matches_no_predicate(): + resp = _response_with_status_code(BuckarooStatusCode.SUCCESS) + assert resp.is_pending() is False + assert resp.is_cancelled() is False + assert resp.is_failed() is False + + +def test_is_successful_with_190_status_and_success_flag(): + resp = PaymentResponse( + { + "is_successful_payment": True, + "data": { + "Status": {"Code": {"Code": 190, "Description": "Success"}}, + }, + } + ) + assert resp.is_successful() is True + assert resp.is_pending() is False + assert resp.is_cancelled() is False + assert resp.is_failed() is False + + +def test_predicates_false_when_no_status(): + resp = PaymentResponse({}) + assert resp.is_pending() is False + assert resp.is_cancelled() is False + assert resp.is_failed() is False + + +# --- get_redirect_url --- + + +def test_get_redirect_url_none_without_required_action(): + resp = PaymentResponse({"data": {}}) + assert resp.get_redirect_url() is None + + +def test_get_redirect_url_returns_required_action_url(): + resp = PaymentResponse( + { + "data": { + "RequiredAction": { + "RedirectURL": "https://checkout.example/pay/1", + "Name": "Redirect", + } + } + } + ) + assert resp.requires_action() is True + assert resp.get_redirect_url() == "https://checkout.example/pay/1" + + +# --- get_transaction_id / get_service_parameter --- + + +def test_get_transaction_id_returns_value_when_present(): + resp = PaymentResponse( + { + "data": { + "Services": [ + { + "Name": "ideal", + "Parameters": [ + {"Name": "TransactionId", "Value": "TX-123"}, + ], + } + ] + } + } + ) + assert resp.get_transaction_id() == "TX-123" + + +def test_get_transaction_id_none_when_missing(): + resp = PaymentResponse( + {"data": {"Services": [{"Name": "ideal", "Parameters": [{"Name": "Other", "Value": "x"}]}]}} + ) + assert resp.get_transaction_id() is None + + +def test_get_service_parameter_case_insensitive(): + resp = PaymentResponse( + { + "data": { + "Services": [ + { + "Name": "ideal", + "Parameters": [ + {"Name": "ConsumerIBAN", "Value": "NL00RABO0123456789"}, + ], + } + ] + } + } + ) + assert resp.get_service_parameter("consumeriban") == "NL00RABO0123456789" + + +def test_get_service_parameter_returns_none_for_missing_key(): + resp = PaymentResponse( + {"data": {"Services": [{"Name": "ideal", "Parameters": [{"Name": "A", "Value": 1}]}]}} + ) + assert resp.get_service_parameter("NotThere") is None + + +# --- __str__ / __repr__ --- + + +def test_str_includes_status_and_amount(): + resp = PaymentResponse( + { + "data": { + "Key": "K", + "Currency": "EUR", + "AmountDebit": 9.99, + "Status": { + "Code": {"Code": int(BuckarooStatusCode.SUCCESS), "Description": "Success"} + }, + } + } + ) + s = str(resp) + assert "PaymentResponse(" in s + assert "key=K" in s + assert f"{int(BuckarooStatusCode.SUCCESS)} - Success" in s + assert "9.99 EUR" in s + + +def test_str_with_unknown_status(): + resp = PaymentResponse({"data": {"Key": "K"}}) + assert "status=Unknown" in str(resp) + + +def test_repr_includes_all_fields(): + resp = PaymentResponse( + { + "status_code": 200, + "success": True, + "data": { + "Key": "K", + "PaymentKey": "PK", + "IsTest": False, + "Currency": "EUR", + "AmountDebit": 1.0, + }, + } + ) + r = repr(resp) + assert "key=K" in r + assert "payment_key=PK" in r + assert "status_code=200" in r + assert "success=True" in r + assert "is_test=False" in r + assert "currency=EUR" in r + assert "amount=1.0" in r + + +# --- Helpers --- + + +def _response_with_status_code(code: int) -> PaymentResponse: + return PaymentResponse( + { + "data": { + "Status": {"Code": {"Code": int(code), "Description": ""}}, + } + } + ) diff --git a/tests/unit/observers/__init__.py b/tests/unit/observers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/observers/test_logging_observer.py b/tests/unit/observers/test_logging_observer.py new file mode 100644 index 0000000..45dd62c --- /dev/null +++ b/tests/unit/observers/test_logging_observer.py @@ -0,0 +1,694 @@ +"""Tests for buckaroo.observers.logging_observer masking behaviour.""" + +import json +import logging +import sys +from logging.handlers import RotatingFileHandler + +import pytest + +from buckaroo.observers.logging_observer import ( + BuckarooLoggingObserver, + ContextualLoggingObserver, + LogConfig, + LogDestination, + LogLevel, + create_logger, + create_logger_from_env, +) + + +def _observer(mask: bool = True) -> BuckarooLoggingObserver: + """Build an observer that doesn't write to disk during tests.""" + config = LogConfig(destination=LogDestination.STDOUT, mask_sensitive_data=mask) + return BuckarooLoggingObserver(config) + + +# --- Sensitive-field set --- + + +@pytest.mark.parametrize( + "field", + [ + "secret_key", + "password", + "token", + "authorization", + "cvv", + "cardnumber", + "card_number", + "iban", + "account_number", + # New Buckaroo-specific entries added in this issue: + "cvc", + "bic", + "pan", + "expirydate", + "encryptedcarddata", + ], +) +def test_sensitive_fields_contains_expected_entries(field): + obs = _observer() + assert field in obs._sensitive_fields + + +# --- Masking matrix --- + + +@pytest.mark.parametrize( + "key", + [ + "cvc", + "bic", + "pan", + "expirydate", + "encryptedcarddata", + "CVC", + "Bic", + "PAN", + "ExpiryDate", + "EncryptedCardData", + ], +) +def test_new_sensitive_keys_are_masked_top_level(key): + obs = _observer() + masked = obs._mask_sensitive_data({key: "raw-value"}) + assert masked[key] == "***MASKED***" + + +def test_nested_dict_masks_sensitive_key(): + obs = _observer() + result = obs._mask_sensitive_data({"outer": {"cvc": "123", "description": "ok"}}) + assert result["outer"]["cvc"] == "***MASKED***" + assert result["outer"]["description"] == "ok" + + +def test_list_of_dicts_masks_sensitive_key(): + obs = _observer() + result = obs._mask_sensitive_data( + [ + {"bic": "ABNANL2A", "amount": 10}, + {"pan": "4111...", "currency": "EUR"}, + ] + ) + assert result[0]["bic"] == "***MASKED***" + assert result[0]["amount"] == 10 + assert result[1]["pan"] == "***MASKED***" + assert result[1]["currency"] == "EUR" + + +def test_deep_buckaroo_shape_parameters_list(): + """Deep Buckaroo payload: Services.ServiceList[].Parameters[].Name/Value. + + The masker inspects dict KEYS for the ***MASKED*** path and string VALUES + for the ***POTENTIALLY_SENSITIVE*** fallback. The Name "encryptedCardData" + is a *value* containing a sensitive substring, so it gets POTENTIALLY_SENSITIVE. + The paired Value is covered by ``test_deep_buckaroo_shape_parameters_value_is_masked``. + """ + obs = _observer() + payload = { + "Services": { + "ServiceList": [ + { + "Name": "creditcard", + "Parameters": [{"Name": "encryptedCardData", "Value": "CARD-SECRET"}], + } + ] + }, + "encryptedCardData": "ALSO-SECRET", + } + result = obs._mask_sensitive_data(payload) + # Top-level sensitive key is masked. + assert result["encryptedCardData"] == "***MASKED***" + param = result["Services"]["ServiceList"][0]["Parameters"][0] + # Name is a value containing a sensitive substring → POTENTIALLY_SENSITIVE. + assert param["Name"] == "***POTENTIALLY_SENSITIVE***" + + +def test_deep_buckaroo_shape_parameters_value_is_masked(): + """The Value field paired with a sensitive Name like 'encryptedCardData' + is masked via the Name/Value pair detection.""" + obs = _observer() + payload = { + "Services": { + "ServiceList": [ + { + "Name": "creditcard", + "Parameters": [{"Name": "encryptedCardData", "Value": "CARD-SECRET"}], + } + ] + }, + } + result = obs._mask_sensitive_data(payload) + param = result["Services"]["ServiceList"][0]["Parameters"][0] + assert param["Value"] == "***MASKED***" + + +def test_name_value_pair_without_sensitive_name_passes_through(): + """A dict with Name/Value where Name is not sensitive leaves Value intact.""" + obs = _observer() + result = obs._mask_sensitive_data({"Name": "amount", "Value": "42"}) + assert result["Value"] == "42" + + +def test_name_value_pair_with_non_string_name_passes_through(): + """A dict with a non-string Name skips the sensitive pair check.""" + obs = _observer() + result = obs._mask_sensitive_data({"Name": 123, "Value": "data"}) + assert result["Value"] == "data" + + +# --- JSON string input --- + + +def test_format_json_parses_json_string_and_masks(): + obs = _observer() + raw = json.dumps({"cvc": "999", "amount": 10}) + out = obs._format_json(raw) + parsed = json.loads(out) + assert parsed["cvc"] == "***MASKED***" + assert parsed["amount"] == 10 + + +def test_format_json_non_json_string_returned_verbatim(): + obs = _observer() + assert obs._format_json("not json at all") == "not json at all" + + +def test_format_json_dict_input_produces_masked_json(): + obs = _observer() + out = obs._format_json({"pan": "4111", "currency": "EUR"}) + parsed = json.loads(out) + assert parsed["pan"] == "***MASKED***" + assert parsed["currency"] == "EUR" + + +def test_format_json_non_serialisable_falls_back_to_str(): + class NotJSON: + def __repr__(self): + return "" + + obs = _observer() + # json.dumps handles most things via default=str; force a failure by + # triggering an exception path — a dict with a non-serialisable key + # (keys must be str/int/float/bool/None) raises TypeError. + weird = {object(): "value"} + result = obs._format_json(weird) + assert isinstance(result, str) + assert result == str(weird) + + +# --- Case-insensitive substring matching --- + + +@pytest.mark.parametrize( + "key", + [ + "Authorization", + "AUTHORIZATION", + "card_Number", + "EncryptedCardData", + "X-Authorization-Header", # substring match + ], +) +def test_case_insensitive_substring_match(key): + obs = _observer() + result = obs._mask_sensitive_data({key: "secret"}) + assert result[key] == "***MASKED***" + + +# --- Sentinel non-sensitive fields pass through --- + + +@pytest.mark.parametrize( + "key,value", + [ + ("description", "Order 42"), + ("amount", 100.50), + ("currency", "EUR"), + ], +) +def test_non_sensitive_fields_pass_through(key, value): + obs = _observer() + result = obs._mask_sensitive_data({key: value}) + assert result[key] == value + + +# --- String values with sensitive keyword --- + + +def test_string_with_sensitive_keyword_is_redacted(): + obs = _observer() + # A bare string value that *contains* a sensitive keyword gets the + # POTENTIALLY_SENSITIVE treatment. + assert obs._mask_sensitive_data("this has a cvc in it") == "***POTENTIALLY_SENSITIVE***" + + +def test_string_without_sensitive_keyword_passes_through(): + obs = _observer() + assert obs._mask_sensitive_data("harmless log line") == "harmless log line" + + +# --- Disable masking --- + + +def test_masking_disabled_returns_data_unchanged(): + obs = _observer(mask=False) + data = {"cvc": "999", "password": "hunter2", "encryptedCardData": "x"} + assert obs._mask_sensitive_data(data) == data + + +# --- log_request --- + + +def test_log_request_emits_one_info_record_with_method_url_masked_headers_and_body(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_request( + "POST", + "https://checkout.buckaroo.nl/json/Transaction", + headers={"Authorization": "hmac topsecret", "Content-Type": "application/json"}, + body={"cvc": "999", "amount": 10}, + ) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert len(records) == 1 + rec = records[0] + assert rec.levelno == logging.INFO + assert "POST" in rec.message + assert "https://checkout.buckaroo.nl/json/Transaction" in rec.message + assert "***MASKED***" in rec.message + assert "topsecret" not in rec.message + assert "999" not in rec.message + + +# --- log_response --- + + +@pytest.mark.parametrize( + "status,expected_level", + [ + (200, logging.INFO), + (201, logging.INFO), + (299, logging.INFO), + (400, logging.WARNING), + (404, logging.WARNING), + (499, logging.WARNING), + (500, logging.ERROR), + (503, logging.ERROR), + ], +) +def test_log_response_level_matches_status_code(caplog, status, expected_level): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_response(status, headers={"X-Trace": "abc"}, body={"ok": True}) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert len(records) == 1 + assert records[0].levelno == expected_level + + +def test_log_response_includes_duration_when_provided(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_response(200, duration_ms=123.456) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert len(records) == 1 + assert "123.46" in records[0].message + assert "ms" in records[0].message + + +def test_log_response_omits_duration_when_not_provided(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_response(200) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert "Duration" not in records[0].message + + +# --- log_exception --- + + +def test_log_exception_emits_error(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_exception(ValueError("boom"), context={"step": "validate"}) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert len(records) == 1 + rec = records[0] + assert rec.levelno == logging.ERROR + assert "ValueError" in rec.message + assert "boom" in rec.message + + +def test_log_exception_includes_stack_trace_when_logger_at_debug(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = BuckarooLoggingObserver( + LogConfig(level=LogLevel.DEBUG, destination=LogDestination.STDOUT) + ) + try: + raise RuntimeError("kaboom") + except RuntimeError as exc: + obs.log_exception(exc) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert len(records) == 1 + assert "Stack Trace" in records[0].message + assert "RuntimeError" in records[0].message + + +def test_log_exception_includes_kwargs_as_additional_info(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_exception(ValueError("boom"), request_id="req-9", attempt=2) + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "Additional Info" in rec.message + assert "req-9" in rec.message + assert "2" in rec.message + + +def test_log_payment_operation_minimal_omits_amount_currency_and_details(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_payment_operation("create", "ideal") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "Amount" not in rec.message + assert "Currency" not in rec.message + assert "Details" not in rec.message + + +def test_log_exception_omits_stack_trace_when_logger_above_debug(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = BuckarooLoggingObserver(LogConfig(level=LogLevel.INFO, destination=LogDestination.STDOUT)) + try: + raise RuntimeError("kaboom") + except RuntimeError as exc: + obs.log_exception(exc) + records = [r for r in caplog.records if r.name == "buckaroo_sdk"] + assert "Stack Trace" not in records[0].message + + +# --- log_payment_operation / log_config_change / log_info family --- + + +def test_log_payment_operation_masks_sensitive_kwargs(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_payment_operation( + "execute", + "creditcard", + amount=42.0, + currency="EUR", + cvc="999", + token="tok-123", + ) + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert rec.levelno == logging.INFO + assert "execute" in rec.message + assert "creditcard" in rec.message + assert "***MASKED***" in rec.message + assert "999" not in rec.message + assert "tok-123" not in rec.message + + +def test_log_config_change_masks_sensitive_values(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + # `_mask_sensitive_data` redacts string values that *contain* a sensitive + # keyword. Use values containing "cvc" so the masker fires on the value. + obs.log_config_change("payment_field", "old cvc value", "new cvc value") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert rec.levelno == logging.INFO + assert "***POTENTIALLY_SENSITIVE***" in rec.message + assert "old cvc value" not in rec.message + assert "new cvc value" not in rec.message + + +def test_log_config_change_with_extra_context(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_config_change("timeout", 10, 30, source="env") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "timeout" in rec.message + assert "env" in rec.message + + +@pytest.mark.parametrize( + "method,expected_level", + [ + ("log_info", logging.INFO), + ("log_debug", logging.DEBUG), + ("log_warning", logging.WARNING), + ("log_error", logging.ERROR), + ], +) +def test_log_info_family_masks_sensitive_kwargs(caplog, method, expected_level): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = BuckarooLoggingObserver( + LogConfig(level=LogLevel.DEBUG, destination=LogDestination.STDOUT) + ) + getattr(obs, method)("processing", cvc="999", request_id="req-1") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert rec.levelno == expected_level + assert "processing" in rec.message + assert "req-1" in rec.message + assert "999" not in rec.message + assert "***MASKED***" in rec.message + + +def test_log_info_without_kwargs_has_no_context_block(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + obs = _observer() + obs.log_info("hello") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert rec.message == "hello" + + +# --- LogConfig defaults --- + + +def test_log_config_defaults(): + cfg = LogConfig() + assert cfg.level is LogLevel.INFO + assert cfg.destination is LogDestination.BOTH + assert cfg.log_file == "buckaroo_sdk.log" + assert cfg.max_file_size == 10 * 1024 * 1024 + assert cfg.backup_count == 5 + assert cfg.mask_sensitive_data is True + + +# --- LogDestination handler installation --- + + +def test_destination_stdout_installs_only_stream_handler(): + obs = BuckarooLoggingObserver(LogConfig(destination=LogDestination.STDOUT)) + handlers = obs.logger.handlers + assert len(handlers) == 1 + assert isinstance(handlers[0], logging.StreamHandler) + assert not isinstance(handlers[0], RotatingFileHandler) + assert handlers[0].stream is sys.stdout + + +def test_destination_file_installs_only_rotating_file_handler(tmp_path): + log_path = str(tmp_path / "test.log") + obs = BuckarooLoggingObserver(LogConfig(destination=LogDestination.FILE, log_file=log_path)) + handlers = obs.logger.handlers + assert len(handlers) == 1 + assert isinstance(handlers[0], RotatingFileHandler) + + +def test_destination_both_installs_stream_and_rotating_file_handlers(tmp_path): + log_path = str(tmp_path / "test.log") + obs = BuckarooLoggingObserver(LogConfig(destination=LogDestination.BOTH, log_file=log_path)) + handler_types = {type(h) for h in obs.logger.handlers} + assert RotatingFileHandler in handler_types + # The non-rotating handler is a StreamHandler pointed at stdout. + stream_handlers = [ + h + for h in obs.logger.handlers + if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) + ] + assert len(stream_handlers) == 1 + assert stream_handlers[0].stream is sys.stdout + + +# --- create_logger --- + + +def test_create_logger_builds_configured_observer(tmp_path): + log_path = str(tmp_path / "configured.log") + obs = create_logger(level=LogLevel.WARNING, destination=LogDestination.FILE, log_file=log_path) + assert isinstance(obs, BuckarooLoggingObserver) + assert obs.config.level is LogLevel.WARNING + assert obs.config.destination is LogDestination.FILE + assert obs.config.log_file == log_path + + +def test_create_logger_passes_through_extra_kwargs(tmp_path): + log_path = str(tmp_path / "extra.log") + obs = create_logger( + destination=LogDestination.FILE, + log_file=log_path, + mask_sensitive_data=False, + backup_count=2, + ) + assert obs.config.mask_sensitive_data is False + assert obs.config.backup_count == 2 + + +# --- create_logger_from_env --- + + +# Kept despite autouse _clean_buckaroo_env — returns monkeypatch for .setenv() chaining in tests. +@pytest.fixture +def clean_env(monkeypatch): + for var in ( + "BUCKAROO_LOG_LEVEL", + "BUCKAROO_LOG_DESTINATION", + "BUCKAROO_LOG_FILE", + "BUCKAROO_LOG_MASK_SENSITIVE", + ): + monkeypatch.delenv(var, raising=False) + return monkeypatch + + +def test_create_logger_from_env_uses_defaults_when_no_env_vars(clean_env): + obs = create_logger_from_env() + assert obs.config.level is LogLevel.INFO + assert obs.config.destination is LogDestination.BOTH + assert obs.config.log_file == "buckaroo_sdk.log" + assert obs.config.mask_sensitive_data is True + + +def test_create_logger_from_env_reads_valid_env_vars(clean_env, tmp_path): + log_path = str(tmp_path / "env.log") + clean_env.setenv("BUCKAROO_LOG_LEVEL", "DEBUG") + clean_env.setenv("BUCKAROO_LOG_DESTINATION", "stdout") + clean_env.setenv("BUCKAROO_LOG_FILE", log_path) + clean_env.setenv("BUCKAROO_LOG_MASK_SENSITIVE", "true") + obs = create_logger_from_env() + assert obs.config.level is LogLevel.DEBUG + assert obs.config.destination is LogDestination.STDOUT + assert obs.config.log_file == log_path + assert obs.config.mask_sensitive_data is True + + +def test_create_logger_from_env_invalid_destination_falls_back_to_both(clean_env): + clean_env.setenv("BUCKAROO_LOG_DESTINATION", "invalid") + obs = create_logger_from_env() + assert obs.config.destination is LogDestination.BOTH + + +def test_create_logger_from_env_invalid_level_falls_back_to_info(clean_env): + clean_env.setenv("BUCKAROO_LOG_LEVEL", "NONSENSE") + obs = create_logger_from_env() + assert obs.config.level is LogLevel.INFO + + +def test_create_logger_from_env_mask_false_disables_masking(clean_env): + clean_env.setenv("BUCKAROO_LOG_MASK_SENSITIVE", "false") + obs = create_logger_from_env() + assert obs.config.mask_sensitive_data is False + + +# --- File rotation --- + + +def test_rotating_file_handler_rolls_at_max_file_size(tmp_path): + log_path = tmp_path / "rotate.log" + obs = BuckarooLoggingObserver( + LogConfig( + destination=LogDestination.FILE, + log_file=str(log_path), + max_file_size=512, + backup_count=3, + ) + ) + # Each log line is well over a few hundred bytes once the formatter is + # applied; write enough to roll past 512 bytes. + for i in range(50): + obs.log_info(f"padding line {i} " + ("x" * 50)) + # Flush + close so RotatingFileHandler finalises the rollover. + for handler in obs.logger.handlers: + handler.close() + backup = tmp_path / "rotate.log.1" + assert backup.exists() + + +# --- create_child_observer / ContextualLoggingObserver --- + + +def test_create_child_observer_returns_contextual_observer(): + parent = _observer() + child = parent.create_child_observer({"transaction_id": "abc"}) + assert isinstance(child, ContextualLoggingObserver) + assert child.parent is parent + assert child.context == {"transaction_id": "abc"} + + +def test_child_log_request_merges_context(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + parent = _observer() + child = parent.create_child_observer({"transaction_id": "abc"}) + child.log_request("POST", "https://example.test/x") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "abc" in rec.message + + +def test_child_log_response_merges_context(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + parent = _observer() + child = parent.create_child_observer({"transaction_id": "abc"}) + child.log_response(200) + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "abc" in rec.message + + +def test_child_log_exception_merges_context(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + parent = _observer() + child = parent.create_child_observer({"transaction_id": "abc"}) + child.log_exception(ValueError("nope"), context={"step": "x"}) + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + # Parent context flows into the `context` dict for log_exception. + assert "abc" in rec.message + assert "step" in rec.message + + +def test_child_log_exception_without_extra_context_still_includes_parent_context(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + parent = _observer() + child = parent.create_child_observer({"transaction_id": "abc"}) + child.log_exception(ValueError("nope")) + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "abc" in rec.message + + +def test_child_log_payment_operation_merges_context(caplog): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + parent = _observer() + child = parent.create_child_observer({"transaction_id": "abc"}) + child.log_payment_operation("execute", "ideal", amount=10, currency="EUR") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert "abc" in rec.message + assert "execute" in rec.message + assert "ideal" in rec.message + + +@pytest.mark.parametrize( + "method,expected_level", + [ + ("log_info", logging.INFO), + ("log_debug", logging.DEBUG), + ("log_warning", logging.WARNING), + ("log_error", logging.ERROR), + ], +) +def test_child_log_info_family_merges_context(caplog, method, expected_level): + caplog.set_level(logging.DEBUG, logger="buckaroo_sdk") + parent = BuckarooLoggingObserver( + LogConfig(level=LogLevel.DEBUG, destination=LogDestination.STDOUT) + ) + child = parent.create_child_observer({"transaction_id": "abc"}) + getattr(child, method)("hello") + rec = [r for r in caplog.records if r.name == "buckaroo_sdk"][0] + assert rec.levelno == expected_level + assert "abc" in rec.message + assert "hello" in rec.message diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/services/test_payment_service.py b/tests/unit/services/test_payment_service.py new file mode 100644 index 0000000..6cab765 --- /dev/null +++ b/tests/unit/services/test_payment_service.py @@ -0,0 +1,140 @@ +"""Tests for :class:`buckaroo.services.payment_service.PaymentService`. + +Verifies the service surface — builder selection, payload population via +``from_dict``, auto-detection from payload, and delegation to the +:class:`PaymentMethodFactory` — through the public API. The +``BuckarooClient`` is wired with a ``MockBuckaroo`` strategy (never +dispatched) so no network calls are required. +""" + +from __future__ import annotations + +import logging + +import pytest + +from buckaroo.builders.payments.credit_card_builder import CreditcardBuilder +from buckaroo.builders.payments.default_builder import DefaultBuilder +from buckaroo.builders.payments.ideal_builder import IdealBuilder +from buckaroo.services.payment_service import PaymentService + + +@pytest.fixture +def service(client): + return PaymentService(client) + + +class TestCreatePayment: + """``create_payment(method, params?)`` — builder selection + from_dict.""" + + def test_returns_builder_registered_for_method(self, service): + builder = service.create_payment("ideal") + assert isinstance(builder, IdealBuilder) + + def test_is_case_insensitive(self, service): + builder = service.create_payment("IDEAL") + assert isinstance(builder, IdealBuilder) + + def test_populates_builder_from_params_via_from_dict(self, service): + params = { + "currency": "EUR", + "amount": 12.5, + "description": "test", + "invoice": "INV-9", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create_payment("ideal", params) + + req = builder.build("Pay", validate=False) + assert req.currency == "EUR" + assert req.amount_debit == 12.5 + assert req.description == "test" + assert req.invoice == "INV-9" + assert req.return_url == "https://ex/ok" + + @pytest.mark.parametrize( + "params,method,expected_cls", + [ + (None, "creditcard", CreditcardBuilder), + ({}, "ideal", IdealBuilder), + ], + ) + def test_falsy_params_skip_from_dict(self, service, params, method, expected_cls): + builder = service.create_payment(method, params) + assert isinstance(builder, expected_cls) + # Falsy params must not populate required fields; build() surfaces that. + with pytest.raises(ValueError, match="Missing required fields"): + builder.build("Pay", validate=False) + + def test_unknown_method_returns_default_builder_and_logs_warning(self, service, caplog): + with caplog.at_level(logging.WARNING): + builder = service.create_payment("nope") + assert isinstance(builder, DefaultBuilder) + assert any("Unsupported payment method" in r.message for r in caplog.records) + + +class TestCreateAutoDetect: + """``create(payload)`` — method auto-detection routing.""" + + def test_detects_from_explicit_method_key(self, service): + payload = { + "method": "ideal", + "amount": 5.0, + "currency": "EUR", + "description": "autodetect", + "invoice": "INV-AD", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create(payload) + assert isinstance(builder, IdealBuilder) + req = builder.build("Pay", validate=False).to_dict() + assert req["AmountDebit"] == 5.0 + assert req["Currency"] == "EUR" + + def test_detects_from_services_service_list(self, service): + payload = { + "Services": {"ServiceList": [{"Name": "creditcard"}]}, + "amount": 7.0, + "currency": "EUR", + "description": "autodetect", + "invoice": "INV-AD", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create(payload) + assert isinstance(builder, CreditcardBuilder) + req = builder.build("Pay", validate=False).to_dict() + assert req["AmountDebit"] == 7.0 + + def test_empty_payload_falls_back_to_default_and_warns(self, service, caplog): + with caplog.at_level(logging.WARNING): + builder = service.create({}) + assert isinstance(builder, DefaultBuilder) + assert any("Cannot determine payment method" in r.message for r in caplog.records) + + +class TestFactoryDelegation: + """``get_available_methods`` / ``is_method_supported`` delegate to factory.""" + + def test_get_available_methods_includes_registered_methods(self, service): + methods = service.get_available_methods() + assert "ideal" in methods + assert "creditcard" in methods + assert "default" in methods + + def test_is_method_supported_true_for_registered(self, service): + assert service.is_method_supported("ideal") is True + + def test_is_method_supported_is_case_insensitive(self, service): + assert service.is_method_supported("IDEAL") is True + + def test_is_method_supported_false_for_unknown(self, service): + assert service.is_method_supported("nope") is False diff --git a/tests/unit/services/test_service_parameter_validator.py b/tests/unit/services/test_service_parameter_validator.py new file mode 100644 index 0000000..3509e61 --- /dev/null +++ b/tests/unit/services/test_service_parameter_validator.py @@ -0,0 +1,497 @@ +"""Tests for :class:`buckaroo.services.service_parameter_validator.ServiceParameterValidator`. + +Exercises the rules engine through real builders' rule tables loaded via +``get_allowed_service_parameters``. The parameterised tests intentionally +walk the registered builders so rule-table edits surface in assertions +automatically; explicit tests pin a few load-bearing edge cases. + +The validator's conceptual public surface: + +- ``validate(...)`` -> ``validate_all_parameters(..., strict=True)`` +- ``filter(...)`` -> ``validate_and_filter_parameters(...)`` +- ``get_allowed_parameters(...)`` -> ``get_parameter_info(...)`` +""" + +from __future__ import annotations + +from typing import Any, Dict +from unittest.mock import MagicMock + +import pytest + +from buckaroo.builders.payments.credit_card_builder import CreditcardBuilder +from buckaroo.builders.payments.ideal_builder import IdealBuilder +from buckaroo.builders.payments.ideal_qr_builder import IdealQrBuilder +from buckaroo.builders.payments.klarna_builder import KlarnaBuilder +from buckaroo.builders.payments.sofort_builder import SofortBuilder +from buckaroo.exceptions._parameter_validation_error import ( + ParameterValidationError, + RequiredParameterMissingError, +) +from buckaroo.models.payment_request import Parameter +from buckaroo.services.service_parameter_validator import ServiceParameterValidator + + +# --------------------------------------------------------------------------- +# Helpers + + +def _validator_for(builder_cls) -> ServiceParameterValidator: + """Build a :class:`ServiceParameterValidator` around a real builder.""" + builder = builder_cls(MagicMock()) + return ServiceParameterValidator(builder) + + +def _stub_builder(allowed: Dict[str, Dict[str, Any]], service_name: str = "stub"): + """Minimal fake builder exposing the two hooks the validator needs.""" + builder = MagicMock() + builder.get_service_name.return_value = service_name + builder.get_allowed_service_parameters.side_effect = lambda action="Pay": allowed.get( + action, {} + ) + return builder + + +# --------------------------------------------------------------------------- +# normalize_parameter_name — strips dots, underscores, and casing. + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("issuer", "issuer"), + ("Issuer", "issuer"), + ("service_parameters.issuer", "issuer"), + ("encryptedcarddata", "encryptedcarddata"), + ("Encrypted_Card_Data", "encryptedcarddata"), + ], +) +def test_normalize_parameter_name_handles_case_underscores_and_dots(raw, expected): + validator = _validator_for(IdealBuilder) + assert validator.normalize_parameter_name(raw) == expected + + +# --------------------------------------------------------------------------- +# normalize_parameter_value — string booleans convert back to real booleans. + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("true", True), + ("True", True), + ("FALSE", False), + ("false", False), + ("hello", "hello"), + ("", ""), + ], +) +def test_normalize_parameter_value_maps_string_booleans(raw, expected): + validator = _validator_for(IdealBuilder) + assert validator.normalize_parameter_value(raw) == expected + + +# --------------------------------------------------------------------------- +# validate_parameter_type — no-ops and explicit type checks. + + +def test_validate_parameter_type_no_type_key_is_noop(): + validator = _validator_for(IdealBuilder) + # No 'type' in config means no validation; should not raise. + validator.validate_parameter_type("x", object(), {}) + + +@pytest.mark.parametrize("structured", [list, dict]) +def test_validate_parameter_type_skips_list_and_dict_expected_types(structured): + validator = _validator_for(IdealBuilder) + # Grouped params pass through without per-field type checks. + validator.validate_parameter_type("x", "anything", {"type": structured}) + + +def test_validate_parameter_type_string_mismatch_raises(): + validator = _validator_for(IdealBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_parameter_type("issuer", 1234, {"type": str}) + assert "issuer" in str(exc.value) + assert exc.value.parameter_name == "issuer" + assert exc.value.service_name == "ideal" + assert exc.value.expected_type == "str" + + +def test_validate_parameter_type_bool_string_true_passes(): + validator = _validator_for(IdealQrBuilder) + # 'true'/'false' strings are accepted when expected type is bool. + validator.validate_parameter_type("isOneOff", "true", {"type": bool}) + validator.validate_parameter_type("isOneOff", "FALSE", {"type": bool}) + + +def test_validate_parameter_type_bool_non_boolean_string_raises(): + validator = _validator_for(IdealQrBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_parameter_type("isOneOff", "maybe", {"type": bool}) + assert "boolean" in str(exc.value) + assert exc.value.expected_type == "bool" + + +def test_validate_parameter_type_bool_non_string_non_bool_raises(): + validator = _validator_for(IdealQrBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_parameter_type("isOneOff", 123, {"type": bool}) + assert "of type bool" in str(exc.value) + + +def test_validate_parameter_type_tuple_accepts_any_matching_type(): + validator = _validator_for(SofortBuilder) + validator.validate_parameter_type("savetoken", True, {"type": (str, bool)}) + validator.validate_parameter_type("savetoken", "opaque", {"type": (str, bool)}) + + +def test_validate_parameter_type_tuple_with_bool_accepts_true_false_string(): + validator = _validator_for(SofortBuilder) + validator.validate_parameter_type("savetoken", "true", {"type": (str, bool)}) + + +def test_validate_parameter_type_tuple_with_bool_rejects_non_boolean_string(): + # Tuple (int, bool) forbids arbitrary strings and only accepts + # 'true'/'false' strings via the bool-in-tuple path. + validator = _validator_for(SofortBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_parameter_type("flag", "nope", {"type": (int, bool)}) + msg = str(exc.value) + assert "flag" in msg + assert "one of types" in msg or "'true'/'false'" in msg + + +def test_validate_parameter_type_tuple_with_bool_no_str_accepts_true_string(): + # ``(int, bool)`` — a raw bool isn't a match (True isn't int-compatible + # for our purposes) but ``'true'`` / ``'false'`` still round-trip via + # the bool-in-tuple branch and must pass silently. + validator = _validator_for(SofortBuilder) + validator.validate_parameter_type("flag", "true", {"type": (int, bool)}) + validator.validate_parameter_type("flag", "FALSE", {"type": (int, bool)}) + + +def test_validate_parameter_type_tuple_without_bool_rejects_mismatch(): + validator = _validator_for(SofortBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_parameter_type("count", "not-an-int", {"type": (int, float)}) + assert "count" in str(exc.value) + assert "got str" in str(exc.value) + + +# --------------------------------------------------------------------------- +# validate_single_parameter — action lookup and disallowed keys. + + +def test_validate_single_parameter_rejects_unknown_key(): + validator = _validator_for(IdealBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_single_parameter("rogue", "x", action="Pay") + assert "rogue" in str(exc.value) + assert exc.value.action == "Pay" + + +def test_validate_single_parameter_accepts_known_key_with_correct_type(): + validator = _validator_for(IdealBuilder) + # issuer: str, known. No exception. + validator.validate_single_parameter("issuer", "INGBNL2A", action="Pay") + + +# --------------------------------------------------------------------------- +# validate_required_parameters — required presence, grouped support. + + +def test_validate_required_parameters_returns_silently_when_present(): + validator = _validator_for(CreditcardBuilder) + params = [Parameter(name="EncryptedCardData", value="blob")] + validator.validate_required_parameters(params, action="PayEncrypted") + + +def test_validate_required_parameters_raises_required_missing_for_single_gap(): + validator = _validator_for(CreditcardBuilder) + with pytest.raises(RequiredParameterMissingError) as exc: + validator.validate_required_parameters([], action="PayEncrypted") + assert exc.value.parameter_name == "encryptedcarddata" + assert "encryptedcarddata" in str(exc.value) + assert exc.value.service_name == "CreditCard" + + +def test_validate_required_parameters_raises_validation_error_for_multiple_gaps(): + validator = _validator_for(KlarnaBuilder) + # Klarna Pay requires billingCustomer, shippingCustomer, article — all missing. + with pytest.raises(ParameterValidationError) as exc: + validator.validate_required_parameters([], action="Pay") + # Multiple missing -> plain ParameterValidationError (not Required...), + # and the message lists each missing name. + assert not isinstance(exc.value, RequiredParameterMissingError) + msg = str(exc.value) + assert "billingCustomer" in msg + assert "shippingCustomer" in msg + assert "article" in msg + + +def test_validate_required_parameters_accepts_grouped_group_type_as_satisfying_requirement(): + validator = _validator_for(KlarnaBuilder) + params = [ + Parameter(name="FirstName", value="Jane", group_type="billingCustomer"), + Parameter(name="FirstName", value="John", group_type="shippingCustomer"), + Parameter(name="Identifier", value="A1", group_type="article"), + ] + # All three required group_types are present via grouped parameters. + validator.validate_required_parameters(params, action="Pay") + + +def test_validate_required_parameters_supports_dot_notation_required_keys(): + # Synthetic rule table with a dot-notation required key. + builder = _stub_builder( + { + "Pay": { + "service_parameters.issuer": { + "type": str, + "required": True, + } + } + }, + service_name="dotted", + ) + validator = ServiceParameterValidator(builder) + + with pytest.raises(RequiredParameterMissingError) as exc: + validator.validate_required_parameters([], action="Pay") + # The raised name is the *last* segment of the dot-notation key. + assert exc.value.parameter_name == "issuer" + + # Providing it under the simple name satisfies the requirement. + validator.validate_required_parameters( + [Parameter(name="Issuer", value="INGBNL2A")], action="Pay" + ) + + +# --------------------------------------------------------------------------- +# validate_and_filter_parameters — filter semantics & grouped handling. + + +def test_filter_drops_unknown_keys_and_preserves_known_ones(): + validator = _validator_for(IdealBuilder) + good = Parameter(name="Issuer", value="INGBNL2A") + garbage = Parameter(name="NotARealParam", value="nope") + + result = validator.validate_and_filter_parameters([good, garbage], action="Pay") + + assert good in result + assert garbage not in result + + +def test_filter_returns_empty_list_when_given_empty_list(): + validator = _validator_for(IdealBuilder) + assert validator.validate_and_filter_parameters([], action="Pay") == [] + + +def test_filter_drops_unknown_sofort_key(): + # ``customerbic`` is not in Sofort's allowed rule set; it must be dropped. + validator = _validator_for(SofortBuilder) + ok = Parameter(name="SaveToken", value="true") + bad_type = Parameter(name="customerbic", value="INGBNL2A") # not in allowed + + result = validator.validate_and_filter_parameters([ok, bad_type], action="Pay") + assert ok in result + assert bad_type not in result + + +def test_filter_preserves_grouped_parameters_when_group_type_is_allowed(): + validator = _validator_for(KlarnaBuilder) + article = Parameter(name="Identifier", value="SKU-1", group_type="article", group_id="1") + result = validator.validate_and_filter_parameters([article], action="Pay") + assert article in result + + +def test_filter_drops_grouped_parameters_when_group_type_is_not_allowed(): + validator = _validator_for(IdealBuilder) + # iDEAL Pay has no grouped params at all; ``article`` is an unknown group. + article = Parameter(name="Identifier", value="SKU-1", group_type="article", group_id="1") + result = validator.validate_and_filter_parameters([article], action="Pay") + assert article not in result + + +def test_filter_drops_service_params_marker_when_rule_is_top_level(): + # ``issuer`` is a top-level rule on iDEAL; providing it via the + # service_parameters marker must be dropped. + validator = _validator_for(IdealBuilder) + misplaced = Parameter(name="Issuer", value="INGBNL2A", group_type="__from_service_params__") + result = validator.validate_and_filter_parameters([misplaced], action="Pay") + assert misplaced not in result + + +def test_filter_drops_top_level_param_when_rule_requires_service_params(): + builder = _stub_builder( + { + "Pay": { + "service_parameters.issuer": {"type": str, "required": False}, + } + } + ) + validator = ServiceParameterValidator(builder) + + misplaced = Parameter(name="Issuer", value="INGBNL2A") + result = validator.validate_and_filter_parameters([misplaced], action="Pay") + assert misplaced not in result + + +def test_filter_accepts_dot_notation_param_when_from_service_params(): + builder = _stub_builder( + { + "Pay": { + "service_parameters.issuer": {"type": str, "required": False}, + } + } + ) + validator = ServiceParameterValidator(builder) + + ok = Parameter(name="Issuer", value="INGBNL2A", group_type="__from_service_params__") + result = validator.validate_and_filter_parameters([ok], action="Pay") + assert ok in result + + +def test_filter_drops_parameter_whose_value_fails_type_check(): + # A rule with type=int should reject a non-numeric string value. + builder = _stub_builder({"Pay": {"count": {"type": int, "required": False}}}) + validator = ServiceParameterValidator(builder) + + # Parameter.value is a string; normalize_parameter_value returns it + # unchanged (not 'true'/'false'), so the int type-check below will fail. + bad = Parameter(name="count", value="not-an-int") + result = validator.validate_and_filter_parameters([bad], action="Pay") + assert bad not in result + + +# --------------------------------------------------------------------------- +# validate_all_parameters — strict vs filter mode. + + +def test_validate_all_strict_returns_params_on_success(): + validator = _validator_for(IdealBuilder) + params = [Parameter(name="Issuer", value="INGBNL2A")] + assert validator.validate_all_parameters(params, action="Pay", strict=True) == params + + +def test_validate_all_strict_raises_on_required_missing(): + validator = _validator_for(CreditcardBuilder) + with pytest.raises(RequiredParameterMissingError): + validator.validate_all_parameters([], action="PayEncrypted", strict=True) + + +def test_validate_all_strict_raises_on_unknown_param(): + validator = _validator_for(IdealBuilder) + with pytest.raises(ParameterValidationError) as exc: + validator.validate_all_parameters( + [Parameter(name="Rogue", value="x")], + action="Pay", + strict=True, + ) + # The error must name the offending parameter. + assert exc.value.parameter_name == "Rogue" + + +def test_validate_all_strict_raises_on_type_mismatch_for_known_param(): + # Numeric-typed rule with a string value that cannot round-trip to bool. + builder = _stub_builder({"Pay": {"count": {"type": int, "required": False}}}) + validator = ServiceParameterValidator(builder) + with pytest.raises(ParameterValidationError): + validator.validate_all_parameters( + [Parameter(name="count", value="nope")], + action="Pay", + strict=True, + ) + + +def test_validate_all_non_strict_filters_invalid_and_checks_required(capsys): + validator = _validator_for(IdealBuilder) + good = Parameter(name="Issuer", value="INGBNL2A") + bad = Parameter(name="Rogue", value="x") + result = validator.validate_all_parameters([good, bad], action="Pay", strict=False) + assert result == [good] + # Filter prints a warning; drain it so it doesn't pollute other captures. + capsys.readouterr() + + +# --------------------------------------------------------------------------- +# get_parameter_info / is_parameter_allowed / get_normalized_parameter_name. + + +def test_get_parameter_info_returns_rule_table_for_action(): + validator = _validator_for(CreditcardBuilder) + info = validator.get_parameter_info("PayEncrypted") + assert "encryptedcarddata" in info + assert info["encryptedcarddata"]["required"] is True + + +def test_is_parameter_allowed_case_insensitive_and_underscore_tolerant(): + validator = _validator_for(IdealBuilder) + assert validator.is_parameter_allowed("issuer", "Pay") is True + assert validator.is_parameter_allowed("Issuer", "Pay") is True + assert validator.is_parameter_allowed("Iss_uer", "Pay") is True + assert validator.is_parameter_allowed("nope", "Pay") is False + + +def test_get_normalized_parameter_name_returns_empty_when_unknown(): + validator = _validator_for(IdealBuilder) + assert validator.get_normalized_parameter_name("issuer", "Pay") == "issuer" + assert validator.get_normalized_parameter_name("nope", "Pay") == "" + + +# --------------------------------------------------------------------------- +# Action-name case-insensitivity (via the builder's own rule table). + + +@pytest.mark.parametrize("action", ["Pay", "pay", "PAY"]) +def test_action_lookup_is_case_insensitive(action): + validator = _validator_for(IdealBuilder) + assert validator.is_parameter_allowed("issuer", action=action) is True + # Required-validation also honours case; ideal has no required params. + validator.validate_required_parameters([], action=action) + + +@pytest.mark.parametrize("action", ["PayEncrypted", "payencrypted", "PAYENCRYPTED"]) +def test_creditcard_payencrypted_action_matches_case_insensitively(action): + validator = _validator_for(CreditcardBuilder) + with pytest.raises(RequiredParameterMissingError): + validator.validate_required_parameters([], action=action) + + +# --------------------------------------------------------------------------- +# Rule-table round-trip: every registered builder's own rules validate +# against themselves. Loads the rule table from the validator itself so +# a source edit reflects in assertions automatically. + + +_BUILDERS_WITH_PAY_RULES = [ + IdealBuilder, + SofortBuilder, + KlarnaBuilder, + IdealQrBuilder, +] + +# Subset: only builders whose Pay spec has at least one required field. +_BUILDERS_WITH_REQUIRED_PAY_PARAMS = [ + KlarnaBuilder, +] + + +@pytest.mark.parametrize("builder_cls", _BUILDERS_WITH_PAY_RULES) +def test_every_allowed_param_name_roundtrips_through_is_parameter_allowed(builder_cls): + validator = _validator_for(builder_cls) + rules = validator.get_parameter_info("Pay") + for name in rules: + assert validator.is_parameter_allowed(name, action="Pay") is True + + +@pytest.mark.parametrize("builder_cls", _BUILDERS_WITH_REQUIRED_PAY_PARAMS) +def test_every_required_param_missing_triggers_required_error(builder_cls): + validator = _validator_for(builder_cls) + required = { + name for name, cfg in validator.get_parameter_info("Pay").items() if cfg.get("required") + } + assert required, f"{builder_cls.__name__} should have required Pay params" + + with pytest.raises(ParameterValidationError): + validator.validate_required_parameters([], action="Pay") diff --git a/tests/unit/services/test_solution_service.py b/tests/unit/services/test_solution_service.py new file mode 100644 index 0000000..0ca96db --- /dev/null +++ b/tests/unit/services/test_solution_service.py @@ -0,0 +1,174 @@ +"""Tests for :class:`buckaroo.services.solution_service.SolutionService`. + +Mirrors ``test_payment_service.py``: the ``BuckarooClient`` is wired with a +``MockBuckaroo`` strategy (never dispatched) so the service can be exercised +without any network. Asserts builder type and payload population through the +public interface only. +""" + +from __future__ import annotations + +import logging + +import pytest + +from buckaroo.builders.solutions.default_builder import DefaultBuilder +from buckaroo.builders.solutions.solution_builder import SolutionBuilder +from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder +from buckaroo.factories.solution_method_factory import SolutionMethodFactory +from buckaroo.services.solution_service import SolutionService + + +@pytest.fixture +def service(client): + return SolutionService(client) + + +class TestCreateSolution: + """``create_solution(method, params?)`` — builder selection + from_dict.""" + + def test_returns_builder_registered_for_method(self, service): + builder = service.create_solution("subscription") + assert isinstance(builder, SubscriptionBuilder) + + def test_is_case_insensitive(self, service): + builder = service.create_solution("SUBSCRIPTION") + assert isinstance(builder, SubscriptionBuilder) + + def test_populates_builder_from_params_via_from_dict(self, service): + params = { + "currency": "EUR", + "amount": 12.5, + "description": "start sub", + "invoice": "INV-9", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create_solution("subscription", params) + + assert isinstance(builder, SubscriptionBuilder) + req = builder.build("Pay", validate=False) + assert req.currency == "EUR" + assert req.amount_debit == 12.5 + assert req.description == "start sub" + assert req.invoice == "INV-9" + assert req.return_url == "https://ex/ok" + + @pytest.mark.parametrize("params", [None, {}]) + def test_falsy_params_skip_from_dict(self, service, params): + builder = service.create_solution("subscription", params) + assert isinstance(builder, SubscriptionBuilder) + # Falsy params must not populate any fields; request dict shows it. + req = builder.build("Pay", validate=False).to_dict() + assert req["Currency"] is None + assert req["AmountDebit"] is None + + def test_unknown_method_returns_default_builder_and_logs_warning(self, service, caplog): + with caplog.at_level(logging.WARNING): + builder = service.create_solution("nope") + assert isinstance(builder, DefaultBuilder) + assert any("Unsupported payment method" in r.message for r in caplog.records) + + def test_unknown_method_with_params_still_populates_default_builder(self, service): + params = { + "currency": "USD", + "amount": 7.0, + "description": "fallback", + "invoice": "INV-FB", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create_solution("unknown-thing", params) + assert isinstance(builder, DefaultBuilder) + req = builder.build("Pay", validate=False) + assert req.currency == "USD" + assert req.amount_debit == 7.0 + + +class TestCreateAutoDetect: + """``create(payload)`` — method auto-detection routing for solutions.""" + + def test_detects_from_explicit_method_key(self, service): + payload = { + "method": "subscription", + "currency": "EUR", + "amount": 3.5, + "description": "autodetect sub", + "invoice": "INV-SUB", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create(payload) + assert isinstance(builder, SubscriptionBuilder) + req = builder.build("Pay", validate=False).to_dict() + assert req["Currency"] == "EUR" + assert req["AmountDebit"] == 3.5 + + def test_method_key_is_case_insensitive(self, service): + builder = service.create({"method": "SUBSCRIPTION"}) + assert isinstance(builder, SubscriptionBuilder) + + def test_empty_payload_falls_back_to_default_builder(self, service, caplog): + with caplog.at_level(logging.WARNING): + builder = service.create({}) + assert isinstance(builder, DefaultBuilder) + assert any("Unsupported payment method" in r.message for r in caplog.records) + + def test_payload_without_method_key_uses_default_builder(self, service): + payload = { + "currency": "EUR", + "amount": 1.0, + "description": "no method", + "invoice": "INV-NM", + "return_url": "https://ex/ok", + "return_url_cancel": "https://ex/cancel", + "return_url_error": "https://ex/error", + "return_url_reject": "https://ex/reject", + } + builder = service.create(payload) + assert isinstance(builder, DefaultBuilder) + req = builder.build("Pay", validate=False).to_dict() + assert req["Currency"] == "EUR" + assert req["AmountDebit"] == 1.0 + + +class TestFactoryDelegation: + """``get_available_methods`` / ``is_method_supported`` delegate to factory.""" + + def test_get_available_methods_matches_factory(self, service): + assert service.get_available_methods() == (SolutionMethodFactory.get_available_methods()) + + def test_get_available_methods_includes_subscription(self, service): + assert "subscription" in service.get_available_methods() + + def test_is_method_supported_true_for_registered(self, service): + assert service.is_method_supported("subscription") is True + + def test_is_method_supported_is_case_insensitive(self, service): + assert service.is_method_supported("SUBSCRIPTION") is True + + def test_is_method_supported_false_for_unknown(self, service): + assert service.is_method_supported("nope") is False + + +class TestSolutionBuilderInheritance: + """Every dispatch path yields a ``SolutionBuilder`` subclass.""" + + @pytest.mark.parametrize( + "dispatch", + [ + lambda s: s.create_solution("subscription"), + lambda s: s.create_solution("unknown"), + lambda s: s.create({"method": "subscription"}), + lambda s: s.create({}), + ], + ids=["known", "unknown", "autodetect-known", "autodetect-empty"], + ) + def test_returns_solution_builder(self, service, dispatch): + assert isinstance(dispatch(service), SolutionBuilder) diff --git a/tests/unit/support/__init__.py b/tests/unit/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/support/test_builders.py b/tests/unit/support/test_builders.py new file mode 100644 index 0000000..4935886 --- /dev/null +++ b/tests/unit/support/test_builders.py @@ -0,0 +1,60 @@ +"""Tests for tests.support.builders.make_test_builder.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.capabilities.authorize_capture_capable import ( + AuthorizeCaptureCapable, +) +from buckaroo.builders.payments.capabilities.encrypted_pay_capable import ( + EncryptedPayCapable, +) +from tests.support.builders import make_test_builder + + +def _client(): + return MagicMock() + + +def test_returns_payment_builder_subclass_instance_bound_to_client(): + client = _client() + builder = make_test_builder(client) + + assert isinstance(builder, PaymentBuilder) + assert builder._client is client + + +def test_service_name_kwarg_sets_service_name_attr_and_getter(): + builder = make_test_builder(_client(), service_name="creditcard") + + assert builder._serviceName == "creditcard" + assert builder.get_service_name() == "creditcard" + + +def test_allowed_params_returned_per_action_empty_dict_for_unknown(): + allowed = { + "Pay": {"a": {"type": str}, "b": {"type": str}}, + "Refund": {"x": {"type": str}}, + } + builder = make_test_builder(_client(), allowed_params=allowed) + + assert builder.get_allowed_service_parameters("Pay") == allowed["Pay"] + assert builder.get_allowed_service_parameters("Refund") == allowed["Refund"] + assert builder.get_allowed_service_parameters("Unknown") == {} + + +def test_capabilities_mix_in_their_methods(): + builder = make_test_builder( + _client(), + capabilities=(EncryptedPayCapable, AuthorizeCaptureCapable), + ) + + assert isinstance(builder, EncryptedPayCapable) + assert isinstance(builder, AuthorizeCaptureCapable) + # Concrete methods from the mixins must be present and callable + assert callable(getattr(builder, "payEncrypted")) + assert callable(getattr(builder, "authorize")) + assert callable(getattr(builder, "capture")) + assert callable(getattr(builder, "cancelAuthorize")) diff --git a/tests/unit/support/test_helpers_module.py b/tests/unit/support/test_helpers_module.py new file mode 100644 index 0000000..962f12b --- /dev/null +++ b/tests/unit/support/test_helpers_module.py @@ -0,0 +1,102 @@ +"""Unit tests for tests.support.helpers.Helpers.""" + +from __future__ import annotations + +import re + +from tests.support.helpers import Helpers + + +class TestGenerateTransactionKey: + def test_returns_32_char_uppercase_hex(self) -> None: + key = Helpers.generate_transaction_key() + + assert len(key) == 32 + assert re.fullmatch(r"[0-9A-F]{32}", key) is not None + + def test_returns_unique_values(self) -> None: + assert Helpers.generate_transaction_key() != Helpers.generate_transaction_key() + + +class TestSuccessResponse: + def test_status_code_is_190(self) -> None: + response = Helpers.success_response() + + assert response["Status"]["Code"]["Code"] == 190 + assert response["Status"]["Code"]["Description"] == "Success" + + def test_includes_buckaroo_shaped_defaults(self) -> None: + response = Helpers.success_response() + + assert response["Status"]["SubCode"] == { + "Code": "S001", + "Description": "Transaction successful", + } + assert response["RequiredAction"] is None + assert response["Services"] == [] + assert response["ServiceCode"] == "creditcard" + assert response["IsTest"] is True + assert response["Currency"] == "EUR" + assert response["AmountDebit"] == 10.00 + assert response["Invoice"].startswith("INV-") + assert re.fullmatch(r"[0-9A-F]{32}", response["Key"]) is not None + # ISO-8601 datetime, second precision + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", response["Status"]["DateTime"]) + + def test_overrides_shallow_merge_top_level(self) -> None: + response = Helpers.success_response(overrides={"Key": "X"}) + + assert response["Key"] == "X" + # The rest of the dict is untouched. + assert response["Status"]["Code"]["Code"] == 190 + assert response["Currency"] == "EUR" + + def test_overrides_defaults_to_none(self) -> None: + # Passing None (the default) must behave like no overrides. + response = Helpers.success_response(overrides=None) + + assert response["Status"]["Code"]["Code"] == 190 + + +class TestFailedResponse: + def test_status_code_is_490(self) -> None: + response = Helpers.failed_response() + + assert response["Status"]["Code"]["Code"] == 490 + assert response["Status"]["Code"]["Description"] == "Failed" + + def test_default_error_message(self) -> None: + response = Helpers.failed_response() + + assert response["Status"]["SubCode"] == { + "Code": "F001", + "Description": "Transaction failed", + } + + def test_custom_error_in_subcode_description(self) -> None: + response = Helpers.failed_response("oops") + + assert response["Status"]["SubCode"]["Description"] == "oops" + assert response["Status"]["SubCode"]["Code"] == "F001" + + def test_inherits_success_response_shape(self) -> None: + response = Helpers.failed_response("boom") + + # Non-Status fields come from success_response. + assert response["ServiceCode"] == "creditcard" + assert response["Currency"] == "EUR" + assert response["AmountDebit"] == 10.00 + assert response["IsTest"] is True + + def test_overrides_respected(self) -> None: + response = Helpers.failed_response("x", overrides={"Currency": "USD"}) + + assert response["Currency"] == "USD" + assert response["Status"]["Code"]["Code"] == 490 + assert response["Status"]["SubCode"]["Description"] == "x" + + def test_overrides_defaults_to_none(self) -> None: + response = Helpers.failed_response("x", overrides=None) + + assert response["Status"]["Code"]["Code"] == 490 + assert response["Currency"] == "EUR" diff --git a/tests/unit/support/test_mock_buckaroo.py b/tests/unit/support/test_mock_buckaroo.py new file mode 100644 index 0000000..c22e180 --- /dev/null +++ b/tests/unit/support/test_mock_buckaroo.py @@ -0,0 +1,122 @@ +"""Tests for tests.support.mock_buckaroo.""" + +import pytest + +from buckaroo.http.strategies.http_strategy import HttpStrategy +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest + + +def test_mock_buckaroo_is_http_strategy_subclass(): + assert issubclass(MockBuckaroo, HttpStrategy) + assert isinstance(MockBuckaroo(), HttpStrategy) + + +def test_is_available_and_get_name(): + mock = MockBuckaroo() + assert mock.is_available() is True + assert mock.get_name() == "mock" + + +def test_configure_accepts_any_kwargs(): + mock = MockBuckaroo() + mock.configure(timeout=5, retry_attempts=1) # must not raise + + +def test_queue_and_queue_many_return_self(): + mock = MockBuckaroo() + req = BuckarooMockRequest.json("POST", "https://x/a", {}) + assert mock.queue(req) is mock + assert mock.queue_many([BuckarooMockRequest.json("POST", "https://x/b", {})]) is mock + + +def test_request_consumes_queued_response(): + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest.json("POST", "https://x/a", {"ok": True})) + response = mock.request("POST", "https://x/a", data="payload") + assert response.status_code == 200 + assert response.success is True + assert response.json() == {"ok": True} + + +def test_request_empty_queue_raises_assertion_error(): + mock = MockBuckaroo() + with pytest.raises(AssertionError) as ei: + mock.request("POST", "https://x/a") + msg = str(ei.value) + assert "POST" in msg + assert "https://x/a" in msg + + +def test_request_method_mismatch_raises_with_expected_and_actual(): + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest.json("POST", "https://x/a", {})) + with pytest.raises(AssertionError) as ei: + mock.request("GET", "https://x/a") + msg = str(ei.value) + assert "expected" in msg.lower() + assert "POST https://x/a" in msg + assert "GET https://x/a" in msg + + +def test_request_url_mismatch_raises_with_expected_and_actual(): + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest.json("POST", "https://x/a", {})) + with pytest.raises(AssertionError) as ei: + mock.request("POST", "https://x/other") + msg = str(ei.value) + assert "https://x/a" in msg + assert "https://x/other" in msg + + +def test_request_with_exception_raises_that_exception(): + mock = MockBuckaroo() + err = RuntimeError("boom") + mock.queue(BuckarooMockRequest.json("POST", "https://x/a", {}).with_exception(err)) + with pytest.raises(RuntimeError) as ei: + mock.request("POST", "https://x/a") + assert ei.value is err + + +def test_assert_all_consumed_passes_on_empty(): + mock = MockBuckaroo() + mock.assert_all_consumed() # no raise + + +def test_assert_all_consumed_raises_with_leftover_count(): + mock = MockBuckaroo() + mock.queue_many( + [ + BuckarooMockRequest.json("POST", "https://x/a", {}), + BuckarooMockRequest.json("POST", "https://x/b", {}), + ] + ) + with pytest.raises(AssertionError) as ei: + mock.assert_all_consumed() + assert "2" in str(ei.value) + + +def test_reset_clears_queue(): + mock = MockBuckaroo() + mock.queue(BuckarooMockRequest.json("POST", "https://x/a", {})) + mock.reset() + mock.assert_all_consumed() # no raise + + +def test_requests_consume_in_order(): + mock = MockBuckaroo() + mock.queue_many( + [ + BuckarooMockRequest.json("POST", "https://x/a", {"n": 1}), + BuckarooMockRequest.json("POST", "https://x/b", {"n": 2}), + ] + ) + r1 = mock.request("POST", "https://x/a") + r2 = mock.request("POST", "https://x/b") + assert r1.json() == {"n": 1} + assert r2.json() == {"n": 2} + mock.assert_all_consumed() + + +def test_mock_strategy_fixture_yields_fresh_instance(mock_strategy): + assert isinstance(mock_strategy, MockBuckaroo) diff --git a/tests/unit/support/test_mock_request.py b/tests/unit/support/test_mock_request.py new file mode 100644 index 0000000..8ae92a5 --- /dev/null +++ b/tests/unit/support/test_mock_request.py @@ -0,0 +1,83 @@ +"""Tests for tests.support.mock_request.""" + +from tests.support.mock_request import BuckarooMockRequest + + +def test_json_factory_stores_method_url_payload_status_headers(): + req = BuckarooMockRequest.json( + "post", + "https://x/json/Pay", + {"ok": True}, + status=201, + headers={"X-Test": "1"}, + ) + response = req.to_http_response() + assert response.status_code == 201 + assert response.success is True + assert response.headers["X-Test"] == "1" + assert response.headers["Content-Type"] == "application/json" + assert '"ok": true' in response.text + + +def test_exact_url_match(): + req = BuckarooMockRequest.json("POST", "https://x/json/Pay", {}) + assert req.matches("POST", "https://x/json/Pay") is True + assert req.matches("POST", "https://x/json/Pay/extra") is False + + +def test_method_is_case_insensitive(): + req = BuckarooMockRequest.json("post", "https://x/a", {}) + assert req.matches("post", "https://x/a") is True + assert req.matches("POST", "https://x/a") is True + + +def test_method_mismatch(): + req = BuckarooMockRequest.json("POST", "https://x/a", {}) + assert req.matches("GET", "https://x/a") is False + + +def test_wildcard_url_match(): + req = BuckarooMockRequest.json("POST", "*/json/Transaction*", {}) + assert req.matches("POST", "https://x/json/Transaction") is True + assert req.matches("POST", "https://x/json/TransactionStatus") is True + assert req.matches("POST", "https://x/other") is False + + +def test_regex_url_match(): + req = BuckarooMockRequest.json("POST", r"/^https:\/\/x\/.*\/Pay$/", {}) + assert req.matches("POST", "https://x/json/Pay") is True + assert req.matches("POST", "https://x/json/Refund") is False + + +def test_mismatch_message_contains_expected_and_actual(): + req = BuckarooMockRequest.json("POST", "https://x/a", {}) + msg = req.mismatch_message("GET", "https://y/b") + assert "POST https://x/a" in msg + assert "GET https://y/b" in msg + + +def test_with_exception_returns_self_and_stores(): + err = RuntimeError("boom") + req = BuckarooMockRequest.json("POST", "https://x/a", {}) + result = req.with_exception(err) + assert result is req + assert req.exception is err + + +def test_no_exception_by_default(): + req = BuckarooMockRequest.json("POST", "https://x/a", {}) + assert req.exception is None + + +def test_non_success_status_sets_success_false(): + req = BuckarooMockRequest.json("POST", "https://x/a", {"err": "bad"}, status=500) + response = req.to_http_response() + assert response.status_code == 500 + assert response.success is False + + +def test_custom_header_does_not_override_content_type_when_absent(): + req = BuckarooMockRequest.json("POST", "https://x/a", {}, headers={"X-Foo": "bar"}) + response = req.to_http_response() + assert response.headers["X-Foo"] == "bar" + assert response.headers["Content-Type"] == "application/json" diff --git a/tests/unit/test__buckaroo_client.py b/tests/unit/test__buckaroo_client.py new file mode 100644 index 0000000..3dc49d7 --- /dev/null +++ b/tests/unit/test__buckaroo_client.py @@ -0,0 +1,296 @@ +"""Behavior tests for :class:`buckaroo._buckaroo_client.BuckarooClient`. + +Exercises the public surface: + +- constructor credential validation (store/secret key must be non-empty) +- environment properties (``is_test_environment`` / ``is_live_environment``) +- ``api_endpoint`` delegation to the config +- ``confirm_credential`` round-trip against the injected HTTP strategy +- ``get_config_info`` exposes safe-to-log fields only + +The HTTP layer is wired to :class:`tests.support.recording_mock.RecordingMock` +so ``confirm_credential`` round-trips without touching the network and we can +inspect the signed request. +""" + +from __future__ import annotations + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.config.buckaroo_config import ( + BuckarooConfig, + Environment, +) +from buckaroo.exceptions._authentication_error import AuthenticationError +from tests.support.mock_buckaroo import MockBuckaroo +from tests.support.mock_request import BuckarooMockRequest +from buckaroo.http.strategies.requests_strategy import RequestsStrategy +from tests.support.recording_mock import RecordingMock + + +# --------------------------------------------------------------------------- +# Construction + credential validation + + +def test_constructs_with_valid_keys(): + client = BuckarooClient(store_key="X", secret_key="Y") + assert client.store_key == "X" + assert client.secret_key == "Y" + + +def test_strips_whitespace_from_keys(): + client = BuckarooClient(store_key=" X ", secret_key="\tY\n") + assert client.store_key == "X" + assert client.secret_key == "Y" + + +@pytest.mark.parametrize( + "store_key, secret_key", + [ + ("", "secret"), + (" ", "secret"), + (None, "secret"), + ], +) +def test_missing_store_key_raises_authentication_error(store_key, secret_key): + with pytest.raises(AuthenticationError): + BuckarooClient(store_key=store_key, secret_key=secret_key) + + +@pytest.mark.parametrize( + "store_key, secret_key", + [ + ("store", ""), + ("store", " "), + ("store", None), + ], +) +def test_missing_secret_key_raises_authentication_error(store_key, secret_key): + with pytest.raises(AuthenticationError): + BuckarooClient(store_key=store_key, secret_key=secret_key) + + +def test_both_keys_empty_raises_authentication_error(): + with pytest.raises(AuthenticationError): + BuckarooClient(store_key="", secret_key="") + + +def test_both_keys_none_raises_authentication_error(): + with pytest.raises(AuthenticationError): + BuckarooClient(store_key=None, secret_key=None) + + +# --------------------------------------------------------------------------- +# Configuration wiring + + +def test_default_mode_is_test_environment(): + client = BuckarooClient("store", "secret") + assert client.is_test_environment is True + assert client.is_live_environment is False + + +def test_mode_live_sets_live_environment(): + client = BuckarooClient("store", "secret", mode="live") + assert client.is_live_environment is True + assert client.is_test_environment is False + + +def test_explicit_config_overrides_mode(): + config = BuckarooConfig(environment=Environment.LIVE) + # mode says test, but the explicit config should win + client = BuckarooClient("store", "secret", mode="test", config=config) + assert client.is_live_environment is True + assert client.is_test_environment is False + assert client.config is config + + +def test_api_endpoint_delegates_to_config(): + config = BuckarooConfig(environment=Environment.TEST) + client = BuckarooClient("store", "secret", config=config) + assert client.api_endpoint == config.api_endpoint + assert client.api_endpoint == "https://testcheckout.buckaroo.nl" + + +def test_api_endpoint_reflects_live_config(): + config = BuckarooConfig(environment=Environment.LIVE) + client = BuckarooClient("store", "secret", config=config) + assert client.api_endpoint == "https://checkout.buckaroo.nl" + + +def test_http_strategy_argument_is_accepted_and_stored(): + client = BuckarooClient("store", "secret", http_strategy="requests") + assert client.http_strategy == "requests" + assert isinstance(client.http_client.http_strategy, RequestsStrategy) + + +# --------------------------------------------------------------------------- +# confirm_credential + + +def test_confirm_credential_returns_true_on_success(): + client = BuckarooClient("store", "secret") + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest.json( + "GET", + "*/json/Transaction/Specification/ideal", + {"ok": True}, + status=200, + ) + ) + client.http_client.http_strategy = mock + + assert client.confirm_credential() is True + mock.assert_all_consumed() + + +def test_confirm_credential_hits_specification_ideal_endpoint(): + client = BuckarooClient("store", "secret") + mock = RecordingMock() + mock.queue( + BuckarooMockRequest.json( + "GET", + "*/json/Transaction/Specification/ideal", + {"ok": True}, + status=200, + ) + ) + client.http_client.http_strategy = mock + + client.confirm_credential() + + assert len(mock.calls) == 1 + call = mock.calls[0] + assert call["method"] == "GET" + assert call["url"].endswith("/json/Transaction/Specification/ideal") + # URL should be built on top of the configured (test) endpoint. + assert call["url"].startswith("https://testcheckout.buckaroo.nl") + + +def test_confirm_credential_signs_request_with_hmac_authorization_header(): + client = BuckarooClient("store_key_xyz", "secret_key_abc") + mock = RecordingMock() + mock.queue( + BuckarooMockRequest.json( + "GET", + "*/json/Transaction/Specification/ideal", + {"ok": True}, + status=200, + ) + ) + client.http_client.http_strategy = mock + + client.confirm_credential() + + headers = mock.calls[0]["headers"] + assert "Authorization" in headers + auth = headers["Authorization"] + # Verify HMAC scheme and that the store key is present; strict + # wire-format assertions live in tests/unit/http/test_client.py. + assert auth.startswith("hmac ") + assert "store_key_xyz" in auth + + +def test_confirm_credential_returns_false_on_authentication_error(): + # 401 / 403 — BuckarooHttpClient raises AuthenticationError, + # confirm_credential must swallow it and return False. + client = BuckarooClient("store", "secret") + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest.json( + "GET", + "*/json/Transaction/Specification/ideal", + {"error": "unauthorized"}, + status=401, + ) + ) + client.http_client.http_strategy = mock + + assert client.confirm_credential() is False + mock.assert_all_consumed() + + +def test_confirm_credential_returns_false_on_forbidden(): + client = BuckarooClient("store", "secret") + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest.json( + "GET", + "*/json/Transaction/Specification/ideal", + {"error": "forbidden"}, + status=403, + ) + ) + client.http_client.http_strategy = mock + + assert client.confirm_credential() is False + mock.assert_all_consumed() + + +def test_confirm_credential_returns_false_on_server_error(): + # 5xx — BuckarooHttpClient raises BuckarooApiError, + # confirm_credential must catch it and return False. + client = BuckarooClient("store", "secret") + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest.json( + "GET", + "*/json/Transaction/Specification/ideal", + {"error": "server"}, + status=500, + ) + ) + client.http_client.http_strategy = mock + + assert client.confirm_credential() is False + mock.assert_all_consumed() + + +def test_confirm_credential_returns_false_on_transport_exception(): + client = BuckarooClient("store", "secret") + mock = MockBuckaroo() + mock.queue( + BuckarooMockRequest("GET", "*/json/Transaction/Specification/ideal").with_exception( + RuntimeError("network dead") + ) + ) + client.http_client.http_strategy = mock + + assert client.confirm_credential() is False + mock.assert_all_consumed() + + +# --------------------------------------------------------------------------- +# get_config_info + + +def test_get_config_info_returns_only_expected_keys(): + client = BuckarooClient("store", "super-secret-value") + info = client.get_config_info() + assert set(info.keys()) == { + "environment", + "api_endpoint", + "timeout", + "retry_attempts", + "api_version", + "logging_enabled", + } + + +def test_get_config_info_exposes_safe_config_fields(): + client = BuckarooClient("store", "secret", mode="test") + info = client.get_config_info() + + assert info["environment"] == "test" + assert info["api_endpoint"] == "https://testcheckout.buckaroo.nl" + assert info["timeout"] == client.config.timeout + assert info["retry_attempts"] == client.config.retry_attempts + assert info["api_version"] == client.config.api_version.value + assert info["logging_enabled"] == client.config.logging_enabled + + +def test_get_config_info_returns_dict(): + client = BuckarooClient("store", "secret") + assert isinstance(client.get_config_info(), dict) diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py new file mode 100644 index 0000000..adc2d0a --- /dev/null +++ b/tests/unit/test_app.py @@ -0,0 +1,412 @@ +"""Tests for buckaroo.app. + +Covers `buckaroo/app.py` at 100%. The module intentionally redefines +`BuckarooConfig` as a dataclass, shadowing `config.buckaroo_config.BuckarooConfig`. +This is documented in CLAUDE.md; a guardrail test pins the shadow so it cannot +silently drift. +""" + +import pytest + +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.app import Buckaroo, BuckarooConfig +from buckaroo.config.buckaroo_config import BuckarooConfig as SdkBuckarooConfig +from buckaroo.config.buckaroo_config import Environment +from buckaroo.exceptions._authentication_error import AuthenticationError +from buckaroo.observers import BuckarooLoggingObserver, LogDestination, LogLevel +from buckaroo.observers.logging_observer import ContextualLoggingObserver +from buckaroo.services.payment_service import PaymentService +from buckaroo.services.solution_service import SolutionService + + +# --- Name-shadow guardrail --- + + +def test_app_buckarooconfig_is_not_sdk_buckarooconfig(): + assert BuckarooConfig is not SdkBuckarooConfig + + +# --- Construction & service exposure --- + + +def test_construct_with_config_exposes_payment_and_solution_services(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss")) + + assert isinstance(app.payments, PaymentService) + assert isinstance(app.solutions, SolutionService) + + +def test_construct_initialises_logger_by_default(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss")) + + assert isinstance(app.logger, BuckarooLoggingObserver) + assert app.get_logger() is app.logger + + +def test_enable_logging_false_skips_logger(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", enable_logging=False)) + + assert app.logger is None + assert app.get_logger() is None + + +# --- Env-var driven construction --- + + +def test_default_constructor_reads_store_and_secret_from_env(monkeypatch): + monkeypatch.setenv("BUCKAROO_STORE_KEY", "env_store") + monkeypatch.setenv("BUCKAROO_SECRET_KEY", "env_secret") + + app = Buckaroo() + + assert app.config.store_key == "env_store" + assert app.config.secret_key == "env_secret" + + +def test_from_env_classmethod_returns_buckaroo_instance(monkeypatch): + monkeypatch.setenv("BUCKAROO_STORE_KEY", "env_store") + monkeypatch.setenv("BUCKAROO_SECRET_KEY", "env_secret") + + app = Buckaroo.from_env() + + assert isinstance(app, Buckaroo) + assert app.config.store_key == "env_store" + assert app.config.secret_key == "env_secret" + + +def test_missing_credentials_raises_authentication_error(): + with pytest.raises(AuthenticationError): + Buckaroo() + + +# --- Mode handling --- + + +@pytest.mark.parametrize( + "env_mode,expected_mode,expected_env", + [ + ("test", "test", Environment.TEST), + ("live", "live", Environment.LIVE), + ("LIVE", "LIVE", Environment.LIVE), + ], +) +def test_mode_env_maps_to_environment(env_credentials, env_mode, expected_mode, expected_env): + env_credentials.setenv("BUCKAROO_MODE", env_mode) + + app = Buckaroo() + + assert app.config.mode == expected_mode + assert app.client.config.environment is expected_env + + +def test_mode_test_via_config_arg(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", mode="test")) + + assert app.config.mode == "test" + assert app.client.config.environment is Environment.TEST + + +def test_invalid_mode_raises_value_error(env_credentials): + env_credentials.setenv("BUCKAROO_MODE", "invalid") + + with pytest.raises(ValueError): + Buckaroo() + + +# --- Timeout & retry settings --- + + +def test_timeout_and_retry_attempts_land_on_app_config(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", timeout=45, retry_attempts=7)) + + assert app.config.timeout == 45 + assert app.config.retry_attempts == 7 + + +def test_timeout_env_string_converted_to_int(env_credentials): + env_credentials.setenv("BUCKAROO_TIMEOUT", "20") + + app = Buckaroo() + + assert app.config.timeout == 20 + assert isinstance(app.config.timeout, int) + + +def test_retry_attempts_env_string_converted_to_int(env_credentials): + env_credentials.setenv("BUCKAROO_RETRY_ATTEMPTS", "5") + + app = Buckaroo() + + assert app.config.retry_attempts == 5 + assert isinstance(app.config.retry_attempts, int) + + +# --- Logging configuration --- + + +@pytest.mark.parametrize( + "env_value,expected", + [ + ("DEBUG", LogLevel.DEBUG), + ("info", LogLevel.INFO), + ("WARNING", LogLevel.WARNING), + ("ERROR", LogLevel.ERROR), + ("CRITICAL", LogLevel.CRITICAL), + ], +) +def test_log_level_env_maps_to_enum(env_credentials, env_value, expected): + env_credentials.setenv("BUCKAROO_LOG_LEVEL", env_value) + + app = Buckaroo() + + assert app.config.log_level is expected + + +def test_invalid_log_level_falls_back_to_info(env_credentials): + env_credentials.setenv("BUCKAROO_LOG_LEVEL", "bogus") + + app = Buckaroo() + + assert app.config.log_level is LogLevel.INFO + + +@pytest.mark.parametrize( + "env_value,expected", + [ + ("stdout", LogDestination.STDOUT), + ("FILE", LogDestination.FILE), + ("both", LogDestination.BOTH), + ], +) +def test_log_destination_env_maps_to_enum(env_credentials, tmp_path, env_value, expected): + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", env_value) + if expected in (LogDestination.FILE, LogDestination.BOTH): + env_credentials.setenv("BUCKAROO_LOG_FILE", str(tmp_path / "app.log")) + + app = Buckaroo() + + assert app.config.log_destination is expected + + +def test_invalid_log_destination_falls_back_to_stdout(env_credentials): + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", "nowhere") + + app = Buckaroo() + + assert app.config.log_destination is LogDestination.STDOUT + + +def test_log_file_env_is_used_as_file_path(env_credentials, tmp_path): + log_path = tmp_path / "custom.log" + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", "file") + env_credentials.setenv("BUCKAROO_LOG_FILE", str(log_path)) + + app = Buckaroo() + app.log_info("probe_file_message") + + assert app.config.log_file == str(log_path) + assert "probe_file_message" in log_path.read_text() + + +def test_log_destination_both_writes_to_file_and_stdout(env_credentials, tmp_path, capsys): + log_path = tmp_path / "both.log" + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", "both") + env_credentials.setenv("BUCKAROO_LOG_FILE", str(log_path)) + + app = Buckaroo() + app.log_info("probe_both_message") + + file_content = log_path.read_text() + captured = capsys.readouterr() + + assert "probe_both_message" in file_content + assert "probe_both_message" in captured.out + + +def test_mask_sensitive_env_false(env_credentials): + env_credentials.setenv("BUCKAROO_LOG_MASK_SENSITIVE", "false") + + app = Buckaroo() + + assert app.config.mask_sensitive_data is False + + +def test_mask_sensitive_env_true_default(env_credentials): + app = Buckaroo() + + assert app.config.mask_sensitive_data is True + + +# --- quick_setup classmethod --- + + +def test_quick_setup_returns_buckaroo_instance(): + app = Buckaroo.quick_setup(store_key="sk", secret_key="ss") + + assert isinstance(app, Buckaroo) + assert app.config.store_key == "sk" + assert app.config.secret_key == "ss" + assert app.config.mode == "test" + assert app.config.log_destination is LogDestination.STDOUT + + +def test_quick_setup_with_live_mode_and_file_logging(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + app = Buckaroo.quick_setup(store_key="sk", secret_key="ss", mode="live", log_to_stdout=False) + + assert app.config.mode == "live" + assert app.config.log_destination is LogDestination.FILE + assert app.client.config.environment is Environment.LIVE + + +# --- Log helper methods --- + + +def test_log_helpers_write_via_logger(env_credentials, tmp_path): + log_path = tmp_path / "helpers.log" + env_credentials.setenv("BUCKAROO_LOG_LEVEL", "DEBUG") + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", "file") + env_credentials.setenv("BUCKAROO_LOG_FILE", str(log_path)) + + app = Buckaroo() + app.log_debug("debug_msg") + app.log_info("info_msg") + app.log_warning("warn_msg") + app.log_error("error_msg") + app.log_exception(RuntimeError("boom")) + + contents = log_path.read_text() + assert "debug_msg" in contents + assert "info_msg" in contents + assert "warn_msg" in contents + assert "error_msg" in contents + assert "boom" in contents + + +def test_log_helpers_no_op_when_logging_disabled(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", enable_logging=False)) + + # All helpers must be safe no-ops when logger is None. + app.log_debug("x") + app.log_info("x") + app.log_warning("x") + app.log_error("x") + app.log_exception(RuntimeError("x")) + + assert app.logger is None + + +# --- Accessors --- + + +def test_get_client_returns_underlying_client(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss")) + + client = app.get_client() + + assert isinstance(client, BuckarooClient) + assert client is app.client + + +def test_get_client_raises_when_client_not_initialised(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss")) + app.client = None + + with pytest.raises(RuntimeError, match="Client not initialized"): + app.get_client() + + +def test_create_child_logger_returns_child_observer(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss")) + + child = app.create_child_logger({"request_id": "abc"}) + + assert isinstance(child, ContextualLoggingObserver) + + +def test_create_child_logger_returns_none_when_logging_disabled(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", enable_logging=False)) + + assert app.create_child_logger({"request_id": "abc"}) is None + + +# --- Context manager --- + + +def test_context_manager_exposes_app_inside_block(): + with Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss")) as app: + assert isinstance(app, Buckaroo) + assert isinstance(app.payments, PaymentService) + + +def test_context_manager_logs_exception_on_failure_path(env_credentials, tmp_path): + log_path = tmp_path / "ctx.log" + env_credentials.setenv("BUCKAROO_LOG_LEVEL", "DEBUG") + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", "file") + env_credentials.setenv("BUCKAROO_LOG_FILE", str(log_path)) + + with pytest.raises(RuntimeError, match="ctx_boom"): + with Buckaroo() as app: + assert app is not None + raise RuntimeError("ctx_boom") + + assert "ctx_boom" in log_path.read_text() + + +def test_context_manager_works_when_logging_disabled(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", enable_logging=False)) + + with app as ctx: + assert ctx is app + + +def test_context_manager_propagates_exception_when_logging_disabled(): + app = Buckaroo(BuckarooConfig(store_key="sk", secret_key="ss", enable_logging=False)) + + with pytest.raises(RuntimeError, match="no_logger_boom"): + with app: + raise RuntimeError("no_logger_boom") + + +# --- Edge-case branches --- + + +def test_missing_credentials_with_logging_disabled_still_raises(): + with pytest.raises(AuthenticationError): + Buckaroo(BuckarooConfig(enable_logging=False)) + + +def test_client_setup_exception_is_logged_and_reraised(env_credentials, tmp_path): + log_path = tmp_path / "setup.log" + env_credentials.setenv("BUCKAROO_LOG_DESTINATION", "file") + env_credentials.setenv("BUCKAROO_LOG_FILE", str(log_path)) + + def _boom(*args, **kwargs): + raise RuntimeError("client_boom") + + env_credentials.setattr("buckaroo.app.BuckarooClient", _boom) + + with pytest.raises(RuntimeError, match="client_boom"): + Buckaroo() + + assert "client_boom" in log_path.read_text() + + +def test_client_setup_exception_reraises_without_logger(env_credentials): + """The exception propagates when enable_logging=False (logger is None).""" + + def _boom(*args, **kwargs): + raise RuntimeError("silent_boom") + + env_credentials.setattr("buckaroo.app.BuckarooClient", _boom) + + config = BuckarooConfig(store_key="sk", secret_key="ss", enable_logging=False) + assert config.enable_logging is False + + with pytest.raises(RuntimeError, match="silent_boom"): + Buckaroo(config) + + # Verify we actually took the logger-is-None branch: the Buckaroo + # constructor sets self.logger before _setup_client, so we can't inspect + # a half-constructed instance. Instead we confirm the config disables + # logging, which causes _setup_logging to skip logger creation.