diff --git a/.env.example b/.env.example old mode 100755 new mode 100644 index b973691..1914588 --- a/.env.example +++ b/.env.example @@ -1,12 +1,12 @@ -BPE_WEBSITE="Example.com" -BPE_WEBSITE_KEY="KEY" -BPE_SECRET_KEY="SECRET" -BPE_MODE="test" -BPE_DEBUG=true -BPE_REPORT_ERROR=true +# Buckaroo API Credentials +BUCKAROO_STORE_KEY=your_store_key_here +BUCKAROO_SECRET_KEY=your_secret_key_here -BPE_EXAMPLE_BASE_URL="https://example.com/buckaroo/" -BPE_EXAMPLE_RETURN_URL="${BPE_EXAMPLE_BASE_URL}return" -BPE_EXAMPLE_PUSH_URL="${BPE_EXAMPLE_BASE_URL}push" -BPE_EXAMPLE_IP="127.0.0.1" -BPE_EXAMPLE_CURRENCY_CODE="EUR" \ No newline at end of file +# Logging Configuration (optional) +BUCKAROO_LOG_LEVEL=DEBUG +BUCKAROO_LOG_DESTINATION=both +BUCKAROO_LOG_FILE=buckaroo_sdk.log +BUCKAROO_LOG_MASK_SENSITIVE=true + +# Copy this file to .env and fill in your actual credentials: +# cp .env.example .env \ No newline at end of file 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/codestyle.yml b/.github/workflows/codestyle.yml new file mode 100644 index 0000000..0753545 --- /dev/null +++ b/.github/workflows/codestyle.yml @@ -0,0 +1,33 @@ +name: Code Style + +on: + pull_request: + +concurrency: + group: codestyle-${{ github.ref }} + cancel-in-progress: true + +jobs: + ruff: + runs-on: ubuntu-latest + + name: Ruff + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff check + run: ruff check . + + - name: Run Ruff format check + run: ruff format --check . diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 8361dc5..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Python Code Quality Checks - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - lint-and-typecheck: - name: Lint and Type Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: 3.12.0 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Mypy (type checker) - run: mypy . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6c9e897 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,79 @@ +name: Publish + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + target: + description: Target repository + required: true + default: testpypi + type: choice + options: + - testpypi + - pypi + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install build tooling + run: pip install --upgrade build twine + + - name: Build sdist + wheel + run: python -m build + + - name: Verify metadata + run: twine check dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish-pypi: + name: Publish to PyPI + needs: build + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 + + publish-testpypi: + name: Publish to TestPyPI + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3cc8cf3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + name: Python ${{ matrix.python-version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + pip install -r requirements-dev.txt + pip install -e . + + - name: Run tests + run: pytest --cov=buckaroo --cov-report=term-missing diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 3f8a0fb..26648db --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,19 @@ -Dockerfile -docker-compose.yml -.mypy_cache/ -.venv/ -*__pycache__/ \ No newline at end of file +# build artifacts +/build +/dist +/*.egg-info +*.egg-info/ +/.eggs + +# test and local dev artifacts +/.pytest_cache +/.coverage +htmlcov/ +.tox/ +/.idea +.DS_Store +__pycache__/ +*.pyc +.env + +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38303fd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +--- + +## [Released] + +## [0.1.0] +- BA-510 Initial setup +- BTI-9 Core SDK setup +- BTI-10 Buckaroo API integration +- BTI-11 Specific payment model +- BTI-13 Documentation +- BTI-14 Webhook / PUSH handler +- BTI-15 Unit testing +- BTI-841 Add a test suite +- BA-992 Add payment method: Alipay +- BA-993 Add payment method: Apple Pay +- BA-994 Add payment method: Bancontact +- BA-995 Add payment method: Belfius +- BA-996 Add payment method: Billink +- BA-999 Add payment method: Bizum +- BA-1000 Add payment method: Blik +- BA-1001 Add payment method: Buckaroo Voucher +- BA-1002 Add payment method: Click to Pay +- BA-1003 Add payment method: Creditcards +- BA-1004 Add payment method: EPS +- BA-1005 Add payment method: Giftcards +- BA-1006 Add payment method: goSettle +- BA-1007 Add payment method: Google Pay +- BA-1008 Add payment method: External Payment +- BA-1009 Add payment method: iDEAL | Wero +- BA-1010 Add payment method: iDEAL | Wero QR +- BA-1011 Add payment method: In3 +- BA-1012 Add payment method: KBC +- BA-1013 Add payment method: Klarna KP +- BA-1014 Add payment method: Klarna +- BA-1015 Add payment method: MB Way +- BA-1016 Add payment method: Multibanco +- BA-1017 Add payment method: Pay by Bank +- BA-1018 Add payment method: Payconiq +- BA-1019 Add payment method: PayPal +- BA-1020 Add payment method: Przelewy24 +- BA-1021 Add payment method: Riverty +- BA-1022 Add payment method: Sepa Direct Debit +- BA-1023 Add payment method: Swish +- BA-1024 Add payment method: Transfer +- BA-1025 Add payment method: Trustly +- BA-1026 Add payment method: Twint +- BA-1027 Add payment method: Wero +- BA-1028 Add payment method: WeChat Pay +- BA-1029 Add payment method: Vouchers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1a837ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contribution Guidelines + +### Repository setup: +- Fork the repository to your account +- more details about [how to fork a repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) can be found [here](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo): + +### Making changes: +- create a branch from develop branch +- name of the branch shoul be something like: `feature/GITHUB-ISSUE-ID-slug` (eg: `feature/50-configprovider-update`) +- including unit tests is encouraged + +### Pull Request: +- open the PR to develop branch +- if there is no issue referenced, add a description about the problem and the way it is being solved +- Allow edits from maintainers + + +### Contribution to refactoring: +- include unit tests +- open the Pull Request +- check that git workflows checks have passed diff --git a/LICENSE b/LICENSE.txt old mode 100755 new mode 100644 similarity index 97% rename from LICENSE rename to LICENSE.txt index 00a6236..197061c --- a/LICENSE +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Buckaroo +Copyright (c) 2025 Buckaroo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cb299c6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include README.md +include LICENSE.txt +include CHANGELOG.md +include CONTRIBUTING.md +include requirements.txt +recursive-include buckaroo *.py py.typed +global-exclude __pycache__ +global-exclude *.pyc diff --git a/README.md b/README.md old mode 100755 new mode 100644 index dd21174..ee090a3 --- a/README.md +++ b/README.md @@ -3,13 +3,110 @@
# Buckaroo Python SDK +[](https://github.com/buckaroo-it/BuckarooSDK_Python/releases) +--- ### Index - - [About](#about) +- [Requirements](#requirements) +- [Pip Installation](#pip-installation) +- [Example](#example) +- [Contribute](#contribute) +- [Versioning](#versioning) +- [Additional information](#additional-information) +--- ### About -Buckaroo is the Payment Service Provider for all your online payments, trusted by more than 30,000 companies to securely process their payments, subscriptions, and unpaid invoices. -Buckaroo has developed its own Python SDK. This SDK is a modern, open-source Python library designed to simplify the integration of your Python application with Buckaroo's services. +Buckaroo is the Payment Service Provider for all your online payments with more than 30,000 companies relying on Buckaroo's platform to securely process their payments, subscriptions and unpaid invoices. +Buckaroo developed their own Python SDK. The SDK is a modern, open-source Python library that makes it easy to integrate your Python application with Buckaroo's services. Start accepting payments today with Buckaroo. + +### Requirements + +To use the Buckaroo API client, the following things are required: + ++ A Buckaroo account ([Dutch](https://www.buckaroo.nl/start) or [English](https://www.buckaroo.eu/solutions/request-form)) ++ Python >= 3.9 ++ Up-to-date OpenSSL (or other SSL/TLS toolkit) + +### Pip Installation + +By far the easiest way to install the Buckaroo SDK is via [pip](https://pip.pypa.io/). + + $ pip install buckaroo-sdk + +Then import the client in your project: + +```python +from buckaroo import BuckarooClient +``` + +### Example +Create and configure the Buckaroo client. +You can find your credentials in [Buckaroo Plaza](https://plaza.buckaroo.nl/Configuration/Merchant/ApiKeys). + +```python +from buckaroo import BuckarooClient +from buckaroo.services.payment_service import PaymentService + +# Get your store & secret key in your plaza. +# mode="test" routes calls to the test environment; use "live" for production. +client = BuckarooClient("STORE_KEY", "SECRET_KEY", mode="test") +payments = PaymentService(client) +``` + +Create a payment with any of the available payment methods. In this example, we show how to create a credit card payment. Each payment has a slightly different payload. + +```python +# Create a new payment +response = ( + payments.create_payment("creditcard", { + "currency": "EUR", + "amount": 10.00, # The amount we want to charge + "invoice": "UNIQUE-INVOICE-NO", # Each payment must contain a unique invoice number + "service_parameters": {"brand": "visa"}, # Request to pay with Visa + }) + .description("Order #UNIQUE-INVOICE-NO") + .pay() +) + +# Inspect the response from Buckaroo +if response.is_successful(): + print("transaction id:", response.get_transaction_id()) + print("redirect:", response.get_redirect_url()) +else: + print("status message:", response.get_message()) +``` + +You can also use the fluent interface directly: + +```python +response = ( + payments.create_payment("creditcard") + .currency("EUR") + .amount(10.00) + .invoice("UNIQUE-INVOICE-NO") + .pay() +) +``` + +Find our full documentation online on [docs.buckaroo.io](https://docs.buckaroo.io). + +### Contribute + +We really appreciate it when developers contribute to improve the Buckaroo plugins. +If you want to contribute as well, then please follow our [Contribution Guidelines](CONTRIBUTING.md). + +### Versioning + +- **MAJOR:** Breaking changes that require additional testing/caution +- **MINOR:** Changes that should not have a big impact +- **PATCHES:** Bug and hotfixes only + +### Additional information +- **Support:** https://docs.buckaroo.io/docs/contact-us +- **Contact:** [support@buckaroo.nl](mailto:support@buckaroo.nl) or [+31 (0)30 711 50 50](tel:+310307115050) + +## License +Buckaroo Python SDK is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/buckaroo/__init__.py b/buckaroo/__init__.py new file mode 100644 index 0000000..e5eff62 --- /dev/null +++ b/buckaroo/__init__.py @@ -0,0 +1,27 @@ +"""Buckaroo SDK for Python. + +Public API: + + from buckaroo import Buckaroo, BuckarooClient + from buckaroo import BuckarooError, AuthenticationError, ParameterValidationError, BuckarooApiError +""" + +from buckaroo._version import VERSION +from buckaroo.app import Buckaroo +from buckaroo._buckaroo_client import BuckarooClient +from buckaroo.exceptions._buckaroo_error import BuckarooError +from buckaroo.exceptions._authentication_error import AuthenticationError +from buckaroo.exceptions._parameter_validation_error import ParameterValidationError +from buckaroo.http.client import BuckarooApiError + +__version__ = VERSION + +__all__ = [ + "__version__", + "Buckaroo", + "BuckarooClient", + "BuckarooError", + "AuthenticationError", + "ParameterValidationError", + "BuckarooApiError", +] diff --git a/buckaroo/_buckaroo_client.py b/buckaroo/_buckaroo_client.py new file mode 100644 index 0000000..4d41051 --- /dev/null +++ b/buckaroo/_buckaroo_client.py @@ -0,0 +1,138 @@ +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 + + +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. + mode (str, optional): Environment mode ('test' or 'live'). Defaults to 'test'. + This parameter is deprecated, use config parameter instead. + config (BuckarooConfig, optional): Configuration object. If not provided, + 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, + mode: str = "test", + config: Optional[BuckarooConfig] = 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 + 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') + If None, will auto-select best available strategy + """ + + 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 + ) + + @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") + return response.success + except Exception: + return False + + def get_config_info(self) -> dict: + """ + Get configuration information. + + Returns: + dict: Configuration information (safe for logging). + """ + return { + "environment": self.config.environment.value, + "api_endpoint": self.config.api_endpoint, + "timeout": self.config.timeout, + "retry_attempts": self.config.retry_attempts, + "api_version": self.config.api_version.value, + "logging_enabled": self.config.logging_enabled, + } diff --git a/buckaroo/_version.py b/buckaroo/_version.py new file mode 100644 index 0000000..1cf6267 --- /dev/null +++ b/buckaroo/_version.py @@ -0,0 +1 @@ +VERSION = "0.1.0" diff --git a/buckaroo/app.py b/buckaroo/app.py new file mode 100644 index 0000000..3ab57c2 --- /dev/null +++ b/buckaroo/app.py @@ -0,0 +1,242 @@ +""" +Buckaroo Application Wrapper + +This module provides a high-level application wrapper for the Buckaroo SDK +that includes automatic logging setup, configuration management, and +convenient methods for common operations. +""" + +import os +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, + LogLevel, + LogDestination, + LogConfig, +) +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 + 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": + """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 [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 + ) + + return cls( + store_key=os.getenv("BUCKAROO_STORE_KEY"), + secret_key=os.getenv("BUCKAROO_SECRET_KEY"), + mode=os.getenv("BUCKAROO_MODE", "test"), + log_level=log_level, + log_destination=log_destination, + 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")), + ) + + +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": + """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": + """ + 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 + """ + config = BuckarooConfig( + store_key=store_key, + secret_key=secret_key, + mode=mode, + 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, + ) + + 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, + ) + + 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), + ) + raise AuthenticationError(error_msg) + + try: + self.client = BuckarooClient( + 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, + ) + + except Exception as e: + if self.logger: + self.logger.log_exception(e, context={"operation": "client_setup"}) + raise + + 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") diff --git a/buckaroo/builders/__init__.py b/buckaroo/builders/__init__.py new file mode 100644 index 0000000..490feb1 --- /dev/null +++ b/buckaroo/builders/__init__.py @@ -0,0 +1 @@ +# Builders module diff --git a/buckaroo/builders/base_builder.py b/buckaroo/builders/base_builder.py new file mode 100644 index 0000000..dd5140d --- /dev/null +++ b/buckaroo/builders/base_builder.py @@ -0,0 +1,594 @@ +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +from ..models.payment_request import PaymentRequest, ClientIP, Service, ServiceList, Parameter +from ..models.payment_response import PaymentResponse +from ..services.service_parameter_validator import ServiceParameterValidator + + +class BaseBuilder(ABC): + """Abstract base class for all builders (payments and solutions).""" + + def __init__(self, client): + """Initialize with client instance.""" + self._client = client + self._currency: Optional[str] = None + self._amount_debit: Optional[float] = None + self._description: Optional[str] = None + self._invoice: Optional[str] = None + self._return_url: Optional[str] = None + self._return_url_cancel: Optional[str] = None + self._return_url_error: Optional[str] = None + self._return_url_reject: Optional[str] = None + self._continue_on_incomplete: str = "1" + self._push_url: Optional[str] = None + self._push_url_failure: Optional[str] = None + self._client_ip: Optional[ClientIP] = None + self._service_parameters: List[Parameter] = [] + self._payload: Dict[str, Any] = {} # Store original payload + self._validator = ServiceParameterValidator(self) + + def currency(self, currency: str) -> "BaseBuilder": + """Set the currency for the payment.""" + self._currency = currency + return self + + def amount(self, amount: float) -> "BaseBuilder": + """Set the amount for the payment.""" + self._amount_debit = amount + return self + + def description(self, description: str) -> "BaseBuilder": + """Set the description for the payment.""" + self._description = description + return self + + 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": + """Set the return URL for successful payment.""" + self._return_url = url + return self + + 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": + """Set the return URL for payment error.""" + self._return_url_error = url + return self + + 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": + """Set whether to continue on incomplete payment.""" + self._continue_on_incomplete = continue_incomplete + return self + + 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": + """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": + """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": + """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]) -> "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) + - 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"]) + + 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 + + @abstractmethod + def get_service_name(self) -> str: + """Get the service name for this payment method.""" + 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.) + """ + 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, + } + + 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("original_transaction_key") + 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 + # PaymentRequest.to_dict always writes AmountDebit; strip it for refunds + del request_data["AmountDebit"] + else: + # Full refund - swap debit to credit + 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; 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 + # 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 + """ + 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)" + ) + + _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) + + # 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) diff --git a/buckaroo/builders/payments/__init__.py b/buckaroo/builders/payments/__init__.py new file mode 100644 index 0000000..28f053f --- /dev/null +++ b/buckaroo/builders/payments/__init__.py @@ -0,0 +1,27 @@ +""" +Payment builders package. + +This package contains all payment method builders and their capabilities. +""" + +from .payment_builder import PaymentBuilder +from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable +from .capabilities.instant_refund_capable import InstantRefundCapable +from .capabilities.fast_checkout_capable import FastCheckoutCapable +from .capabilities.bank_transfer_capabilities import BankTransferCapabilities +from .ideal_builder import IdealBuilder +from .credit_card_builder import CreditcardBuilder +from .sofort_builder import SofortBuilder +from .payconiq_builder import PayconiqBuilder + +__all__ = [ + "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 new file mode 100644 index 0000000..d352516 --- /dev/null +++ b/buckaroo/builders/payments/alipay_builder.py @@ -0,0 +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", + } + } + + # Default to Pay action parameters + return {} diff --git a/buckaroo/builders/payments/apple_pay_builder.py b/buckaroo/builders/payments/apple_pay_builder.py new file mode 100644 index 0000000..a38beb9 --- /dev/null +++ b/buckaroo/builders/payments/apple_pay_builder.py @@ -0,0 +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": False, + "description": "Apple Pay payment data", + }, + "CustomerCardName": { + "type": str, + "required": False, + "description": "Customer card name", + }, + } + + # Default to Pay action parameters + return {} diff --git a/buckaroo/builders/payments/bancontact_builder.py b/buckaroo/builders/payments/bancontact_builder.py new file mode 100644 index 0000000..a7ab516 --- /dev/null +++ b/buckaroo/builders/payments/bancontact_builder.py @@ -0,0 +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", + }, + } + + if action.lower() in ["payencrypted", "completepayment"]: + return { + "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 new file mode 100644 index 0000000..6d67017 --- /dev/null +++ b/buckaroo/builders/payments/belfius_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/billink_builder.py b/buckaroo/builders/payments/billink_builder.py new file mode 100644 index 0000000..4e86fd7 --- /dev/null +++ b/buckaroo/builders/payments/billink_builder.py @@ -0,0 +1,30 @@ +from typing import Dict, Any +from .payment_builder import PaymentBuilder + + +class BillinkBuilder(PaymentBuilder): + """Builder for Billink payments with buy-now-pay-later capabilities.""" + + def get_service_name(self) -> str: + """Get the service name for Billink payments.""" + return "billink" + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + """Get the allowed service parameters for Billink 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": "Billink articles"}, + } + + return {} diff --git a/buckaroo/builders/payments/bizum_builder.py b/buckaroo/builders/payments/bizum_builder.py new file mode 100644 index 0000000..f1758a2 --- /dev/null +++ b/buckaroo/builders/payments/bizum_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/blik_builder.py b/buckaroo/builders/payments/blik_builder.py new file mode 100644 index 0000000..e2cf505 --- /dev/null +++ b/buckaroo/builders/payments/blik_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/buckaroo_voucher_builder.py b/buckaroo/builders/payments/buckaroo_voucher_builder.py new file mode 100644 index 0000000..31e6931 --- /dev/null +++ b/buckaroo/builders/payments/buckaroo_voucher_builder.py @@ -0,0 +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", + }, + } + + 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", + }, + } + + return {} diff --git a/buckaroo/builders/payments/capabilities/__init__.py b/buckaroo/builders/payments/capabilities/__init__.py new file mode 100644 index 0000000..9854f82 --- /dev/null +++ b/buckaroo/builders/payments/capabilities/__init__.py @@ -0,0 +1,17 @@ +""" +Payment capabilities package. + +This package contains capability mixins for different payment features. +""" + +from .authorize_capture_capable import AuthorizeCaptureCapable +from .instant_refund_capable import InstantRefundCapable +from .fast_checkout_capable import FastCheckoutCapable +from .bank_transfer_capabilities import BankTransferCapabilities + +__all__ = [ + "AuthorizeCaptureCapable", + "InstantRefundCapable", + "FastCheckoutCapable", + "BankTransferCapabilities", +] diff --git a/buckaroo/builders/payments/capabilities/authorize_capture_capable.py b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py new file mode 100644 index 0000000..04fa9f3 --- /dev/null +++ b/buckaroo/builders/payments/capabilities/authorize_capture_capable.py @@ -0,0 +1,81 @@ +""" +Payment capability mixins for specific payment features. + +This module provides mixins that can be selectively applied to payment builders +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 contributing the Authorize / CancelAuthorize action surface. + + ``capture`` lives on :class:`BaseBuilder` with the full + ``original_transaction_key`` / ``amount`` signature and is shared by every + builder; it is intentionally not duplicated here. + """ + + def authorize(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + """Authorize a payment without capturing it. + + Args: + validate: 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: + """Authorize an encrypted-card payment without capturing it. + + Args: + validate: 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: + """Cancel a previously authorized payment. + + Uses AmountCredit (not AmountDebit) per Buckaroo API requirements. + """ + txn_key = ( + original_transaction_key + or self._payload.get("original_transaction_key") + or self._payload.get("authorization_key") + ) + if not txn_key: + raise ValueError( + "Original transaction key is required for cancelAuthorize " + "(provide 'original_transaction_key' in payload)" + ) + + payment_request = self.build("CancelAuthorize", validate=validate) + request_data = payment_request.to_dict() + + request_data["OriginalTransactionKey"] = txn_key + + # 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) diff --git a/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py b/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py new file mode 100644 index 0000000..a92221d --- /dev/null +++ b/buckaroo/builders/payments/capabilities/bank_transfer_capabilities.py @@ -0,0 +1,17 @@ +""" +Payment capability mixins for specific payment features. + +This module provides mixins that can be selectively applied to payment builders +based on their actual capabilities, rather than giving all methods to all builders. +""" + +from __future__ import annotations + +from .fast_checkout_capable import FastCheckoutCapable +from .instant_refund_capable import InstantRefundCapable + + +class BankTransferCapabilities(InstantRefundCapable, FastCheckoutCapable): + """Combined capabilities for bank transfer payment methods.""" + + pass diff --git a/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py new file mode 100644 index 0000000..d9c0d19 --- /dev/null +++ b/buckaroo/builders/payments/capabilities/encrypted_pay_capable.py @@ -0,0 +1,36 @@ +""" +Payment capability mixins for specific payment features. + +This module provides mixins that can be selectively applied to payment builders +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: + """ + Process a payment with encryption. + + 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 payment response + """ + payment_request = self.build("PayEncrypted", validate=validate) + request_data = payment_request.to_dict() + 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 new file mode 100644 index 0000000..cd44cf3 --- /dev/null +++ b/buckaroo/builders/payments/capabilities/fast_checkout_capable.py @@ -0,0 +1,38 @@ +""" +Payment capability mixins for specific payment features. + +This module provides mixins that can be selectively applied to payment builders +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 FastCheckoutCapable: + """Mixin for payment methods that support fast checkout (iDEAL, Sofort, PayConiq).""" + + 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 new file mode 100644 index 0000000..019f78a --- /dev/null +++ b/buckaroo/builders/payments/capabilities/instant_refund_capable.py @@ -0,0 +1,36 @@ +""" +Payment capability mixins for specific payment features. + +This module provides mixins that can be selectively applied to payment builders +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 InstantRefundCapable: + """Mixin for payment methods that support instant refunds (iDEAL, Sofort, PayConiq).""" + + 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 + """ + payment_request = self.build("instantRefund", validate=validate) + request_data = payment_request.to_dict() + return self._post_transaction(request_data) diff --git a/buckaroo/builders/payments/click_to_pay_builder.py b/buckaroo/builders/payments/click_to_pay_builder.py new file mode 100644 index 0000000..b6b64a0 --- /dev/null +++ b/buckaroo/builders/payments/click_to_pay_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/credit_card_builder.py b/buckaroo/builders/payments/credit_card_builder.py new file mode 100644 index 0000000..65885d5 --- /dev/null +++ b/buckaroo/builders/payments/credit_card_builder.py @@ -0,0 +1,119 @@ +from __future__ import annotations +from typing import Dict, Any + +from buckaroo.builders.payments.capabilities.encrypted_pay_capable import EncryptedPayCapable +from .payment_builder import PaymentBuilder +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") + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + """Get the allowed service parameters for Credit Card payments based on action.""" + + if action.lower() == "payencrypted": + # Encrypted payment uses encrypted data instead of raw card details + return { + "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", + }, + } + + 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()", + }, + } + + 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()", + }, + } + + return {} + + 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 + + Returns: + PaymentResponse: The payment response + """ + 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: + """ + Process a payment using a Hosted Fields session token. + + The SessionId parameter must be set via add_parameter('SessionId', token) + before calling this method. The token comes from the Hosted Fields + submitSession() call on the client side. + + The response may include a RequiredAction for 3DS authentication. + """ + payment_request = self.build("PayWithToken", validate=validate) + request_data = payment_request.to_dict() + return self._post_transaction(request_data) + + def authorizeWithToken(self: "PaymentBuilder", validate: bool = True) -> PaymentResponse: + """ + Authorize a payment using a Hosted Fields session token. + + The SessionId parameter must be set via add_parameter('SessionId', token) + before calling this method. The token comes from the Hosted Fields + submitSession() call on the client side. + + The response may include a RequiredAction for 3DS authentication. + """ + payment_request = self.build("AuthorizeWithToken", validate=validate) + request_data = payment_request.to_dict() + return self._post_transaction(request_data) + + 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) diff --git a/buckaroo/builders/payments/default_builder.py b/buckaroo/builders/payments/default_builder.py new file mode 100644 index 0000000..6859d8d --- /dev/null +++ b/buckaroo/builders/payments/default_builder.py @@ -0,0 +1,16 @@ +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") + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + """Get the allowed service parameters for Default payments based on action.""" + + return {} diff --git a/buckaroo/builders/payments/eps_builder.py b/buckaroo/builders/payments/eps_builder.py new file mode 100644 index 0000000..7d3adc7 --- /dev/null +++ b/buckaroo/builders/payments/eps_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/external_payment_builder.py b/buckaroo/builders/payments/external_payment_builder.py new file mode 100644 index 0000000..8f7c235 --- /dev/null +++ b/buckaroo/builders/payments/external_payment_builder.py @@ -0,0 +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 new file mode 100644 index 0000000..fdceba8 --- /dev/null +++ b/buckaroo/builders/payments/giftcards_builder.py @@ -0,0 +1,49 @@ +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") + + 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": + 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", + }, + } + + 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": + 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": ""}, + "LastName": {"type": str, "required": False, "description": ""}, + "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 new file mode 100644 index 0000000..7831779 --- /dev/null +++ b/buckaroo/builders/payments/google_pay_builder.py @@ -0,0 +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": ""}, + } + + return {} diff --git a/buckaroo/builders/payments/ideal_builder.py b/buckaroo/builders/payments/ideal_builder.py new file mode 100644 index 0000000..472655e --- /dev/null +++ b/buckaroo/builders/payments/ideal_builder.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from .capabilities.bank_transfer_capabilities import BankTransferCapabilities + + +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 {} diff --git a/buckaroo/builders/payments/ideal_qr_builder.py b/buckaroo/builders/payments/ideal_qr_builder.py new file mode 100644 index 0000000..1922b89 --- /dev/null +++ b/buckaroo/builders/payments/ideal_qr_builder.py @@ -0,0 +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.""" + + def required_fields(self, action: str = "Pay") -> Dict[str, Any]: + """ + Get the required fields for this payment method. + + 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, + } + + 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", + }, + } + + 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 + ) + + # Convert to dictionary for API + request_data = payment_request.to_dict() + + return self._post_data_request(request_data) diff --git a/buckaroo/builders/payments/in3_builder.py b/buckaroo/builders/payments/in3_builder.py new file mode 100644 index 0000000..71e5dab --- /dev/null +++ b/buckaroo/builders/payments/in3_builder.py @@ -0,0 +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", + }, + "article": {"type": list, "required": True, "description": "IN3 articles"}, + } + + return {} diff --git a/buckaroo/builders/payments/kbc_builder.py b/buckaroo/builders/payments/kbc_builder.py new file mode 100644 index 0000000..b78debb --- /dev/null +++ b/buckaroo/builders/payments/kbc_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/klarna_builder.py b/buckaroo/builders/payments/klarna_builder.py new file mode 100644 index 0000000..a74e2cb --- /dev/null +++ b/buckaroo/builders/payments/klarna_builder.py @@ -0,0 +1,100 @@ +from __future__ import annotations +from typing import Dict, Any, Optional + +from buckaroo.models.payment_response import PaymentResponse +from .payment_builder import PaymentBuilder + + +class KlarnaBuilder(PaymentBuilder): + """Builder for Klarna MOR (Merchant of Record) payments.""" + + 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.""" + action = action.lower() + + if action == "pay": + return { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Key of the prior Klarna Reserve", + }, + } + + if action == "reserve": + 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": "Klarna articles", + }, + "operatingCountry": { + "type": str, + "required": False, + "description": "Operating country code", + }, + "pno": { + "type": str, + "required": False, + "description": "Personal identification number", + }, + "gender": { + "type": str, + "required": False, + "description": "Customer gender", + }, + "locale": { + "type": str, + "required": False, + "description": "Customer locale", + }, + } + + if action == "cancelreservation": + return {} + + return {} + + 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", + original_transaction_key: Optional[str] = None, + validate: bool = True, + ) -> PaymentResponse: + """Cancel a previously-reserved Klarna transaction. + + Mirrors :meth:`AuthorizeCaptureCapable.cancelAuthorize` but with the + ``CancelReservation`` action. + """ + txn_key = original_transaction_key or self._payload.get("original_transaction_key") + if not txn_key: + raise ValueError( + "Original transaction key is required for cancelReservation " + "(provide 'original_transaction_key' in payload)" + ) + + payment_request = self.build("CancelReservation", validate=validate) + request_data = payment_request.to_dict() + request_data["OriginalTransactionKey"] = txn_key + + return self._post_transaction(request_data) diff --git a/buckaroo/builders/payments/klarnakp_builder.py b/buckaroo/builders/payments/klarnakp_builder.py new file mode 100644 index 0000000..cb9bfb4 --- /dev/null +++ b/buckaroo/builders/payments/klarnakp_builder.py @@ -0,0 +1,121 @@ +from __future__ import annotations +from typing import Dict, Any + +from buckaroo.models.payment_response import PaymentResponse +from .payment_builder import PaymentBuilder + + +class KlarnaKPBuilder(PaymentBuilder): + """Builder for Klarna KP payments with bank transfer capabilities.""" + + 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, + } + + 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", "extendreservation"]: + return { + "reservationNumber": { + "type": str, + "required": True, + "description": "Klarna KP reservation number", + }, + } + + if action.lower() == "reserve": + return { + "operatingCountry": { + "type": str, + "required": True, + "description": "Operating country code", + }, + "article": {"type": list, "required": True, "description": "Klarna KP articles"}, + } + + if action.lower() == "updatereservation": + return { + "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", + }, + "company": {"type": str, "required": False, "description": "Shipping company name"}, + "trackingNumber": { + "type": str, + "required": False, + "description": "Shipping tracking number", + }, + } + + return {} + + 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: + + 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: + + 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: + + 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: + + payment_request = self.build("AddShippingInfo", validate=validate) + request_data = payment_request.to_dict() + + return self._post_data_request(request_data) diff --git a/buckaroo/builders/payments/knaken_builder.py b/buckaroo/builders/payments/knaken_builder.py new file mode 100644 index 0000000..7a4ce97 --- /dev/null +++ b/buckaroo/builders/payments/knaken_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/mbway_builder.py b/buckaroo/builders/payments/mbway_builder.py new file mode 100644 index 0000000..d9b3d7f --- /dev/null +++ b/buckaroo/builders/payments/mbway_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/multibanco_builder.py b/buckaroo/builders/payments/multibanco_builder.py new file mode 100644 index 0000000..b0163a8 --- /dev/null +++ b/buckaroo/builders/payments/multibanco_builder.py @@ -0,0 +1,15 @@ +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.""" + + return {} diff --git a/buckaroo/builders/payments/paybybank_builder.py b/buckaroo/builders/payments/paybybank_builder.py new file mode 100644 index 0000000..d12efd6 --- /dev/null +++ b/buckaroo/builders/payments/paybybank_builder.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from .capabilities.bank_transfer_capabilities import BankTransferCapabilities + + +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", + }, + } + + return {} diff --git a/buckaroo/builders/payments/payconiq_builder.py b/buckaroo/builders/payments/payconiq_builder.py new file mode 100644 index 0000000..b997363 --- /dev/null +++ b/buckaroo/builders/payments/payconiq_builder.py @@ -0,0 +1,92 @@ +from __future__ import annotations +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from .capabilities.bank_transfer_capabilities import BankTransferCapabilities + + +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", + }, + } + elif action.lower() == "instantrefund": + # Instant refund has different requirements + return {} + elif action.lower() in ["refund", "capture", "cancel"]: + # These actions typically don't require mobile number + return {} + 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", + }, + } + + 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": + """ + 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"]) + + return self + + # Bank transfer capabilities (inherited from BankTransferCapabilities): + # - instantRefund() + # - payFastCheckout() + # + # Standard methods (inherited from PaymentBuilder): + # - pay(), refund(), capture(), cancel(), execute_action() diff --git a/buckaroo/builders/payments/payment_builder.py b/buckaroo/builders/payments/payment_builder.py new file mode 100644 index 0000000..1b287f2 --- /dev/null +++ b/buckaroo/builders/payments/payment_builder.py @@ -0,0 +1,8 @@ +from ..base_builder import BaseBuilder + + +class PaymentBuilder(BaseBuilder): + """Payment-specific builder. All behavior inherited from BaseBuilder; + payment-specific overrides (when they arise) go here.""" + + pass diff --git a/buckaroo/builders/payments/paypal_builder.py b/buckaroo/builders/payments/paypal_builder.py new file mode 100644 index 0000000..4c93d92 --- /dev/null +++ b/buckaroo/builders/payments/paypal_builder.py @@ -0,0 +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.", + }, + } + + return {} diff --git a/buckaroo/builders/payments/przelewy24_builder.py b/buckaroo/builders/payments/przelewy24_builder.py new file mode 100644 index 0000000..f840ee8 --- /dev/null +++ b/buckaroo/builders/payments/przelewy24_builder.py @@ -0,0 +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", + }, + } + + return {} diff --git a/buckaroo/builders/payments/riverty_builder.py b/buckaroo/builders/payments/riverty_builder.py new file mode 100644 index 0000000..9d37889 --- /dev/null +++ b/buckaroo/builders/payments/riverty_builder.py @@ -0,0 +1,40 @@ +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from .capabilities.authorize_capture_capable import AuthorizeCaptureCapable + + +class RivertyBuilder(PaymentBuilder, AuthorizeCaptureCapable): + """Builder for Riverty (Afterpay New) buy-now-pay-later payments. + + Riverty is the rebrand of AfterPay; the wire service name remains + ``afterpay``. The method supports Pay, Authorize/Capture/CancelAuthorize, + and Refund. + """ + + 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. + + Pay and Authorize share the same cart-line / customer trio. + Capture and CancelAuthorize reference the original via top-level + ``OriginalTransactionKey`` (handled by the SDK), no service params. + """ + if action.lower() in ("pay", "authorize"): + 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"}, + } + + return {} diff --git a/buckaroo/builders/payments/sepadirectdebit_builder.py b/buckaroo/builders/payments/sepadirectdebit_builder.py new file mode 100644 index 0000000..c99e474 --- /dev/null +++ b/buckaroo/builders/payments/sepadirectdebit_builder.py @@ -0,0 +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", + }, + "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", + }, + } + + return {} diff --git a/buckaroo/builders/payments/sofort_builder.py b/buckaroo/builders/payments/sofort_builder.py new file mode 100644 index 0000000..bd71b5f --- /dev/null +++ b/buckaroo/builders/payments/sofort_builder.py @@ -0,0 +1,92 @@ +from __future__ import annotations +from typing import Dict, Any +from .payment_builder import PaymentBuilder +from .capabilities.bank_transfer_capabilities import BankTransferCapabilities + + +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", + }, + } + elif action.lower() == "instantrefund": + # Instant refund has different requirements + return {} + elif action.lower() in ["refund", "capture", "cancel"]: + # These actions typically don't require country code + return {} + 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", + }, + } + + 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": + """ + 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"]) + + return self + + # Bank transfer capabilities (inherited from BankTransferCapabilities): + # - instantRefund() + # - payFastCheckout() + # + # Standard methods (inherited from PaymentBuilder): + # - pay(), refund(), capture(), cancel(), execute_action() diff --git a/buckaroo/builders/payments/swish_builder.py b/buckaroo/builders/payments/swish_builder.py new file mode 100644 index 0000000..04355bc --- /dev/null +++ b/buckaroo/builders/payments/swish_builder.py @@ -0,0 +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 {} + + return {} diff --git a/buckaroo/builders/payments/transfer_builder.py b/buckaroo/builders/payments/transfer_builder.py new file mode 100644 index 0000000..4fccfeb --- /dev/null +++ b/buckaroo/builders/payments/transfer_builder.py @@ -0,0 +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", + }, + } + + return {} diff --git a/buckaroo/builders/payments/trustly_builder.py b/buckaroo/builders/payments/trustly_builder.py new file mode 100644 index 0000000..b0bec15 --- /dev/null +++ b/buckaroo/builders/payments/trustly_builder.py @@ -0,0 +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"}, + } + + return {} diff --git a/buckaroo/builders/payments/twint_builder.py b/buckaroo/builders/payments/twint_builder.py new file mode 100644 index 0000000..62cf8f6 --- /dev/null +++ b/buckaroo/builders/payments/twint_builder.py @@ -0,0 +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 {} diff --git a/buckaroo/builders/payments/voucher_builder.py b/buckaroo/builders/payments/voucher_builder.py new file mode 100644 index 0000000..cff16ee --- /dev/null +++ b/buckaroo/builders/payments/voucher_builder.py @@ -0,0 +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") + + 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 {} diff --git a/buckaroo/builders/payments/wechatpay_builder.py b/buckaroo/builders/payments/wechatpay_builder.py new file mode 100644 index 0000000..2d72878 --- /dev/null +++ b/buckaroo/builders/payments/wechatpay_builder.py @@ -0,0 +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 {} diff --git a/buckaroo/builders/payments/wero_builder.py b/buckaroo/builders/payments/wero_builder.py new file mode 100644 index 0000000..37cc1b0 --- /dev/null +++ b/buckaroo/builders/payments/wero_builder.py @@ -0,0 +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 {} diff --git a/src/__init__.py b/buckaroo/builders/solutions/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from src/__init__.py rename to buckaroo/builders/solutions/__init__.py diff --git a/buckaroo/builders/solutions/default_builder.py b/buckaroo/builders/solutions/default_builder.py new file mode 100644 index 0000000..a140011 --- /dev/null +++ b/buckaroo/builders/solutions/default_builder.py @@ -0,0 +1,16 @@ +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") + + def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: + """Get the allowed service parameters for Default payments based on action.""" + + return {} diff --git a/buckaroo/builders/solutions/solution_builder.py b/buckaroo/builders/solutions/solution_builder.py new file mode 100644 index 0000000..3c062a1 --- /dev/null +++ b/buckaroo/builders/solutions/solution_builder.py @@ -0,0 +1,18 @@ +from typing import Dict, Any +from ..base_builder import BaseBuilder + + +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 {} diff --git a/buckaroo/builders/solutions/subscription_builder.py b/buckaroo/builders/solutions/subscription_builder.py new file mode 100644 index 0000000..2d86b6e --- /dev/null +++ b/buckaroo/builders/solutions/subscription_builder.py @@ -0,0 +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 {} + + 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) diff --git a/src/exceptions/__init__.py b/buckaroo/config/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from src/exceptions/__init__.py rename to buckaroo/config/__init__.py diff --git a/buckaroo/config/buckaroo_config.py b/buckaroo/config/buckaroo_config.py new file mode 100644 index 0000000..0f03d20 --- /dev/null +++ b/buckaroo/config/buckaroo_config.py @@ -0,0 +1,403 @@ +""" +Buckaroo SDK Configuration Module. + +This module provides configuration classes for the Buckaroo SDK, allowing +customization of API endpoints, timeouts, retry logic, and other settings. +""" + +from typing import Optional, Dict, Any +from dataclasses import dataclass +from enum import Enum + + +class Environment(Enum): + """Buckaroo API environment options.""" + + TEST = "test" + LIVE = "live" + + +class ApiVersion(Enum): + """Supported Buckaroo API versions.""" + + V1 = "v1" + V2 = "v2" + + +@dataclass +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. + timeout (int): Request timeout in seconds. + retry_attempts (int): Number of retry attempts for failed requests. + retry_delay (float): Delay between retry attempts in seconds. + logging_enabled (bool): Whether to enable SDK logging. + verify_ssl (bool): Whether to verify SSL certificates. + 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, + ... timeout=60, + ... retry_attempts=5 + ... ) + >>> client = BuckarooClient("store_key", "secret_key", config=config) + """ + + environment: Environment = Environment.TEST + api_version: ApiVersion = ApiVersion.V1 + timeout: int = 30 + retry_attempts: int = 3 + retry_delay: float = 1.0 + logging_enabled: bool = True + verify_ssl: bool = True + 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. + """ + return { + "Content-Type": "application/json", + "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. + """ + return { + "environment": self.environment.value, + "api_version": self.api_version.value, + "api_endpoint": self.api_endpoint, + "timeout": self.timeout, + "retry_attempts": self.retry_attempts, + "retry_delay": self.retry_delay, + "logging_enabled": self.logging_enabled, + "verify_ssl": self.verify_ssl, + "custom_endpoint": self.custom_endpoint, + "user_agent": self.user_agent, + "max_redirects": self.max_redirects, + "is_test": self.is_test_environment, + "is_live": self.is_live_environment, + } + + @classmethod + 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. + """ + # Convert string values to enums + 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", + } + 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": + """ + 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) + """ + config_dict = self.to_dict() + config_dict.update(changes) + return self.from_dict(config_dict) + + +class DefaultConfig(BuckarooConfig): + """ + Default configuration for Buckaroo SDK. + + This class provides sensible defaults for most use cases. + """ + + pass + + +class TestConfig(BuckarooConfig): + """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, + ) + defaults.update(overrides) + defaults["environment"] = Environment.TEST + super().__init__(**defaults) + + +class ProductionConfig(BuckarooConfig): + """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, + ) + 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) + ... .timeout(45) + ... .retry_attempts(3) + ... .enable_logging() + ... .build()) + """ + + def __init__(self): + self._config_dict = {} + + def environment(self, env: Environment) -> "ConfigBuilder": + """Set the environment.""" + self._config_dict["environment"] = env + return self + + def test_environment(self) -> "ConfigBuilder": + """Set test environment.""" + return self.environment(Environment.TEST) + + def live_environment(self) -> "ConfigBuilder": + """Set live environment.""" + return self.environment(Environment.LIVE) + + 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": + """Set request timeout.""" + self._config_dict["timeout"] = seconds + return self + + 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": + """Set retry delay.""" + self._config_dict["retry_delay"] = delay + return self + + def enable_logging(self) -> "ConfigBuilder": + """Enable logging.""" + self._config_dict["logging_enabled"] = True + return self + + def disable_logging(self) -> "ConfigBuilder": + """Disable logging.""" + self._config_dict["logging_enabled"] = False + return self + + def enable_ssl_verification(self) -> "ConfigBuilder": + """Enable SSL verification.""" + self._config_dict["verify_ssl"] = True + return self + + 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": + """Set custom API endpoint.""" + self._config_dict["custom_endpoint"] = endpoint + return self + + 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": + """Set maximum redirects.""" + self._config_dict["max_redirects"] = redirects + return self + + def build(self) -> BuckarooConfig: + """ + Build the configuration. + + Returns: + BuckarooConfig: The built configuration. + """ + return BuckarooConfig.from_dict(self._config_dict) + + +# Convenience functions for common configurations +def create_test_config(**kwargs) -> BuckarooConfig: + """ + Create a test configuration with optional overrides. + + Args: + **kwargs: Configuration overrides. + + Returns: + BuckarooConfig: Test configuration. + """ + config = TestConfig() + if kwargs: + return config.copy(**kwargs) + return config + + +def create_production_config(**kwargs) -> BuckarooConfig: + """ + Create a production configuration with optional overrides. + + Args: + **kwargs: Configuration overrides. + + Returns: + BuckarooConfig: Production configuration. + """ + config = ProductionConfig() + if kwargs: + return config.copy(**kwargs) + return config + + +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. + """ + if mode.lower() == "test": + return create_test_config() + elif mode.lower() == "live": + return create_production_config() + else: + raise ValueError(f"Invalid mode: {mode}. Must be 'test' or 'live'.") diff --git a/buckaroo/exceptions/__init__.py b/buckaroo/exceptions/__init__.py new file mode 100644 index 0000000..2ba345f --- /dev/null +++ b/buckaroo/exceptions/__init__.py @@ -0,0 +1,11 @@ +from ._buckaroo_error import BuckarooError +from ._authentication_error import AuthenticationError +from ._parameter_validation_error import ParameterValidationError +from buckaroo.http.client import BuckarooApiError + +__all__ = [ + "BuckarooError", + "AuthenticationError", + "ParameterValidationError", + "BuckarooApiError", +] diff --git a/buckaroo/exceptions/_authentication_error.py b/buckaroo/exceptions/_authentication_error.py new file mode 100644 index 0000000..1e52d00 --- /dev/null +++ b/buckaroo/exceptions/_authentication_error.py @@ -0,0 +1,5 @@ +from ._buckaroo_error import BuckarooError + + +class AuthenticationError(BuckarooError): + pass diff --git a/buckaroo/exceptions/_buckaroo_error.py b/buckaroo/exceptions/_buckaroo_error.py new file mode 100644 index 0000000..89dbd36 --- /dev/null +++ b/buckaroo/exceptions/_buckaroo_error.py @@ -0,0 +1,12 @@ +from typing import Any, Dict, Optional + + +class BuckarooError(Exception): + _message: Optional[str] + http_body: Optional[str] + http_status: Optional[int] + json_body: Optional[object] + headers: Optional[Dict[str, str]] + code: Optional[str] + request_id: Optional[str] + error: Optional[Any] diff --git a/buckaroo/exceptions/_parameter_validation_error.py b/buckaroo/exceptions/_parameter_validation_error.py new file mode 100644 index 0000000..8bc2af3 --- /dev/null +++ b/buckaroo/exceptions/_parameter_validation_error.py @@ -0,0 +1,59 @@ +""" +Exception for parameter validation errors. +""" + +from ._buckaroo_error import BuckarooError + + +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, + ): + """ + Initialize parameter validation error. + + Args: + message (str): Error message + parameter_name (str, optional): Name of the parameter that failed validation + expected_type (str, optional): Expected parameter type + action (str, optional): Action being performed when validation failed + service_name (str, optional): Service name where validation failed + """ + super().__init__(message) + self.parameter_name = parameter_name + self.expected_type = expected_type + self.action = action + self.service_name = service_name + self._message = message + + def __str__(self): + """Return string representation of the error.""" + return self._message + + +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 + """ + 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 + ) diff --git a/buckaroo/factories/__init__.py b/buckaroo/factories/__init__.py new file mode 100644 index 0000000..9700531 --- /dev/null +++ b/buckaroo/factories/__init__.py @@ -0,0 +1,6 @@ +# Factories module +from .payment_method_factory import PaymentMethodFactory +from .solution_method_factory import SolutionMethodFactory +from .builder_factory import BuilderFactory + +__all__ = ["PaymentMethodFactory", "SolutionMethodFactory", "BuilderFactory"] diff --git a/buckaroo/factories/builder_factory.py b/buckaroo/factories/builder_factory.py new file mode 100644 index 0000000..627e3da --- /dev/null +++ b/buckaroo/factories/builder_factory.py @@ -0,0 +1,78 @@ +from abc import ABC, abstractmethod +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 + """ + pass diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py new file mode 100644 index 0000000..c040d52 --- /dev/null +++ b/buckaroo/factories/payment_method_factory.py @@ -0,0 +1,189 @@ +from typing import Dict, Type +import logging + +from .builder_factory import BuilderFactory +from buckaroo.builders.payments.alipay_builder import AlipayBuilder +from buckaroo.builders.payments.apple_pay_builder import ApplePayBuilder +from buckaroo.builders.payments.bancontact_builder import BancontactBuilder +from buckaroo.builders.payments.belfius_builder import BelfiusBuilder +from buckaroo.builders.payments.bizum_builder import BizumBuilder +from buckaroo.builders.payments.blik_builder import BlikBuilder +from buckaroo.builders.payments.buckaroo_voucher_builder import BuckarooVoucherBuilder +from buckaroo.builders.payments.click_to_pay_builder import ClickToPayBuilder +from buckaroo.builders.payments.credit_card_builder import CreditcardBuilder +from buckaroo.builders.payments.default_builder import DefaultBuilder +from buckaroo.builders.payments.eps_builder import EpsBuilder +from buckaroo.builders.payments.external_payment_builder import ExternalPaymentBuilder +from buckaroo.builders.payments.giftcards_builder import GiftcardsBuilder +from buckaroo.builders.payments.google_pay_builder import GooglePayBuilder +from buckaroo.builders.payments.ideal_qr_builder import IdealQrBuilder +from buckaroo.builders.payments.in3_builder import In3Builder +from buckaroo.builders.payments.kbc_builder import KBCBuilder +from buckaroo.builders.payments.billink_builder import BillinkBuilder +from buckaroo.builders.payments.klarna_builder import KlarnaBuilder +from buckaroo.builders.payments.klarnakp_builder import KlarnaKPBuilder +from buckaroo.builders.payments.knaken_builder import KnakenBuilder +from buckaroo.builders.payments.przelewy24_builder import Przelewy24Builder +from buckaroo.builders.payments.riverty_builder import RivertyBuilder +from buckaroo.builders.payments.sepadirectdebit_builder import SepaDirectDebitBuilder +from buckaroo.builders.payments.swish_builder import SwishBuilder +from buckaroo.builders.payments.transfer_builder import TransferBuilder +from buckaroo.builders.payments.trustly_builder import TrustlyBuilder +from buckaroo.builders.payments.twint_builder import TwintBuilder +from buckaroo.builders.payments.wechatpay_builder import WeChatPayBuilder +from buckaroo.builders.payments.wero_builder import WeroBuilder +from buckaroo.builders.payments.payment_builder import PaymentBuilder +from buckaroo.builders.payments.ideal_builder import IdealBuilder +from buckaroo.builders.payments.sofort_builder import SofortBuilder +from buckaroo.builders.payments.payconiq_builder import PayconiqBuilder +from buckaroo.builders.payments.voucher_builder import VoucherBuilder +from buckaroo.builders.payments.multibanco_builder import MultibancoBuilder +from buckaroo.builders.payments.mbway_builder import MBWayBuilder +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, + "applepay": ApplePayBuilder, + "bancontact": BancontactBuilder, + "belfius": BelfiusBuilder, + "bizum": BizumBuilder, + "billink": BillinkBuilder, + "blik": BlikBuilder, + "buckaroovoucher": BuckarooVoucherBuilder, + "clicktopay": ClickToPayBuilder, + "creditcard": CreditcardBuilder, + "default": DefaultBuilder, + "externalpayment": ExternalPaymentBuilder, + "eps": EpsBuilder, + "giftcards": GiftcardsBuilder, + "googlepay": GooglePayBuilder, + "ideal": IdealBuilder, + "idealqr": IdealQrBuilder, + "in3": In3Builder, + "kbc": KBCBuilder, + "knaken": KnakenBuilder, + "klarna": KlarnaBuilder, + "klarnakp": KlarnaKPBuilder, + "multibanco": MultibancoBuilder, + "mbway": MBWayBuilder, + "payconiq": PayconiqBuilder, + "paypal": PaypalBuilder, + "paybybank": PayByBankBuilder, + "przelewy24": Przelewy24Builder, + "riverty": RivertyBuilder, + "sepadirectdebit": SepaDirectDebitBuilder, + "sofort": SofortBuilder, + "swish": SwishBuilder, + "transfer": TransferBuilder, + "trustly": TrustlyBuilder, + "twint": TwintBuilder, + "voucher": VoucherBuilder, + "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( + f"Unsupported payment method: {method}. " + f"Available methods: {available_methods}. " + f"Using DefaultBuilder as fallback." + ) + # 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() + + # Check Services.ServiceList for payment method detection + services = payload.get("Services", {}) + service_list = services.get("ServiceList", []) + + if service_list: + for service in service_list: + 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" diff --git a/buckaroo/factories/solution_method_factory.py b/buckaroo/factories/solution_method_factory.py new file mode 100644 index 0000000..afd0303 --- /dev/null +++ b/buckaroo/factories/solution_method_factory.py @@ -0,0 +1,100 @@ +from typing import Dict, Type +import logging + +from .builder_factory import BuilderFactory +from buckaroo.builders.solutions.subscription_builder import SubscriptionBuilder +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( + f"Unsupported payment method: {method}. " + f"Available methods: {available_methods}. " + f"Using DefaultBuilder as fallback." + ) + # 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() + + return "default" diff --git a/buckaroo/http/__init__.py b/buckaroo/http/__init__.py new file mode 100644 index 0000000..3607e6b --- /dev/null +++ b/buckaroo/http/__init__.py @@ -0,0 +1,9 @@ +""" +HTTP module for Buckaroo SDK. + +This module contains HTTP client functionality for communicating with the Buckaroo API. +""" + +from .client import BuckarooHttpClient, BuckarooResponse, BuckarooApiError + +__all__ = ["BuckarooHttpClient", "BuckarooResponse", "BuckarooApiError"] diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py new file mode 100644 index 0000000..3c2073e --- /dev/null +++ b/buckaroo/http/client.py @@ -0,0 +1,353 @@ +""" +HTTP Client Module for Buckaroo SDK. + +This module provides HTTP client functionality for communicating with the Buckaroo API, +including request/response handling, authentication, and error management. +""" + +import json +import time +import hashlib +import hmac +import base64 +from typing import Dict, Any, Optional +from urllib.parse import urlencode, quote +import uuid + +from ..config.buckaroo_config import BuckarooConfig +from ..exceptions._authentication_error import AuthenticationError +from .strategies import HttpStrategyFactory, HttpResponse + + +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, + config: BuckarooConfig, + 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(), + } + + self.http_strategy.configure(**strategy_config) + + def _generate_hmac_signature( + 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") + md5_hash = hashlib.md5(content_bytes).digest() + content_b64 = base64.b64encode(md5_hash).decode("utf-8") + else: + content_b64 = "" + + # Remove protocol from URL and encode for HMAC signature + url_without_protocol = url + if url.startswith("https://"): + url_without_protocol = url[8:] + 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() + + # 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") + signature = hmac.new(secret_key_bytes, signature_data_bytes, hashlib.sha256).digest() + 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, + } + + def post( + self, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + 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": + """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": + """Make an HTTP request to the Buckaroo API.""" + # Build full URL + base_url = self.config.api_endpoint + if not endpoint.startswith("/"): + endpoint = "/" + endpoint + url = f"{base_url}{endpoint}" + + # Add URL parameters + if params: + url += "?" + urlencode(params) + + # Prepare request body + content = "" + if data: + content = json.dumps(data, separators=(",", ":")) + + # Generate authentication headers + auth_headers = self._generate_hmac_signature(method, url, content) + + try: + 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, + ) + except (AuthenticationError, BuckarooApiError): + raise + except Exception as 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. Raises BuckarooApiError on malformed JSON.""" + text = self._response.text + if not text or not text.strip(): + self._data = {} + return + try: + 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 + success_statuses = [190, 490, 491, 492, 790, 791, 792, 793] + status = self._data.get("Status", {}) + if status and "Code" in status: + code = status.get("Code") + # Handle nested Code structure + if isinstance(code, dict): + actual_code = code.get("Code") + elif isinstance(code, int): + 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: + return None + services = self._data.get("Services", []) + if isinstance(services, list) and services: + return services[0].get("TransactionKey") + elif isinstance(services, dict): + service_list = services.get("ServiceList", []) + 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: + # Try to get description from Code if SubCode is None + code = status.get("Code") + 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: + return None + required_action = self._data.get("RequiredAction") + 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 { + "status_code": self.status_code, + "success": self.success, + "data": self.data, + "headers": self.headers, + } + + +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.""" + return self.response.data if self.response else {} diff --git a/buckaroo/http/strategies/__init__.py b/buckaroo/http/strategies/__init__.py new file mode 100644 index 0000000..eb477d7 --- /dev/null +++ b/buckaroo/http/strategies/__init__.py @@ -0,0 +1,18 @@ +""" +HTTP Client Strategies for Buckaroo SDK. + +This package provides different HTTP client implementations using the strategy pattern. +""" + +from .http_strategy import HttpStrategy, HttpResponse +from .requests_strategy import RequestsStrategy +from .curl_strategy import CurlStrategy +from .strategy_factory import HttpStrategyFactory + +__all__ = [ + "HttpStrategy", + "HttpResponse", + "RequestsStrategy", + "CurlStrategy", + "HttpStrategyFactory", +] diff --git a/buckaroo/http/strategies/curl_strategy.py b/buckaroo/http/strategies/curl_strategy.py new file mode 100644 index 0000000..6a113c4 --- /dev/null +++ b/buckaroo/http/strategies/curl_strategy.py @@ -0,0 +1,245 @@ +""" +cURL-based HTTP Strategy for Buckaroo SDK. + +This module provides an HTTP strategy implementation using system curl command. +""" + +import subprocess +import shutil +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 + - verify_ssl: Whether to verify SSL certificates + - 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", {}) + + 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: + """ + Make an HTTP request using curl command. + + Args: + method: HTTP method (GET, POST, etc.) + url: Request URL + headers: Request headers + 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 + """ + # Build curl command + cmd = self._build_curl_command( + method=method, + url=url, + headers=headers, + data=data, + timeout=timeout or self._timeout, + verify_ssl=verify_ssl, + ) + + # Execute curl with retry logic + last_exception = None + for attempt in range(self._retry_attempts): + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout or self._timeout, + 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" + ) + if attempt == self._retry_attempts - 1: + raise last_exception + except subprocess.SubprocessError as e: + last_exception = Exception(f"Curl command failed: {str(e)}") + if attempt == self._retry_attempts - 1: + raise last_exception + except Exception as e: + 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, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[str] = None, + timeout: int = 30, + verify_ssl: bool = True, + ) -> List[str]: + """ + Build the curl command arguments. + + Args: + method: HTTP method + url: Request URL + headers: Request headers + 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 + ] + + # SSL verification + if not verify_ssl: + 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}"]) + + # Add data for POST/PUT requests + 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 + ) + + # 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) + 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") + # 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 + ) + + 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 + + def get_name(self) -> str: + """ + Get the name of this strategy. + + Returns: + str: Strategy name + """ + return "curl" diff --git a/buckaroo/http/strategies/http_strategy.py b/buckaroo/http/strategies/http_strategy.py new file mode 100644 index 0000000..e8de5c4 --- /dev/null +++ b/buckaroo/http/strategies/http_strategy.py @@ -0,0 +1,95 @@ +""" +HTTP Strategy Interface for Buckaroo SDK. + +This module defines the abstract base class for HTTP client strategies. +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional +from dataclasses import dataclass + + +@dataclass +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: + return {"raw_content": self.text} + + +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 + """ + + @abstractmethod + 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: + """ + Make an HTTP request. + + Args: + method: HTTP method (GET, POST, etc.) + url: Request URL + headers: Request headers + 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 + """ + + @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 + """ + + @abstractmethod + def get_name(self) -> str: + """ + Get the name of this HTTP strategy. + + Returns: + str: Strategy name + """ diff --git a/buckaroo/http/strategies/requests_strategy.py b/buckaroo/http/strategies/requests_strategy.py new file mode 100644 index 0000000..7aa579a --- /dev/null +++ b/buckaroo/http/strategies/requests_strategy.py @@ -0,0 +1,163 @@ +""" +Requests-based HTTP Strategy for Buckaroo SDK. + +This module provides an HTTP strategy implementation using the requests library. +""" + +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: + from requests.packages.urllib3.util.retry import Retry + 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 + + +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 + - retry_delay: Delay between retries + - default_headers: Default headers to set + """ + if not REQUESTS_AVAILABLE: + raise ImportError( + "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) + + # 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"], + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + except (NameError, TypeError): + # Fallback if Retry is not available + 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", {}) + if default_headers: + self.session.headers.update(default_headers) + + 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: + """ + Make an HTTP request using requests library. + + Args: + method: HTTP method (GET, POST, etc.) + url: Request URL + headers: Request headers + 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, + } + + if 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, + ) + + except requests.exceptions.Timeout: + 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" diff --git a/buckaroo/http/strategies/strategy_factory.py b/buckaroo/http/strategies/strategy_factory.py new file mode 100644 index 0000000..2ec951a --- /dev/null +++ b/buckaroo/http/strategies/strategy_factory.py @@ -0,0 +1,112 @@ +""" +HTTP Strategy Factory for Buckaroo SDK. + +This module provides automatic selection of the best available HTTP strategy. +""" + +from typing import Optional, List, Type +from .http_strategy import HttpStrategy +from .requests_strategy import RequestsStrategy +from .curl_strategy import CurlStrategy + + +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 + ] + + @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 + """ + # If specific strategy requested, try to use it + if preferred_strategy: + strategy = cls._create_named_strategy(preferred_strategy) + if strategy and strategy.is_available(): + return strategy + else: + available_strategies = cls.get_available_strategies() + raise RuntimeError( + 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, + } + + 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 + """ + available = [] + for strategy_class in cls._STRATEGY_CLASSES: + strategy = strategy_class() + 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 diff --git a/buckaroo/models/__init__.py b/buckaroo/models/__init__.py new file mode 100644 index 0000000..6778dee --- /dev/null +++ b/buckaroo/models/__init__.py @@ -0,0 +1,25 @@ +""" +Models package for Buckaroo SDK. + +This package contains all data models and response objects. +""" + +from .payment_response import ( + BuckarooStatusCode, + PaymentResponse, + RequiredAction, + Service, + ServiceParameter, + Status, + StatusCode, +) + +__all__ = [ + "BuckarooStatusCode", + "PaymentResponse", + "RequiredAction", + "Service", + "ServiceParameter", + "Status", + "StatusCode", +] diff --git a/buckaroo/models/payment_request.py b/buckaroo/models/payment_request.py new file mode 100644 index 0000000..017a95c --- /dev/null +++ b/buckaroo/models/payment_request.py @@ -0,0 +1,141 @@ +from typing import List, Dict, Any, Optional, Union +from dataclasses import dataclass + + +@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, + } + + +@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} + + +@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} + + if self.parameters: + if isinstance(self.parameters, list): + # Parameters array format (for methods like IdealQr) + service_dict["Parameters"] = [param.to_dict() for param in self.parameters] + 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]} + + 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 + invoice: str + return_url: str + return_url_cancel: str + return_url_error: str + return_url_reject: str + continue_on_incomplete: str = "1" + push_url: Optional[str] = None + 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 = { + "Currency": self.currency, + "AmountDebit": self.amount_debit, + "Description": self.description, + "Invoice": self.invoice, + "ReturnURL": self.return_url, + "ReturnURLCancel": self.return_url_cancel, + "ReturnURLError": self.return_url_error, + "ReturnURLReject": self.return_url_reject, + "ContinueOnIncomplete": self.continue_on_incomplete, + } + + if self.push_url: + request_dict["PushURL"] = self.push_url + if self.push_url_failure: + request_dict["PushURLFailure"] = self.push_url_failure + + 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 diff --git a/buckaroo/models/payment_response.py b/buckaroo/models/payment_response.py new file mode 100644 index 0000000..fc5e7d5 --- /dev/null +++ b/buckaroo/models/payment_response.py @@ -0,0 +1,426 @@ +""" +Payment Response Model for Buckaroo SDK. + +This module provides response objects for payment transactions. +""" + +from typing import Any, Dict, Iterator, List, Optional +from dataclasses import dataclass +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": + """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", "")) + # Handle simple structure: {"Code": 490} or just integer + elif isinstance(data, dict): + return cls(code=data.get("Code", 0), description=data.get("Description", "")) + # Handle direct integer + elif isinstance(data, int): + return cls(code=data, description="") + else: + 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": + """Create Status from dictionary.""" + if data is None: + data = {} + + # Handle SubCode being None + sub_code_data = data.get("SubCode") + if sub_code_data is None: + sub_code_data = {} + + return cls( + code=StatusCode.from_dict(data.get("Code", {})), + sub_code=StatusCode.from_dict(sub_code_data), + 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": + """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), + ) + + +@dataclass +class ServiceParameter: + """Represents a service parameter.""" + + name: str + value: Any + + @classmethod + 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")) + + +@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": + """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) + + +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() + """ + if response_data is None: + 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", {}) + + # 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", {}) + + # Payment identifiers + 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 + + # 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 + ) + + # Services + self.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") + + # 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.message = data.get("Message") + 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") + + def is_pending(self) -> bool: + """Check if the payment is pending.""" + if self.status and self.status.code: + 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: + 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: + 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 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": + 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 + """ + for service in self.services: + for param in service.parameters: + if param.name.lower() == parameter_name.lower(): + return param.value + return None + + _ERROR_TYPES = ( + "ChannelErrors", + "ServiceErrors", + "ActionErrors", + "ParameterErrors", + "CustomParameterErrors", + ) + + @staticmethod + def _normalize_error_bucket(bucket: Any) -> List[Dict[str, Any]]: + """Coerce a ``RequestErrors`` bucket into a list of dict entries. + + Real-world Buckaroo responses occasionally collapse a single-error + bucket into a bare dict, or supply unexpected scalars. Coerce both + shapes here so callers can iterate without type checks. + """ + if isinstance(bucket, list): + return [entry for entry in bucket if isinstance(entry, dict)] + if isinstance(bucket, dict): + return [bucket] + return [] + + def _iter_error_entries(self) -> Iterator[Dict[str, Any]]: + """Yield error-entry dicts across every bucket in priority order.""" + errors = self.request_errors + if not isinstance(errors, dict): + return + for bucket_name in self._ERROR_TYPES: + for entry in self._normalize_error_bucket(errors.get(bucket_name)): + yield entry + + def has_error(self) -> bool: + """Return True when ``RequestErrors`` carries any usable entry.""" + return next(self._iter_error_entries(), None) is not None + + def get_first_error(self) -> Dict[str, Any]: + """Return the first error entry from ``RequestErrors``, or ``{}``.""" + return next(self._iter_error_entries(), {}) + + def has_consumer_message(self) -> bool: + """Return True when the response carries a non-empty ConsumerMessage.""" + message = self.consumer_message or {} + if isinstance(message, dict): + return bool(message.get("HtmlText")) + return False + + def get_consumer_message(self) -> str: + """Return the consumer-facing HTML message, or ``''``.""" + message = self.consumer_message or {} + if isinstance(message, dict): + return message.get("HtmlText") or "" + return "" + + def has_message(self) -> bool: + """Return True when the top-level ``Message`` field is set.""" + return bool(self.message) + + def get_message(self) -> str: + """Return the top-level ``Message`` field, or ``''``.""" + return self.message or "" + + def has_sub_code_message(self) -> bool: + """Return True when ``Status.SubCode.Description`` is set.""" + return bool(self.status and self.status.sub_code and self.status.sub_code.description) + + def get_sub_code_message(self) -> str: + """Return the ``Status.SubCode.Description``, or ``''``.""" + if self.has_sub_code_message(): + return self.status.sub_code.description + return "" + + def has_some_error(self) -> bool: + """Return True when any error/message channel carries text.""" + return bool(self.get_some_error()) + + def get_some_error(self) -> str: + """Return the most-specific error message from the response. + + Walks the response in priority order, mirroring PHP SDK's + ``TransactionResponse::getSomeError``: + + 1. The first entry from ``RequestErrors[*]`` (ChannelErrors, + ServiceErrors, ActionErrors, ParameterErrors, + CustomParameterErrors) → ``ErrorMessage``. + 2. ``ConsumerMessage.HtmlText`` (consumer-facing copy). + 3. Top-level ``Message`` (gateway-level message). + 4. ``Status.SubCode.Description`` (e.g. Riverty 491 reason). + + Returns ``''`` when none of those carry text. + """ + for entry in self._iter_error_entries(): + message = entry.get("ErrorMessage") + if message: + return message + if self.has_consumer_message(): + return self.get_consumer_message() + if self.has_message(): + return self.get_message() + if self.has_sub_code_message(): + return self.get_sub_code_message() + return "" + + 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" + ) + 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})" + ) diff --git a/buckaroo/observers/__init__.py b/buckaroo/observers/__init__.py new file mode 100644 index 0000000..8b3e6e7 --- /dev/null +++ b/buckaroo/observers/__init__.py @@ -0,0 +1,26 @@ +""" +Observer module for Buckaroo SDK. + +This module provides observer pattern implementations for monitoring +SDK operations, including logging, metrics collection, and event handling. +""" + +from .logging_observer import ( + BuckarooLoggingObserver, + ContextualLoggingObserver, + LogConfig, + LogLevel, + LogDestination, + create_logger, + create_logger_from_env, +) + +__all__ = [ + "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 new file mode 100644 index 0000000..767ede9 --- /dev/null +++ b/buckaroo/observers/logging_observer.py @@ -0,0 +1,527 @@ +""" +Logging observer for Buckaroo SDK. + +This module provides comprehensive logging capabilities for HTTP requests, +responses, exceptions, and general SDK operations. It supports both file +and stdout logging with configurable log levels and formats. +""" + +import logging +import json +import os +import sys +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" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class LogDestination(Enum): + """Log output destinations.""" + + STDOUT = "stdout" + FILE = "file" + BOTH = "both" + + +@dataclass +class LogConfig: + """Configuration for logging observer.""" + + level: LogLevel = LogLevel.INFO + destination: LogDestination = LogDestination.BOTH + log_file: str = "buckaroo_sdk.log" + max_file_size: int = 10 * 1024 * 1024 # 10MB + backup_count: int = 5 + include_request_body: bool = True + include_response_body: bool = True + mask_sensitive_data: bool = True + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + date_format: str = "%Y-%m-%d %H:%M:%S" + + +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", + "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) + + # 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, + ) + 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 + elif isinstance(data, list): + return [self._mask_sensitive_data(item) for item in data] + elif isinstance(data, str): + # Basic masking for potential sensitive data in strings + if any(sensitive in data.lower() for sensitive in self._sensitive_fields): + return "***POTENTIALLY_SENSITIVE***" + return data + else: + return data + + def _format_json(self, data: Any) -> str: + """Format data as pretty JSON string.""" + try: + if isinstance(data, str): + # Try to parse if it's a JSON string + try: + 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, + ): + """ + Log HTTP request details. + + Args: + method: HTTP method (GET, POST, etc.) + url: Request URL + headers: Request headers + 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}"] + + 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, + ): + """ + Log HTTP response details. + + Args: + status_code: HTTP status code + headers: Response headers + body: Response body + 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}"] + + 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)) + elif 400 <= status_code < 500: + 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 + ): + """ + 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") + + log_message = [ + f"EXCEPTION [{request_id}]", + f"Type: {type(exception).__name__}", + 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, + ): + """ + Log payment-specific operations. + + Args: + operation: Operation type (create, execute, validate, etc.) + payment_method: Payment method (ideal, creditcard, etc.) + amount: Payment amount + currency: Payment currency + **kwargs: Additional payment data + """ + request_id = kwargs.get("request_id", self._generate_request_id()) + + log_message = [ + f"PAYMENT OPERATION [{request_id}]", + f"Operation: {operation}", + 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 + new_value: New value + **kwargs: Additional context + """ + log_message = [ + "CONFIG CHANGE", + f"Parameter: {config_name}", + f"Old Value: {self._mask_sensitive_data(old_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", "") + 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": + """ + Create a child observer with additional context. + + Args: + context: Context to be included in all log messages + + Returns: + A contextual logging observer + """ + return ContextualLoggingObserver(self, context) + + +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, + ): + """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, + ): + """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 + ): + """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, + ): + """Log payment operation with context.""" + merged_kwargs = {**self.context, **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: + """ + 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) + 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 [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 + ) + + 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 + ) + + return BuckarooLoggingObserver(config) diff --git a/src/handlers/__init__.py b/buckaroo/py.typed old mode 100755 new mode 100644 similarity index 100% rename from src/handlers/__init__.py rename to buckaroo/py.typed diff --git a/buckaroo/services/__init__.py b/buckaroo/services/__init__.py new file mode 100644 index 0000000..0557eb6 --- /dev/null +++ b/buckaroo/services/__init__.py @@ -0,0 +1 @@ +# Services module diff --git a/buckaroo/services/hosted_fields_service.py b/buckaroo/services/hosted_fields_service.py new file mode 100644 index 0000000..df4f8bc --- /dev/null +++ b/buckaroo/services/hosted_fields_service.py @@ -0,0 +1,112 @@ +"""Hosted Fields token service for Buckaroo credit card tokenization. + +The Buckaroo Hosted Fields iframe tokenizes card data client-side, keeping +PAN/CVV out of the merchant server. The iframe needs a short-lived JWT, +minted via the OAuth2 ``client_credentials`` flow against +``auth.buckaroo.io`` using the merchant's Hosted Fields client_id / +client_secret pair (distinct from the Buckaroo store key / secret key +used for HMAC-signed API calls). + +The PHP SDK does not include this service; OAuth is out of scope for the +core transaction API. This module exists only to consolidate the Hosted +Fields token exchange in one place. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any, Dict, Optional +from urllib.parse import urlencode + +from ..exceptions._buckaroo_error import BuckarooError +from ..http.strategies import HttpStrategy, HttpStrategyFactory + + +class HostedFieldsService: + """Mint OAuth access tokens for the Buckaroo Hosted Fields iframe.""" + + OAUTH_TOKEN_URL = "https://auth.buckaroo.io/oauth/token" + GRANT_TYPE = "client_credentials" + DEFAULT_SCOPE = "hostedfields:save" + + def __init__( + self, + client_id: str, + client_secret: str, + http_strategy: Optional[HttpStrategy] = None, + timeout: int = 10, + ) -> None: + if client_id is None or not client_id.strip(): + raise ValueError("Client ID must be provided") + if client_secret is None or not client_secret.strip(): + raise ValueError("Client secret must be provided") + + self._client_id = client_id.strip() + self._client_secret = client_secret.strip() + self._timeout = timeout + self._injected_strategy = http_strategy + self._strategy: Optional[HttpStrategy] = None + + def _get_strategy(self) -> HttpStrategy: + if self._injected_strategy is not None: + return self._injected_strategy + if self._strategy is None: + self._strategy = HttpStrategyFactory.create_strategy() + self._strategy.configure() + return self._strategy + + def get_token(self, scope: Optional[str] = None) -> Dict[str, Any]: + """Mint a Hosted Fields access token. Returns the decoded JSON body.""" + scope = scope or self.DEFAULT_SCOPE + credentials = base64.b64encode( + f"{self._client_id}:{self._client_secret}".encode("utf-8") + ).decode("ascii") + body = urlencode({"scope": scope, "grant_type": self.GRANT_TYPE}) + + try: + response = self._get_strategy().request( + method="POST", + url=self.OAUTH_TOKEN_URL, + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + data=body, + timeout=self._timeout, + ) + except Exception as exc: + if isinstance(exc, BuckarooError): + raise + raise BuckarooError(f"Hosted Fields token request failed: {exc}") from exc + + if not response.success: + error = self._extract_error(response.text) + raise BuckarooError( + f"Hosted Fields token request failed (status {response.status_code}): {error}" + ) + + text = response.text or "" + if not text.strip(): + raise BuckarooError("Hosted Fields token response was empty") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise BuckarooError( + f"Failed to parse Hosted Fields token response JSON: {exc}" + ) from exc + + @staticmethod + def _extract_error(text: Optional[str]) -> str: + """Pull RFC6749 error fields from the response without echoing the full body.""" + if not text: + return "no body" + try: + data = json.loads(text) + except json.JSONDecodeError: + return "non-JSON body" + if not isinstance(data, dict): + return "unexpected body shape" + code = data.get("error") or "unknown_error" + description = data.get("error_description") + return f"{code}: {description}" if description else code diff --git a/buckaroo/services/payment_service.py b/buckaroo/services/payment_service.py new file mode 100644 index 0000000..c02d67b --- /dev/null +++ b/buckaroo/services/payment_service.py @@ -0,0 +1,132 @@ +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") \\ + ... .currency("EUR") \\ + ... .amount(6.0) \\ + ... .description("Test payment") \\ + ... .execute() + + >>> # Using parameters dictionary for quick setup + >>> payment = client.payments.create_payment("ideal", { + ... 'currency': 'EUR', + ... 'amount': 6.0, + ... 'description': 'Test payment', + ... 'invoice': 'INV-123', + ... 'return_url': 'https://example.com/success', + ... 'return_url_cancel': 'https://example.com/cancel', + ... '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', + ... 'amount': 6.0 + ... }).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({ + ... 'amount': 25.50, + ... 'currency': 'EUR', + ... 'description': 'Test payment', + ... 'issuer': 'ABNANL2A', + ... 'return_url': 'https://example.com/success' + ... }) + >>> response = payment.execute() + + >>> # Credit card payment (auto-detected by card fields) + >>> payment = app.payments.create({ + ... 'amount': 15.75, + ... 'currency': 'USD', + ... 'card_number': '4111111111111111', + ... 'expiry_month': '12', + ... 'expiry_year': '2025', + ... 'cvv': '123' + ... }) + >>> response = payment.execute() + + >>> # Refund operation (separate method call) + >>> refund_response = payment.refund('TXN_123', 10.00) + """ + # Detect payment method from payload + method = self._factory.detect_method_from_payload(payload) + + # Create payment using the detected method + return self.create_payment(method, payload) diff --git a/buckaroo/services/reply/__init__.py b/buckaroo/services/reply/__init__.py new file mode 100644 index 0000000..9a349d2 --- /dev/null +++ b/buckaroo/services/reply/__init__.py @@ -0,0 +1,15 @@ +"""Reply (push notification) verification handlers. + +Mirrors the PHP SDK ``Handlers/Reply/`` folder. Pick the strategy that +matches the wire format of the incoming Buckaroo push: + +* :class:`buckaroo.services.reply.http_post.HttpPost` — form-encoded pushes + (SHA-1 over ``brq_signature``). +* :class:`buckaroo.services.reply.json_reply.Json` — JSON pushes (HMAC-SHA256 + over the ``Authorization`` header). +""" + +from .http_post import HttpPost +from .json_reply import Json + +__all__ = ["HttpPost", "Json"] diff --git a/buckaroo/services/reply/http_post.py b/buckaroo/services/reply/http_post.py new file mode 100644 index 0000000..4ef65c0 --- /dev/null +++ b/buckaroo/services/reply/http_post.py @@ -0,0 +1,54 @@ +"""Form-encoded push verifier (SHA-1 over ``brq_signature``). + +Mirrors PHP ``Handlers/Reply/HttpPost.php``. Buckaroo signs form-style push +notifications with a SHA-1 digest over the sorted ``brq_*`` / ``add_*`` / +``cust_*`` parameters concatenated with the merchant secret key, delivered +in the ``brq_signature`` field of the form body. +""" + +from __future__ import annotations + +import hmac +from hashlib import sha1 +from typing import Mapping +from urllib.parse import unquote_plus + + +class HttpPost: + """Verify SHA-1 signatures on form-style Buckaroo pushes.""" + + SIGNATURE_FIELD = "brq_signature" + INCLUDE_PREFIXES = ("add_", "brq_", "cust_") + + def __init__(self, secret_key: str) -> None: + if secret_key is None or not secret_key.strip(): + raise ValueError("Secret key must be provided") + self.secret_key = secret_key.strip() + + def validate(self, params: Mapping[str, object]) -> bool: + """Return True if ``params['brq_signature']`` matches the computed signature.""" + provided = next( + (v for k, v in params.items() if k.lower() == self.SIGNATURE_FIELD), + None, + ) + if not provided or not isinstance(provided, str): + return False + expected = self.compute_signature(params) + return hmac.compare_digest(provided, expected) + + def compute_signature(self, params: Mapping[str, object]) -> str: + """Compute the expected SHA-1 signature for a form-style push.""" + decoded = [ + (k, unquote_plus(v) if isinstance(v, str) else v) + for k, v in params.items() + if k.lower() != self.SIGNATURE_FIELD + ] + filtered = [ + (k, v) + for k, v in decoded + if any(k.lower().startswith(p) for p in self.INCLUDE_PREFIXES) + ] + sorted_items = sorted(filtered, key=lambda pair: pair[0].lower()) + sign_string = "".join(f"{k}={v if v is not None else ''}" for k, v in sorted_items) + sign_string += self.secret_key + return sha1(sign_string.encode("utf-8")).hexdigest() diff --git a/buckaroo/services/reply/json_reply.py b/buckaroo/services/reply/json_reply.py new file mode 100644 index 0000000..d76e22e --- /dev/null +++ b/buckaroo/services/reply/json_reply.py @@ -0,0 +1,79 @@ +"""JSON push verifier (HMAC-SHA256 over the ``Authorization`` header). + +Mirrors PHP ``Handlers/Reply/Json.php`` (which delegates to +``Handlers/HMAC/Validator.php``). Buckaroo signs JSON push notifications +with an HMAC-SHA256 hash carried in the +``Authorization: hmac key:hash:nonce:time`` header. Verification recomputes +the hash from the URL, method, body, and merchant credentials, then +compares constant-time. + +Module file is ``json_reply.py`` (not ``json.py``) to avoid shadowing the +stdlib :mod:`json`. Class name is :class:`Json` for parity with the PHP SDK. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac as _hmac +from typing import Optional, Union +from urllib.parse import quote + + +class Json: + """Verify HMAC-SHA256 signatures on JSON-style Buckaroo pushes.""" + + def __init__(self, store_key: str, secret_key: str) -> None: + if store_key is None or not store_key.strip(): + raise ValueError("Store key must be provided") + if secret_key is None or not secret_key.strip(): + raise ValueError("Secret key must be provided") + self.store_key = store_key.strip() + self.secret_key = secret_key.strip() + + def validate( + self, + authorization: Optional[str], + uri: str, + method: str, + body: Union[str, bytes, None] = "", + ) -> bool: + """Return True if ``authorization`` is a valid HMAC for the request.""" + if not authorization: + return False + + parts = authorization.split(":") + if len(parts) != 4: + return False + provided_hash = parts[1] + nonce = parts[2] + timestamp = parts[3] + + content_b64 = self._md5_b64(body) + encoded_url = self._encode_url(uri) + signing_string = f"{self.store_key}{method}{encoded_url}{timestamp}{nonce}{content_b64}" + expected = base64.b64encode( + _hmac.new( + self.secret_key.encode("utf-8"), + signing_string.encode("utf-8"), + hashlib.sha256, + ).digest() + ).decode("ascii") + + return _hmac.compare_digest(provided_hash, expected) + + @staticmethod + def _md5_b64(body: Union[str, bytes, None]) -> str: + if not body: + return "" + if isinstance(body, str): + body = body.encode("utf-8") + return base64.b64encode(hashlib.md5(body).digest()).decode("ascii") + + @staticmethod + def _encode_url(url: str) -> str: + if url.startswith("https://"): + url = url[8:] + elif url.startswith("http://"): + url = url[7:] + return quote(url, safe="").lower() diff --git a/buckaroo/services/service_parameter_validator.py b/buckaroo/services/service_parameter_validator.py new file mode 100644 index 0000000..c1cb431 --- /dev/null +++ b/buckaroo/services/service_parameter_validator.py @@ -0,0 +1,372 @@ +""" +Service parameter validation for payment builders. +""" + +from typing import Dict, Any, List +from buckaroo.models.payment_request import Parameter +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("_", "") + + 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: + return + + 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): + return + + # Handle tuple of types (e.g., (str, bool)) + if isinstance(expected_type, tuple): + type_valid = any(isinstance(value, t) for t in expected_type) + 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"]: + 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(), + ) + else: + type_names = [t.__name__ for t in expected_type] + raise ParameterValidationError( + 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(), + ) + else: + if not isinstance(value, expected_type): + # Allow string representations of booleans + 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(), + ) + 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(), + ) + + 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 + """ + allowed_params = self.payment_builder.get_allowed_service_parameters(action) + + if key not in allowed_params: + raise ParameterValidationError( + f"Parameter '{key}' is not allowed for {self.payment_builder.get_service_name()} {action} action. " + f"Allowed parameters: {list(allowed_params.keys())}", + parameter_name=key, + action=action, + 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" + return value + + 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 = {} + for param in parameters: + # 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): + # 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 + ) + + # 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(), + ) + 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(), + ) + + 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() + } + + valid_parameters = [] + invalid_params = [] + + for param in parameters: + # For grouped parameters (like articles), validate the group_type instead of the parameter name + if param.group_type and param.group_type != "__from_service_params__": + normalized_group_type = self.normalize_parameter_name(param.group_type) + if normalized_group_type in normalized_allowed: + # 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" + ) + else: + # Regular parameter - validate including source check + normalized_param_name = self.normalize_parameter_name(param.name) + is_from_service_params = param.group_type == "__from_service_params__" + + if normalized_param_name in normalized_allowed: + 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." + ) + if requires_service_params and not is_from_service_params: + 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" + ) + continue + + # Convert parameter value for validation + param_value = self.normalize_parameter_value(param.value) + + self.validate_single_parameter(allowed_param_name, param_value, action) + valid_parameters.append(param) + 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" + ) + + if 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]: + """ + 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) + """ + 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() + } + + if normalized_param_name in normalized_allowed: + allowed_param_name = normalized_allowed[normalized_param_name] + param_value = self.normalize_parameter_value(param.value) + self.validate_single_parameter(allowed_param_name, param_value, action) + else: + raise ParameterValidationError( + 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(), + ) + + 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_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_param = self.normalize_parameter_name(param_name) + + return normalized_allowed.get(normalized_param, "") diff --git a/buckaroo/services/solution_service.py b/buckaroo/services/solution_service.py new file mode 100644 index 0000000..3834e97 --- /dev/null +++ b/buckaroo/services/solution_service.py @@ -0,0 +1,132 @@ +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. + + Args: + client: The Buckaroo client instance + """ + 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") \\ + ... .currency("EUR") \\ + ... .amount(6.0) \\ + ... .description("Test payment") \\ + ... .execute() + + >>> # Using parameters dictionary for quick setup + >>> payment = client.solution.create_payment("ideal", { + ... 'currency': 'EUR', + ... 'amount': 6.0, + ... 'description': 'Test payment', + ... 'invoice': 'INV-123', + ... 'return_url': 'https://example.com/success', + ... 'return_url_cancel': 'https://example.com/cancel', + ... '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', + ... 'amount': 6.0 + ... }).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({ + ... 'amount': 25.50, + ... 'currency': 'EUR', + ... 'description': 'Test payment', + ... 'issuer': 'ABNANL2A', + ... 'return_url': 'https://example.com/success' + ... }) + >>> response = payment.execute() + + >>> # Credit card payment (auto-detected by card fields) + >>> payment = app.payments.create({ + ... 'amount': 15.75, + ... 'currency': 'USD', + ... 'card_number': '4111111111111111', + ... 'expiry_month': '12', + ... 'expiry_year': '2025', + ... 'cvv': '123' + ... }) + >>> response = payment.execute() + + >>> # Refund operation (separate method call) + >>> refund_response = payment.refund('TXN_123', 10.00) + """ + # Detect payment method from payload + method = self._factory.detect_method_from_payload(payload) + + # Create payment using the detected method + return self.create_solution(method, payload) diff --git a/examples/demo_app_wrapper.py b/examples/demo_app_wrapper.py new file mode 100644 index 0000000..ed4e635 --- /dev/null +++ b/examples/demo_app_wrapper.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Demo of the Buckaroo app wrapper. + +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 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 LogDestination, LogLevel + + +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 + + +def demo_quick_setup() -> None: + """Minimal bootstrap: one call, ready to go.""" + print("\n1. Quick setup") + print("-" * 40) + if not _have_credentials(): + return + + 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") + + # 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(f" status.code={response.status.code.code} key={response.key}") + app.log_info("quick setup demo finished") + except Exception as e: + print(f" ❌ {e}") + + +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: + app = Buckaroo.from_env() + 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" ❌ {e}") + + +def demo_custom_config() -> None: + """Construct Buckaroo with a hand-built ``BuckarooConfig``.""" + print("\n3. Custom config") + print("-" * 40) + if not _have_credentials(): + return + + try: + config = BuckarooConfig( + 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, + ) + app = Buckaroo(config) + + # 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 = builder.pay() + print(f" status.code={response.status.code.code} key={response.key}") + except Exception as e: + print(f" ❌ {e}") + + +def demo_context_manager() -> None: + """``Buckaroo`` supports ``with`` — logs entry + exit automatically.""" + print("\n4. Context manager") + print("-" * 40) + if not _have_credentials(): + return + + try: + 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): + 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"}, + }, + ) + response = builder.pay() + app.log_info(f"payment {i + 1} dispatched", code=response.status.code.code) + except Exception as e: + print(f" ❌ {e}") + + +def main() -> None: + print("BUCKAROO SDK — APP WRAPPER DEMOS") + print("=" * 60) + 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("done.") + + +if __name__ == "__main__": + 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/requirements.txt b/requirements.txt old mode 100755 new mode 100644 index 82aa867..3098218 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,2 @@ -mypy==1.13.0 -pytest==8.3.3 -requests==2.32.3 -black==24.10.0 -python-dotenv==1.0.1 -setuptools==75.8.0 -types-setuptools==75.6.0.20241223 -types-requests==2.32.0.20250306 \ No newline at end of file +requests>=2.20.0 +typing_extensions>=4.5.0 \ No newline at end of file diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 9a1c635..9f17667 --- a/setup.py +++ b/setup.py @@ -1,36 +1,56 @@ +import os from setuptools import setup, find_packages -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() + +ROOT_DIR = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(ROOT_DIR, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +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", - version="1.0.0", - author="Buckaroo", - author_email="support@buckaroo.nl", - description="A Python SDK for Buckaroo payment methods", + name="buckaroo-sdk", + version=version_contents["VERSION"], + description="Python bindings for the Buckaroo API", long_description=long_description, long_description_content_type="text/markdown", + author="Buckaroo", + author_email="support@buckaroo.nl", url="https://github.com/buckaroo-it/BuckarooSDK_Python", - packages=find_packages(where="src"), - package_dir={"": "src"}, - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.6", + license="MIT", + keywords="buckaroo api payments", + packages=find_packages(exclude=["tests", "tests.*"]), + package_data={"buckaroo": ["py.typed"]}, + zip_safe=False, install_requires=[ - "httpx==0.28.0", - "python-dotenv==1.0.1", - "setuptools==75.8.0", + "typing_extensions >= 4.5.0", + "requests >= 2.20", ], - extras_require={ - "dev": [ - "mypy==1.13.0", - "pytest==8.3.3", - "black==24.10.0", - "types-setuptools==75.6.0.20241223", - ], + python_requires=">=3.9", + project_urls={ + "Homepage": "https://www.buckaroo.nl", + "Bug Tracker": "https://github.com/buckaroo-it/BuckarooSDK_Python/issues", + "Changes": "https://github.com/buckaroo-it/BuckarooSDK_Python/blob/master/CHANGELOG.md", + "Documentation": "https://github.com/buckaroo-it/BuckarooSDK_Python#readme", + "Source Code": "https://github.com/buckaroo-it/BuckarooSDK_Python/", }, + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "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", + ], ) diff --git a/src/buckaroo_client.py b/src/buckaroo_client.py deleted file mode 100755 index ef1b5b5..0000000 --- a/src/buckaroo_client.py +++ /dev/null @@ -1,160 +0,0 @@ -from typing import Optional - -import src.handlers.config.config_interface as config_interface -import src.handlers.config.default_config as default_config -import src.payment_methods.base.payable.payable_method_factory as payable_method_factory -import src.payment_methods.base.payable.payable_method_interface as payable_method_interface -import src.transaction.client as client - - -class BuckarooClient: - _config: config_interface.ConfigInterface - _client: client.Client - - def __init__( - self, - website_key: str | config_interface.ConfigInterface, - secret_key: str, - mode: Optional[str], - ): - self._config = self.generate_config(website_key, secret_key, mode) - self._client = client.Client(self._config) - - @property - def config(self) -> config_interface.ConfigInterface: - return self._config - - @config.setter - def config(self, config: config_interface.ConfigInterface) -> None: - self._config = config - - @property - def client(self) -> client.Client: - return self._client - - @client.setter - def client(self, client: "client.Client") -> None: - self._client = client - - def payable( - self, payment_method: str - ) -> payable_method_interface.PayableMethodInterface: - return payable_method_factory.payable_method_factory( - payment_method, self.client - ) - - # @todo: Implement authorizable(payment_method: string): AuthorizableMethodBuilderInterface - def authorizable(self, payment_method: str): - # @todo: Add the implementation for authorizable method - pass - - # @todo: Implement verifiable(payment_method: string): VerifiableMethodBuilderInterface - def verifiable(self, payment_method: str): - # @todo: Add the implementation for verifiable method - pass - - # @todo: Implement encrypted_payable(payment_method: string): EncryptableMethodBuilderInterface - def encrypted_payable(self, payment_method: str): - # @todo: Add the implementation for encrypted_payable method - pass - - # @todo: Implement payable_with_redirect(payment_method: string): RedirectPaymentMethodBuilderInterface - def payable_with_redirect(self, payment_method: str): - # @todo: Add the implementation for payable_with_redirect method - pass - - # @todo: Implement payable_with_emandates(payment_method: string): EmandatesPaymentMethodBuilderInterface - def payable_with_emandates(self, payment_method: str): - # @todo: Add the implementation for payable_with_emandates method - pass - - # @todo: Implement payable_with_one_click(payment_method: string): OneClickPaymentMethodBuilderInterface - def payable_with_one_click(self, payment_method: str): - # @todo: Add the implementation for payable_with_one_click method - pass - - # @todo: Implement complete_encrypted_payable(payment_method: string): CompleteEncryptedPaymentMethodBuilderInterface - def complete_encrypted_payable(self, payment_method: str): - # @todo: Add the implementation for complete_encrypted_payable method - pass - - # @todo: Implement voucher(payment_method: string): VoucherBuilderInterface - def voucher(self, payment_method: str): - # @todo: Add the implementation for voucher method - pass - - # @todo: Implement wallet(payment_method: string): BuckarooWalletBuilderInterface - def wallet(self, payment_method: str): - # @todo: Add the implementation for wallet method - pass - - # @todo: Implement credit_management(payment_method: string): CreditManagementBuilderInterface - def credit_management(self, payment_method: str): - # @todo: Add the implementation for credit_management method - pass - - # @todo: Implement emandates(payment_method: string): EmandatesBuilderInterface - def emandates(self, payment_method: str): - # @todo: Add the implementation for emandates method - pass - - # @todo: Implement fast_checkout(payment_method: string): FastCheckoutBuilderInterface - def fast_checkout(self, payment_method: str): - # @todo: Add the implementation for fast_checkout method - pass - - # @todo: Implement qr(payment_method: string): QRBuilderInterface - def qr(self, payment_method: str): - # @todo: Add the implementation for qr method - pass - - # @todo: Implement reserve(payment_method: string): ReserveBuilderInterface - def reserve(self, payment_method: str): - # @todo: Add the implementation for reserve method - pass - - # @todo: Implement market_place(payment_method: string): MarketplaceBuilderInterface - def market_place(self, payment_method: str): - # @todo: Add the implementation for market_place method - pass - - # @todo: Implement extra_info(payment_method: string): ExtraInfoBuilderInterface - def extra_info(self, payment_method: str): - # @todo: Add the implementation for extra_info method - pass - - # @todo: Implement subscriptions(payment_method: string): SubscriptionsBuilderInterface - def subscriptions(self, payment_method: str): - # @todo: Add the implementation for subscriptions method - pass - - # @todo: Implement get_active_subscriptions(): list - def get_active_subscriptions(self): - # @todo: Add the implementation for get_active_subscriptions method - pass - - # @todo: Implement batch(transactions: list): BatchTransactions - def batch(self, transactions: list): - # @todo: Add the implementation for batch method - pass - - # @todo: Implement transaction(transaction_key: str): TransactionService - def transaction(self, transaction_key: str): - # @todo: Add the implementation for transaction method - pass - - # @todo: Implement attachLogger(logger: Observer): self - def attachLogger(self, logger): - # @todo: Add the implementation for attachLogger method - pass - - def generate_config( - self, - website_key: str | config_interface.ConfigInterface, - secret_key: str, - mode: Optional[str], - ) -> config_interface.ConfigInterface: - if isinstance(website_key, config_interface.ConfigInterface): - return website_key - else: - return default_config.DefaultConfig(website_key, secret_key, mode) diff --git a/src/exceptions/buckaroo_exception.py b/src/exceptions/buckaroo_exception.py deleted file mode 100755 index 7ebb73b..0000000 --- a/src/exceptions/buckaroo_exception.py +++ /dev/null @@ -1,2 +0,0 @@ -class BuckarooException(Exception): - pass diff --git a/src/exceptions/transfer_exception.py b/src/exceptions/transfer_exception.py deleted file mode 100755 index c141352..0000000 --- a/src/exceptions/transfer_exception.py +++ /dev/null @@ -1,6 +0,0 @@ -import src.exceptions.buckaroo_exception as buckaroo_exception - - -class TransferException(buckaroo_exception.BuckarooException): - def __init__(self, message: str): - super().__init__(f"Buckaroo TransferException {message}") diff --git a/src/handlers/config/config_interface.py b/src/handlers/config/config_interface.py deleted file mode 100755 index c20f8e9..0000000 --- a/src/handlers/config/config_interface.py +++ /dev/null @@ -1,73 +0,0 @@ -from abc import ABC, abstractmethod - -import src.handlers.logging.subject_interface as subject_interface - - -class ConfigInterface(ABC): - @abstractmethod - def website_key(self) -> str: - pass - - @abstractmethod - def secret_key(self) -> str: - pass - - @abstractmethod - def is_live_mode(self) -> bool: - pass - - @abstractmethod - def mode(self) -> str: - pass - - @abstractmethod - def currency(self) -> str: - pass - - @abstractmethod - def return_url(self) -> str: - pass - - @abstractmethod - def return_url_cancel(self) -> str: - pass - - @abstractmethod - def push_url(self) -> str: - pass - - @abstractmethod - def platform_name(self) -> str: - pass - - @abstractmethod - def platform_version(self) -> str: - pass - - @abstractmethod - def module_supplier(self) -> str: - pass - - @abstractmethod - def module_name(self) -> str: - pass - - @abstractmethod - def module_version(self) -> str: - pass - - @abstractmethod - def culture(self) -> str: - pass - - @abstractmethod - def channel(self) -> str: - pass - - @abstractmethod - def set_logger(self, logger: subject_interface.SubjectInterface) -> None: - pass - - @abstractmethod - def get_logger(self) -> subject_interface.SubjectInterface: - pass diff --git a/src/handlers/config/default_config.py b/src/handlers/config/default_config.py deleted file mode 100755 index b23949b..0000000 --- a/src/handlers/config/default_config.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Optional -from dotenv import load_dotenv -import os - -import src.handlers.logging.default_logger as default_logger -import src.handlers.logging.subject_interface as subject_interface -import src.handlers.config.config_interface as config_interface - -load_dotenv() - - -class DefaultConfig(config_interface.ConfigInterface): - def __init__( - self, - website_key: str, - secret_key: str, - mode: Optional[str] = None, - currency: Optional[str] = None, - return_url: Optional[str] = None, - return_url_cancel: Optional[str] = None, - push_url: Optional[str] = None, - platform_name: Optional[str] = None, - platform_version: Optional[str] = None, - module_supplier: Optional[str] = None, - module_name: Optional[str] = None, - module_version: Optional[str] = None, - culture: Optional[str] = None, - channel: Optional[str] = None, - logger: Optional[subject_interface.SubjectInterface] = None, - ) -> None: - self.LIVE_MODE = "live" - self.TEST_MODE = "test" - - self._website_key = website_key - self._secret_key = secret_key - - self._mode = os.getenv("BPE_MODE", mode or self.TEST_MODE) - self._currency = os.getenv("BPE_CURRENCY_CODE", currency or "EUR") - self._return_url = os.getenv("BPE_RETURN_URL", return_url or "") - self._return_url_cancel = os.getenv( - "BPE_RETURN_URL_CANCEL", return_url_cancel or "" - ) - self._push_url = os.getenv("BPE_PUSH_URL", push_url or "") - self._platform_name = os.getenv( - "PlatformName", platform_name or "Default Platform" - ) - self._platform_version = os.getenv( - "PlatformVersion", platform_version or "1.0.0" - ) - self._module_supplier = os.getenv( - "ModuleSupplier", module_supplier or "Default Supplier" - ) - self._module_name = os.getenv("ModuleName", module_name or "Default Module") - self._module_version = os.getenv("ModuleVersion", module_version or "1.0.0") - self._culture = os.getenv("Culture", culture or "") - self._channel = os.getenv("Channel", channel or "") - self._logger = logger or default_logger.DefaultLogger() - - def website_key(self) -> str: - return self._website_key - - def secret_key(self) -> str: - return self._secret_key - - def is_live_mode(self) -> bool: - return self._mode == self.LIVE_MODE - - def mode(self) -> str: - return self._mode - - def currency(self) -> str: - return self._currency - - def return_url(self) -> str: - return self._return_url - - def return_url_cancel(self) -> str: - return self._return_url_cancel - - def push_url(self) -> str: - return self._push_url - - def platform_name(self) -> str: - return self._platform_name - - def platform_version(self) -> str: - return self._platform_version - - def module_supplier(self) -> str: - return self._module_supplier - - def module_name(self) -> str: - return self._module_name - - def module_version(self) -> str: - return self._module_version - - def culture(self) -> str: - return self._culture - - def channel(self) -> str: - return self._channel - - def set_mode(self, mode: Optional[str]) -> None: - if mode: - self._mode = mode - - def set_logger(self, logger: subject_interface.SubjectInterface) -> None: - self._logger = logger - - def get_logger(self) -> subject_interface.SubjectInterface: - if not self._logger: - raise ValueError("Logger has not been set.") - return self._logger diff --git a/src/handlers/hmac/generator.py b/src/handlers/hmac/generator.py deleted file mode 100755 index bb61efd..0000000 --- a/src/handlers/hmac/generator.py +++ /dev/null @@ -1,51 +0,0 @@ -import uuid -import time -import hmac -import hashlib -import base64 - -import src.handlers.config.config_interface as config_interface -import src.handlers.hmac.hmac as HMAC - - -class Generator: - - def __init__( - self, - config: config_interface.ConfigInterface, - data: str, - uri: str, - method: str = "POST", - ): - self._hmac = HMAC.Hmac( - config, - data, - uri, - str(uuid.uuid4()), - int(time.time()), - ) - self._method = method - - @property - def HMAC(self) -> HMAC.Hmac: - return self._hmac - - def generate(self) -> str: - hash_string = f"{self.HMAC.config.website_key()}{self._method}{self.HMAC.uri}{self.HMAC.time}{self.HMAC.nonce}{self.HMAC.base64_data}" - - hash_bytes = hmac.new( - self.HMAC.config.secret_key().encode("utf-8"), - hash_string.encode("utf-8"), - hashlib.sha256, - ).digest() - - hmac_value = base64.b64encode(hash_bytes).decode("utf-8") - - return ":".join( - [ - self.HMAC.config.website_key(), - hmac_value, - self.HMAC.nonce, - str(self.HMAC.time), - ] - ) diff --git a/src/handlers/hmac/hmac.py b/src/handlers/hmac/hmac.py deleted file mode 100755 index 97e42f6..0000000 --- a/src/handlers/hmac/hmac.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Optional -from urllib.parse import urlparse, urlencode -import json -import hashlib -import base64 -from typing import Union - -import src.handlers.config.config_interface as config_interface - - -class Hmac: - def __init__( - self, - config: config_interface.ConfigInterface, - base64_data: str = "", - uri: str = "", - nonce: str = "", - time: Union[int, str] = 0, - ) -> None: - self._config = config - self._base64_data = base64_data - self._uri = uri - self._nonce = nonce - self._time = time - - @property - def config(self) -> config_interface.ConfigInterface: - return self._config - - @property - def base64_data(self) -> str: - return self._base64_data - - @base64_data.setter - def base64_data(self, value: Optional[dict]) -> None: - if value: - self._base64_data = self.generate_base64_data(value) - self._base64_data = "" - - @property - def uri(self) -> str: - return self._uri - - @uri.setter - def uri(self, value: str) -> None: - if value: - self._uri = self.generate_uri(value) - self._uri = value - - @property - def nonce(self) -> str: - return self._nonce - - @nonce.setter - def nonce(self, value: str) -> None: - self._nonce = value - - @property - def time(self) -> Union[int, str]: - return self._time - - @time.setter - def time(self, value: Union[int, str]) -> None: - self._time = value - - def generate_uri(self, uri: str) -> str: - if uri: - parsed = urlparse(uri) - uri = parsed.netloc + parsed.path - return urlencode({"": uri}).lstrip("=").lower() - return "" - - def generate_base64_data(self, data=None) -> str: - if data: - if isinstance(data, dict): - data = json.dumps(data, ensure_ascii=False) - - md5_hash = hashlib.md5(data.encode("utf-8")).digest() - - return base64.b64encode(md5_hash).decode("utf-8") - - return self.base64_data diff --git a/src/handlers/hmac/validator.py b/src/handlers/hmac/validator.py deleted file mode 100755 index 3da16c7..0000000 --- a/src/handlers/hmac/validator.py +++ /dev/null @@ -1,46 +0,0 @@ -import base64 -import hmac -import hashlib -from typing import Any - -import src.handlers.config.config_interface as config_interface -import src.exceptions.buckaroo_exception as buckaroo_exception -import src.handlers.hmac.hmac as HMAC - - -class Validator: - def __init__(self, config: config_interface.ConfigInterface): - self._hmac = HMAC.Hmac(config, "", "", "", "") - self._hash = "" - - @property - def HMAC(self) -> HMAC.Hmac: - return self._hmac - - def validate(self, header: str, uri: str, method: str, data: Any) -> bool: - header_parts = header.split(":") - provided_hash = header_parts[1] - - self.HMAC.uri = uri - self.HMAC.nonce = header_parts[2] - self.HMAC.time = header_parts[3] - - self.HMAC.base64_data = data - - hmac_string = f"{self.HMAC.config.website_key()}{method}{self.HMAC.uri}{self.HMAC.time}{self.HMAC.nonce}{self.HMAC.base64_data}" - - hash_bytes = hmac.new( - self.HMAC.config.secret_key().encode("utf-8"), - hmac_string.encode("utf-8"), - hashlib.sha256, - ).digest() - - self._hash = base64.b64encode(hash_bytes).decode("utf-8") - - return provided_hash == self._hash - - def validate_or_fail(self, header: str, uri: str, method: str, data: Any) -> bool: - if self.validate(header, uri, method, data): - return True - - raise buckaroo_exception.BuckarooException("HMAC validation failed.") diff --git a/src/handlers/logging/default_logger.py b/src/handlers/logging/default_logger.py deleted file mode 100755 index 26b9eec..0000000 --- a/src/handlers/logging/default_logger.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import List, Union -from dotenv import load_dotenv -import os - -import src.handlers.logging.observers.error_reporter as error_reporter -import src.handlers.logging.observers.logger_reporter as logger_reporter -import src.handlers.logging.observer_interface as observer_interface -import src.handlers.logging.subject_interface as subject_interface - -load_dotenv() - - -class DefaultLogger(subject_interface.SubjectInterface): - def __init__(self) -> None: - self._observers: List[observer_interface.ObserverInterface] = [] - - if os.getenv("BPE_LOG") == "true": - self.attach(logger_reporter.LoggerReporter()) - - if os.getenv("BPE_REPORT_ERROR") == "true": - self.attach(error_reporter.ErrorReporter()) - - def attach( - self, - new_observer_interface: Union[ - observer_interface.ObserverInterface, - List[observer_interface.ObserverInterface], - ], - ) -> None: - if isinstance(new_observer_interface, list): - for obs_interface in new_observer_interface: - self.attach(obs_interface) - return - - if isinstance(new_observer_interface, observer_interface.ObserverInterface): - self._observers.append(new_observer_interface) - - def detach(self, ObserverInterface: observer_interface.ObserverInterface) -> None: - self._observers = [ - o for o in self._observers if not isinstance(o, type(ObserverInterface)) - ] - - def notify(self, method: str, message: str, context: List[dict] = []) -> None: - for observer_interface in self._observers: - observer_interface.update(method, message, context) - - def emergency(self, message: str, context: List[dict] = []) -> None: - self.notify("emergency", message, context) - - def alert(self, message: str, context: List[dict] = []) -> None: - self.notify("alert", message, context) - - def critical(self, message: str, context: List[dict] = []) -> None: - self.notify("critical", message, context) - - def error(self, message: str, context: List[dict] = []) -> None: - self.notify("error", message, context) - - def warning(self, message: str, context: List[dict] = []) -> None: - self.notify("warning", message, context) - - def notice(self, message: str, context: List[dict] = []) -> None: - self.notify("notice", message, context) - - def info(self, message: str, context: List[dict] = []) -> None: - self.notify("info", message, context) - - def debug(self, message: str, context: List[dict] = []) -> None: - if os.getenv("BPE_DEBUG") == "true": - self.notify("debug", message, context) - - def log(self, message: str, context: List[dict] = []) -> None: - self.notify("log", message, context) diff --git a/src/handlers/logging/loggable_interface.py b/src/handlers/logging/loggable_interface.py deleted file mode 100755 index 2a64e0b..0000000 --- a/src/handlers/logging/loggable_interface.py +++ /dev/null @@ -1,16 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional - -import src.handlers.logging.subject_interface as subject_interface - - -class Loggable(ABC): - @property - @abstractmethod - def logger(self) -> Optional[subject_interface.SubjectInterface]: - pass - - @logger.setter - @abstractmethod - def logger(self, logger: subject_interface.SubjectInterface) -> None: - pass diff --git a/src/handlers/logging/observer_interface.py b/src/handlers/logging/observer_interface.py deleted file mode 100755 index 102e4ab..0000000 --- a/src/handlers/logging/observer_interface.py +++ /dev/null @@ -1,8 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List, Dict - - -class ObserverInterface(ABC): - @abstractmethod - def update(self, method: str, message: str, context: List[Dict]): - pass diff --git a/src/handlers/logging/observers/error_reporter.py b/src/handlers/logging/observers/error_reporter.py deleted file mode 100755 index 9ac8927..0000000 --- a/src/handlers/logging/observers/error_reporter.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import List - -import src.handlers.logging.observer_interface as observer_interface - - -class ErrorReporter(observer_interface.ObserverInterface): - def __init__(self) -> None: - self._reportables: List[str] = ["error", "critical", "emergency"] - - # Method was created in PHP SDK but did not have any functionality, it should probably send a message to a slack channel or email? - # @TODO - def update(self, method: str, message: str, context: List[dict] = []) -> None: - if method in self._reportables: - print( - f"Firing off message: {message} for method: {method} to mail/report server/slack" - ) diff --git a/src/handlers/logging/observers/logger_reporter.py b/src/handlers/logging/observers/logger_reporter.py deleted file mode 100755 index 0d7a732..0000000 --- a/src/handlers/logging/observers/logger_reporter.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging -from typing import List, Dict -import src.handlers.logging.observer_interface as observer_interface - - -class LoggerReporter(observer_interface.ObserverInterface): - def __init__(self): - self.log = logging.getLogger("Buckaroo log") - self.log.setLevel(logging.INFO) - - stream_handler = logging.StreamHandler() - stream_handler.setLevel(logging.INFO) - - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - stream_handler.setFormatter(formatter) - - self.log.addHandler(stream_handler) - - def update(self, method: str, message: str, context: List[Dict] = []) -> None: - log_method = getattr(self.log, method, None) - if log_method: - log_method(message, *context) diff --git a/src/handlers/logging/subject_interface.py b/src/handlers/logging/subject_interface.py deleted file mode 100755 index db03e87..0000000 --- a/src/handlers/logging/subject_interface.py +++ /dev/null @@ -1,54 +0,0 @@ -from abc import ABC, abstractmethod -from typing import List, Dict - -import src.handlers.logging.observer_interface as observer_interface - - -class SubjectInterface(ABC): - @abstractmethod - def attach(self, observer: observer_interface.ObserverInterface) -> None: - pass - - @abstractmethod - def detach(self, observer: observer_interface.ObserverInterface) -> None: - pass - - @abstractmethod - def notify(self, method: str, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def emergency(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def alert(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def critical(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def error(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def warning(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def notice(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def info(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def debug(self, message: str, context: List[Dict] = []) -> None: - pass - - @abstractmethod - def log(self, message: str, context: List[Dict] = []) -> None: - pass diff --git a/src/handlers/reply/http_post.py b/src/handlers/reply/http_post.py deleted file mode 100755 index 42a0fb0..0000000 --- a/src/handlers/reply/http_post.py +++ /dev/null @@ -1,48 +0,0 @@ -from abc import ABC -from typing import Any, Dict -import hashlib - -import src.handlers.config.config_interface as config_interface -import src.handlers.reply.reply_strategy_interface as reply_strategy_interface - - -class HttpPost(reply_strategy_interface.ReplyStrategyInterface): - - def __init__(self, config: config_interface.ConfigInterface, data: Dict[str, Any]): - self.config = config - self.data = data - self.data = dict(sorted(self.data.items(), key=lambda item: item[0].lower())) - - def validate(self) -> bool: - acceptable_top_level = ["brq", "add", "cust", "BRQ", "ADD", "CUST"] - - filtered_data = {} - for key, value in self.data.items(): - if ( - key not in ["brq_signature", "BRQ_SIGNATURE"] - and key.split("_")[0] in acceptable_top_level - ): - filtered_data[key] = value - - data_string = "" - for key, value in filtered_data.items(): - data_string += f"{key}={html_entity_decode(value)}" - - data_string += self.config.secret_key().strip() - - signature = self.data.get("brq_signature") or self.data.get("BRQ_SIGNATURE") - - if not isinstance(signature, str): - raise ValueError("Signature must be a string") - - return self._hash_equals(data_string, signature) - - def _hash_equals(self, data_string: str, signature: str) -> bool: - computed_hash = hashlib.sha1(data_string.encode("utf-8")).hexdigest() - return computed_hash == signature - - -def html_entity_decode(value: str) -> str: - from html import unescape - - return unescape(value) diff --git a/src/handlers/reply/json.py b/src/handlers/reply/json.py deleted file mode 100755 index 420bf2c..0000000 --- a/src/handlers/reply/json.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any, Dict - -import src.handlers.hmac.validator as hmacValidator -import src.handlers.config.config_interface as config_interface -import src.handlers.reply.reply_strategy_interface as reply_strategy_interface - - -class Json(reply_strategy_interface.ReplyStrategyInterface): - - def __init__( - self, - config: config_interface.ConfigInterface, - data: Dict[str, Any], - auth_header: str, - uri: str, - method: str = "POST", - ): - self.config = config - self.data = data - self.auth_header = auth_header - self.uri = uri - self.method = method - - def validate(self) -> bool: - - validator = hmacValidator.Validator(self.config) - return validator.validate(self.auth_header, self.uri, self.method, self.data) diff --git a/src/handlers/reply/reply_handler.py b/src/handlers/reply/reply_handler.py deleted file mode 100755 index d843e3d..0000000 --- a/src/handlers/reply/reply_handler.py +++ /dev/null @@ -1,57 +0,0 @@ -import json -from typing import Self - -import src.handlers.reply.http_post as http_post -import src.handlers.reply.json as Json -import src.handlers.config.config_interface as config_interface -import src.handlers.reply.reply_strategy_interface as reply_strategy_interface - - -class ReplyHandler: - strategy: reply_strategy_interface.ReplyStrategyInterface - - def __init__( - self, - config: config_interface.ConfigInterface, - data: dict, - auth_header: str, - uri: str, - ): - self.config = config - self.data = data - self.auth_header = auth_header - self.uri = uri - self._is_valid = False - - def validate(self) -> Self: - self.set_strategy() - self._is_valid = self.strategy.validate() - return self - - def set_strategy(self) -> None: - if isinstance(self.data, str): - self.data = json.loads(self.data) - - if self.contains("Transaction", self.data) and self.auth_header and self.uri: - self.strategy = Json.Json( - self.config, self.data, self.auth_header, self.uri - ) - return - - if self.contains("brq_", self.data) or self.contains("BRQ_", self.data): - self.strategy = http_post.HttpPost(self.config, self.data) - return - - raise Exception("No reply handler strategy applied.") - - def contains(self, needle, data, strict=False) -> bool: - for key in data.keys(): - if strict and key == needle: - return True - if not strict and needle in key: - return True - return False - - @property - def is_valid(self) -> bool: - return self._is_valid diff --git a/src/handlers/reply/reply_strategy_interface.py b/src/handlers/reply/reply_strategy_interface.py deleted file mode 100755 index 4a494e4..0000000 --- a/src/handlers/reply/reply_strategy_interface.py +++ /dev/null @@ -1,8 +0,0 @@ -from abc import ABC, abstractmethod - - -class ReplyStrategyInterface(ABC): - - @abstractmethod - def validate(self) -> bool: - pass diff --git a/src/index.py b/src/index.py deleted file mode 100644 index ab0b4a0..0000000 --- a/src/index.py +++ /dev/null @@ -1,17 +0,0 @@ -import src.buckaroo_client as buckaroo_client - -from uuid import uuid4 - -x = buckaroo_client.BuckarooClient( - "EKkn4BByTj", "AB6176482E7B44C3BA7DB47F156088B5", "test" -) - -x.payable("ideal").pay( - { - "amountDebit": 40.30, - "order": str(uuid4()), - "invoice": str(uuid4()), - "trackAndTrace": "TR0F123456789", - "vatNumber": "2", - } -).execute() diff --git a/src/models/additional_parameters.py b/src/models/additional_parameters.py deleted file mode 100755 index 383976b..0000000 --- a/src/models/additional_parameters.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import List, Optional, Self - -import src.models.model_mixin as model_mixin - - -class AdditionalParameters(model_mixin.ModelMixin): - def __init__(self, values: Optional[dict] = None, is_data_request: bool = False): - self.AdditionalParameter: Optional[List[dict]] = [] - self.List: Optional[List[dict]] = [] - self.is_data_request: bool = is_data_request - - self.set_properties(values) - - def set_properties(self, data: Optional[dict] = None) -> Self: - for name, value in (data or {}).items(): - if self.is_data_request and self.List: - self.List.append({"Value": value, "Name": name}) - else: - if self.AdditionalParameter: - self.AdditionalParameter.append({"Value": value, "Name": name}) - - super().set_properties(data) - return self diff --git a/src/models/address.py b/src/models/address.py deleted file mode 100644 index d3faa5a..0000000 --- a/src/models/address.py +++ /dev/null @@ -1,11 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class Address(model_mixin.ModelMixin): - _street: str - _house_number: str - _house_number_additional: str - _zip_code: str - _city: str - _state: str - _country: str diff --git a/src/models/article.py b/src/models/article.py deleted file mode 100644 index 878a7a8..0000000 --- a/src/models/article.py +++ /dev/null @@ -1,14 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class Article(model_mixin.ModelMixin): - _identifier: str - _type: str - _brand: str - _manufacturer: str - _unit_code: str - _price: float - _quantity: int - _vat_percentage: float - _vat_category: str - _description: str diff --git a/src/models/bank_account.py b/src/models/bank_account.py deleted file mode 100644 index 97d03a6..0000000 --- a/src/models/bank_account.py +++ /dev/null @@ -1,7 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class BankAccount(model_mixin.ModelMixin): - _iban: str - _account_name: str - _bic: str diff --git a/src/models/client_ip.py b/src/models/client_ip.py deleted file mode 100755 index 8fca435..0000000 --- a/src/models/client_ip.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Optional, Self -import os -import socket - -import src.resources.constants.ip_protocol_version as ip_protocol_version -import src.models.model_mixin as model_mixin - - -class ClientIP(model_mixin.ModelMixin): - def __init__(self, ip: Optional[str] = None, ip_type: Optional[int] = None): - self._Type: Optional[int] = None - self._Address: Optional[str] = None - self.set_address(ip) - self.set_type(ip_type) - - self.set_properties() - - def set_address(self, ip: Optional[str]) -> Self: - self._Address = ip or self.get_remote_ip() - return self - - def set_type(self, ip_type: Optional[int]) -> Self: - self._Type = ip_type or ip_protocol_version.get_version(self._Address or "") - return self - - def get_remote_ip(self): - x_forwarded_for = os.environ.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - ip = x_forwarded_for.split(",")[0].strip() - if self.is_valid_ip(ip): - return ip - - http_x_forwarded_for = os.environ.get("HTTP_X_FORWARDED_FOR") - if http_x_forwarded_for: - ip = http_x_forwarded_for.split(",")[0].strip() - if self.is_valid_ip(ip): - return ip - - remote_addr = os.environ.get("REMOTE_ADDR") - if remote_addr and self.is_valid_ip(remote_addr): - return remote_addr - - return "127.0.0.1" - - def is_valid_ip(ip): - try: - socket.inet_aton(ip) - return True - except socket.error: - return False diff --git a/src/models/company.py b/src/models/company.py deleted file mode 100644 index 15b58ae..0000000 --- a/src/models/company.py +++ /dev/null @@ -1,8 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class BankAccount(model_mixin.ModelMixin): - _company_name: str - _vat_applicable: bool - _vat_number: str - _chamber_of_commerce: str diff --git a/src/models/custom_parameters.py b/src/models/custom_parameters.py deleted file mode 100755 index 36a75c0..0000000 --- a/src/models/custom_parameters.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import List, Optional, Self - -import src.models.model_mixin as model_mixin - - -class CustomParameters(model_mixin.ModelMixin): - def __init__(self, values: Optional[dict] = None): - self._List: List[dict] = [] - self.set_properties(values) - - def set_properties(self, data: Optional[dict] = None) -> Self: - for name, value in (data or {}).items(): - self._List.append({"Value": value, "Name": name}) - - super().set_properties(data) - return self diff --git a/src/models/debtor.py b/src/models/debtor.py deleted file mode 100644 index 7828ef9..0000000 --- a/src/models/debtor.py +++ /dev/null @@ -1,5 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class Debtor(model_mixin.ModelMixin): - _code: str diff --git a/src/models/email.py b/src/models/email.py deleted file mode 100644 index e880cc0..0000000 --- a/src/models/email.py +++ /dev/null @@ -1,8 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class BankAccount(model_mixin.ModelMixin): - _email: str - - def __init__(self): - self.set_properties({"email": self._email}) diff --git a/src/models/model_interface.py b/src/models/model_interface.py deleted file mode 100755 index deb5347..0000000 --- a/src/models/model_interface.py +++ /dev/null @@ -1,29 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Dict, Optional, Any, Self - - -class ModelInterface(ABC): - - @abstractmethod - def __getattr__(self, property_name: str) -> Any: - pass - - @abstractmethod - def __setattr__(self, property_name: str, value: Any) -> None: - pass - - @abstractmethod - def to_dict(self) -> Dict: - pass - - @abstractmethod - def set_properties(self, data: Optional[Dict] = None) -> Self: - pass - - @abstractmethod - def get_object_vars(self) -> Dict: - pass - - @abstractmethod - def service_parameter_key_of(self, property_name: str) -> str: - pass diff --git a/src/models/model_mixin.py b/src/models/model_mixin.py deleted file mode 100755 index 518ee53..0000000 --- a/src/models/model_mixin.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Any, Dict, Optional, Self - -import src.models.model_interface as model_interface - - -class ModelMixin(model_interface.ModelInterface): - def __getattr__(self, property_name: str) -> Any: - if property_name in self.__dict__: - return self.__dict__.get(property_name) - return None - - def __setattr__(self, property_name: str, value: Any) -> None: - self.__dict__[property_name.removeprefix("_")] = value - - def get_object_vars(self) -> Dict: - return self.__dict__ - - def set_properties(self, data: Optional[Dict] = None) -> Self: - if data: - for property_name, value in data.items(): - setattr(self, property_name, value) - return self - - def service_parameter_key_of(self, property_name: str) -> str: - cleaned_parameter = property_name.removeprefix("_") - parameter_words = cleaned_parameter.split("_") - return "".join(word.capitalize() for word in parameter_words) - - def to_dict(self) -> Dict: - return self._recursive_to_dict(self.__dict__) - - def _recursive_to_dict(self, data: Dict) -> Dict: - result = {} - for key, value in data.items(): - if isinstance(value, dict): - result[key] = self._recursive_to_dict(value) - else: - result[key] = value - return result diff --git a/src/models/payload/data_request_payload.py b/src/models/payload/data_request_payload.py deleted file mode 100755 index b5e3665..0000000 --- a/src/models/payload/data_request_payload.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Self - -import src.models.additional_parameters as additional_parameters -import src.models.payload.payload as payload - - -class DataRequestPayload(payload.Payload): - def set_properties(self, data=None) -> Self: - if "additionalParameters" in (data or {}): - self.additional_parameters = additional_parameters.AdditionalParameters( - data["additionalParameters"], True - ) - del data["additionalParameters"] - - super().set_properties(data) - return self diff --git a/src/models/payload/pay_payload.py b/src/models/payload/pay_payload.py deleted file mode 100755 index 8f42385..0000000 --- a/src/models/payload/pay_payload.py +++ /dev/null @@ -1,11 +0,0 @@ -import uuid - -import src.models.payload.payload as payload - - -class PayPayload(payload.Payload): - _order: str - - def __init__(self, values=dict): - self._order = f"ORDER_NO_{uuid.uuid4().hex}" - super().__init__(values) diff --git a/src/models/payload/payload.py b/src/models/payload/payload.py deleted file mode 100755 index 0a66375..0000000 --- a/src/models/payload/payload.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Optional, Dict, Self - -import src.models.model_mixin as model_mixin -import src.models.client_ip as client_ip -import src.models.additional_parameters as additional_parameters -import src.models.custom_parameters as custom_parameters - - -class Payload(model_mixin.ModelMixin): - def __init__(self, values: Optional[Dict] = None): - self._client_ip: Optional[client_ip.ClientIP] = None - self._currency: str = "" - self._return_url: str = "" - self._return_url_error: str = "" - self._return_url_cancel: str = "" - self._return_url_reject: str = "" - self._push_url: str = "" - self._push_url_failure: str = "" - self._invoice: str = "" - self._description: str = "" - self._original_transaction_key: str = "" - self._original_transaction_reference: str = "" - self._website_key: str = "" - self._culture: str = "" - self._start_recurrent: bool = False - self._continue_on_incomplete: str = "" - self._services_selectable_by_client: str = "" - self._services_excluded_for_client: str = "" - self._additional_parameters: Optional[ - additional_parameters.AdditionalParameters - ] = None - self._custom_parameters: Optional[custom_parameters.CustomParameters] = None - - self.set_properties(values) - - def set_properties(self, data: Optional[Dict] = None) -> Self: - if data is None: - return self - - if "customParameters" in data: - self._custom_parameters = custom_parameters.CustomParameters( - data["customParameters"] - ) - del data["customParameters"] - - if "additionalParameters" in data: - self._additional_parameters = additional_parameters.AdditionalParameters( - data["additionalParameters"] - ) - del data["additionalParameters"] - - if "clientIP" in data: - client_ip_data = data["clientIP"] - self._client_ip = client_ip.ClientIP( - ip=client_ip_data.get("address"), - ip_type=client_ip_data.get("type"), - ) - del data["clientIP"] - - super().set_properties(data) - return self diff --git a/src/models/payload/refund_payload.py b/src/models/payload/refund_payload.py deleted file mode 100755 index 50338dc..0000000 --- a/src/models/payload/refund_payload.py +++ /dev/null @@ -1,5 +0,0 @@ -import src.models.payload.payload as payload - - -class RefundPayload(payload.Payload): - _amount_credit: float diff --git a/src/models/person.py b/src/models/person.py deleted file mode 100644 index 5417108..0000000 --- a/src/models/person.py +++ /dev/null @@ -1,17 +0,0 @@ -import src.models.model_mixin as model_mixin -import src.models.recipient_interface as recipient_interface - - -class Person(model_mixin.ModelMixin, recipient_interface.RecipientInterface): - _category: str - _gender: str - _culture: str - _care_of: str - _title: str - _initials: str = "" - _name: str - _first_name: str - _last_name_prefix: str - _last_name: str - _birth_date: str = "" - _place_of_birth: str diff --git a/src/models/phone.py b/src/models/phone.py deleted file mode 100644 index 0cd40cb..0000000 --- a/src/models/phone.py +++ /dev/null @@ -1,8 +0,0 @@ -import src.models.model_mixin as model_mixin - - -class Phone(model_mixin.ModelMixin): - _land_line: str - _mobile: str - _phone: str - _fax: str diff --git a/src/models/recipient_interface.py b/src/models/recipient_interface.py deleted file mode 100644 index a2a6487..0000000 --- a/src/models/recipient_interface.py +++ /dev/null @@ -1,5 +0,0 @@ -from abc import ABC - - -class RecipientInterface(ABC): - pass diff --git a/src/models/service_list.py b/src/models/service_list.py deleted file mode 100644 index c3bee4f..0000000 --- a/src/models/service_list.py +++ /dev/null @@ -1,78 +0,0 @@ -from typing import Any, Dict, Optional, Self - -import src.services.service_list_parameters.service_list_parameter_interface as service_list_parameter_interface -import src.services.service_list_parameters.default_parameters as default_parameters -import src.services.service_list_parameters.model_parameters as model_parameters -import src.models.service_parameter_interface as service_parameter_interface -import src.models.model_interface as model_interface -import src.models.service_list_interface as service_list_interface - - -class ServiceList(service_list_interface.ServiceListInterface): - def __init__( - self, - name: str, - version: int, - action: str, - model: Optional[model_interface.ModelInterface] = None, - ): - self._name: str = name - self._version: int = version - self._action: str = action - self._parameters: Dict[str, Any] = {} - self._parameter_service: ( - service_list_parameter_interface.ServiceListParameterInterface - ) = default_parameters.DefaultParameters(self) - - if model: - self._decorate_parameters(model) - self._parameter_service.data() - - def get_parameters(self) -> Dict[str, Any]: - return self._parameters - - def append_parameter(self, value: Any, key: Optional[str] = None) -> Self: - if isinstance(value, list) and all(isinstance(v, list) for v in value): - for single_value in value: - self.append_parameter(single_value, key) - return self - - if key: - self._parameters[key] = value - - return self - - def _decorate_parameters( - self, - model: model_interface.ModelInterface, - group_type: Optional[str] = None, - group_key: Optional[int] = None, - ) -> Self: - self._parameter_service = model_parameters.ModelParameters( - self._parameter_service, model, group_type, group_key - ) - self._iterate_through_object(model, model.get_object_vars()) - return self - - def _iterate_through_object( - self, - model: model_interface.ModelInterface, - array: Dict[str, Any], - key_name: Optional[str] = None, - ) -> Self: - for key, value in array.items(): - if isinstance( - model, service_parameter_interface.ServiceParameterInterface - ) and isinstance(value, model_interface.ModelInterface): - self._decorate_parameters( - value, - model.get_group_type(key_name or key), - model.get_group_key(key_name or key), - ) - continue - - if isinstance(value, list) and value: - formatted_data = {str(index): item for index, item in enumerate(value)} - self._iterate_through_object(model, formatted_data, key) - - return self diff --git a/src/models/service_list_interface.py b/src/models/service_list_interface.py deleted file mode 100644 index e0e3df3..0000000 --- a/src/models/service_list_interface.py +++ /dev/null @@ -1,11 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, Self - -class ServiceListInterface(ABC): - @abstractmethod - def get_parameters(self) -> Dict[str, Any]: - pass - - @abstractmethod - def append_parameter(self, value: Any, key: Optional[str] = None) -> Self: - pass \ No newline at end of file diff --git a/src/models/service_parameter.py b/src/models/service_parameter.py deleted file mode 100644 index 44f84f7..0000000 --- a/src/models/service_parameter.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional, Dict - -import src.models.service_parameter_interface as service_parameter_interface -import src.models.model_mixin as model_mixin - - -class ServiceParameter( - service_parameter_interface.ServiceParameterInterface, model_mixin.ModelMixin -): - def set_properties(self, data: Optional[Dict] = None): - if data is None: - return self - - for property_name, value in data.items(): - method_name = f"set_{property_name}" - if hasattr(self, method_name): - method = getattr(self, method_name) - method(value) - else: - setattr(self, property_name, value) - - return self - - def get_group_type(self, key: str) -> Optional[str]: - return self.group_data.get(key, {}).get("groupType", None) - - def get_group_key(self, key: str) -> Optional[int]: - return self.group_data.get(key, {}).get("groupKey", None) diff --git a/src/models/service_parameter_interface.py b/src/models/service_parameter_interface.py deleted file mode 100755 index aae87c1..0000000 --- a/src/models/service_parameter_interface.py +++ /dev/null @@ -1,15 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional - -import src.models.model_interface as model_interface - - -class ServiceParameterInterface(model_interface.ModelInterface, ABC): - - @abstractmethod - def get_group_type(self, key: str) -> Optional[str]: - pass - - @abstractmethod - def get_group_key(self, key: str) -> Optional[int]: - pass diff --git a/src/payment_methods/base/has_properties.py b/src/payment_methods/base/has_properties.py deleted file mode 100644 index 86bcd20..0000000 --- a/src/payment_methods/base/has_properties.py +++ /dev/null @@ -1,28 +0,0 @@ -from abc import ABC, abstractmethod - -import src.transaction.client as client -import src.transaction.request.transaction_request as transaction_request - - -class HasClient(ABC): - - @property - @abstractmethod - def client(self) -> client.Client: - pass - - -class HasPayload(ABC): - - @property - @abstractmethod - def payload(self) -> dict: - pass - - -class HasTransationRequest(ABC): - - @property - @abstractmethod - def transaction_request(self) -> transaction_request.TransactionRequest: - pass diff --git a/src/payment_methods/base/payable/payable_method_factory.py b/src/payment_methods/base/payable/payable_method_factory.py deleted file mode 100644 index 3375049..0000000 --- a/src/payment_methods/base/payable/payable_method_factory.py +++ /dev/null @@ -1,11 +0,0 @@ -import src.transaction.client as client -import src.payment_methods.ideal.ideal as ideal -import src.payment_methods.base.payable.payable_method_interface as payable_method_interface - - -def payable_method_factory( - payment_method: str, client: client.Client -) -> payable_method_interface.PayableMethodInterface: - if payment_method == "ideal": - return ideal.Ideal(client) - raise ValueError(f"Unknown payment method: {payment_method}") diff --git a/src/payment_methods/base/payable/payable_method_interface.py b/src/payment_methods/base/payable/payable_method_interface.py deleted file mode 100644 index 555f0c8..0000000 --- a/src/payment_methods/base/payable/payable_method_interface.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Self -from abc import ABC, abstractmethod - -import src.payment_methods.base.payment.payment_method_interface as payment_method_interface - - -class PayableMethodInterface(payment_method_interface.PaymentMethodInterface, ABC): - - @abstractmethod - def pay(self, data: dict) -> Self: - pass - - @abstractmethod - def refund(self, data: dict) -> Self: - pass - - @abstractmethod - def pay_remainder(self, data: dict) -> Self: - pass diff --git a/src/payment_methods/base/payable/payable_method_mixin.py b/src/payment_methods/base/payable/payable_method_mixin.py deleted file mode 100644 index 4eaafb8..0000000 --- a/src/payment_methods/base/payable/payable_method_mixin.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Self -from abc import ABC, abstractmethod - -import src.payment_methods.base.payment.payment_method_mixin as payment_method_mixin -import src.models.payload.payload as payload -import src.models.payload.pay_payload as pay_payload -import src.models.payload.refund_payload as refund_payload -import src.transaction.request.transaction_request as transaction_request -import src.models.service_list as service_list -import src.payment_methods.base.payable.payable_method_interface as payable_method_interface - - -class PayableMethodMixin( - payment_method_mixin.PaymentMethodMixin, - payable_method_interface.PayableMethodInterface, - ABC, -): - _pay_model: pay_payload.PayPayload - _refund_model: refund_payload.RefundPayload - _transaction_request: transaction_request.TransactionRequest = ( - transaction_request.TransactionRequest() - ) - - def pay(self, data: dict) -> Self: - self._set_pay_payload(data) - self._set_service_list(self._pay_model, "Pay") - - return self - - def refund(self, data: dict) -> Self: - self._set_refund_payload(data) - self._set_service_list(self._refund_model, "Refund") - - return self - - def pay_remainder(self, data: dict) -> Self: - self._set_pay_remainder_payload(data) - self._set_service_list(self._pay_model, "PayRemainder") - - return self - - def _set_pay_payload(self, data: dict) -> None: - self._pay_model = pay_payload.PayPayload(data) - - self._transaction_request.set_payload(self._pay_model) - - def _set_refund_payload(self, data: dict) -> None: - self._refund_model = refund_payload.RefundPayload(data) - - self._transaction_request.set_payload(self._refund_model) - - def _set_pay_remainder_payload(self, data: dict) -> None: - self._pay_model = pay_payload.PayPayload(data) - self._transaction_request.set_payload(self._pay_model) - - def _set_service_list(self, payload: payload.Payload, action: str) -> None: - new_service_list = service_list.ServiceList( - self.payment_name, self.service_version, action, payload - ) - self._transaction_request.set_services(new_service_list) - - @property - @abstractmethod - def payment_name(self) -> str: - pass - - @property - @abstractmethod - def service_version(self) -> int: - pass diff --git a/src/payment_methods/base/payment/payment_method_interface.py b/src/payment_methods/base/payment/payment_method_interface.py deleted file mode 100644 index 345e18d..0000000 --- a/src/payment_methods/base/payment/payment_method_interface.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations -from typing import Self -from abc import ABC, abstractmethod - -import src.transaction.response.transaction_response as transaction_response - - -class PaymentMethodInterface(ABC): - @abstractmethod - def header(self, data: dict) -> Self: - pass - - @abstractmethod - def execute(self) -> transaction_response.TransactionResponse: - pass diff --git a/src/payment_methods/base/payment/payment_method_mixin.py b/src/payment_methods/base/payment/payment_method_mixin.py deleted file mode 100644 index 3287320..0000000 --- a/src/payment_methods/base/payment/payment_method_mixin.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations -from typing import Self - -import src.transaction.response.transaction_response as transaction_response -import src.payment_methods.base.payment.payment_method_interface as payment_method_interface -import src.payment_methods.base.has_properties as has_properties - - -class PaymentMethodMixin( - payment_method_interface.PaymentMethodInterface, - has_properties.HasClient, - has_properties.HasTransationRequest, -): - def header(self, data: dict) -> Self: - self.client.add_header(data) - return self - - def execute(self) -> transaction_response.TransactionResponse: - return self.client.post(self.transaction_request) diff --git a/src/payment_methods/ideal/ideal.py b/src/payment_methods/ideal/ideal.py deleted file mode 100755 index 6e408d0..0000000 --- a/src/payment_methods/ideal/ideal.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import src.transaction.client as client -import src.transaction.request.transaction_request as transaction_request -import src.payment_methods.base.payable.payable_method_mixin as payable_method_mixin - - -class Ideal(payable_method_mixin.PayableMethodMixin): - def __init__(self, client: client.Client): - self._client = client - self._transaction_request = transaction_request.TransactionRequest() - - @property - def client(self) -> client.Client: - return self._client - - @property - def transaction_request(self) -> transaction_request.TransactionRequest: - return self._transaction_request - - @property - def payment_name(self) -> str: - return "ideal" - - @property - def service_version(self) -> int: - return 0 diff --git a/src/resources/constants/credit_management_installment_interval.py b/src/resources/constants/credit_management_installment_interval.py deleted file mode 100755 index ba61089..0000000 --- a/src/resources/constants/credit_management_installment_interval.py +++ /dev/null @@ -1,10 +0,0 @@ -DAY = "Day" -TWO_DAYS = "TwoDays" -WEEK = "Week" -TWO_WEEKS = "TwoWeeks" -HALF_MONTH = "HalfMonth" -MONTH = "Month" -TWO_MONTHS = "TwoMonths" -QUARTER_YEAR = "QuarterYear" -HALF_YEAR = "HalfYear" -YEAR = "Year" diff --git a/src/resources/constants/endpoints.py b/src/resources/constants/endpoints.py deleted file mode 100755 index 5a17be5..0000000 --- a/src/resources/constants/endpoints.py +++ /dev/null @@ -1,2 +0,0 @@ -LIVE = "https://checkout.buckaroo.nl/" -TEST = "https://testcheckout.buckaroo.nl/" diff --git a/src/resources/constants/gender.py b/src/resources/constants/gender.py deleted file mode 100755 index 259e244..0000000 --- a/src/resources/constants/gender.py +++ /dev/null @@ -1,4 +0,0 @@ -UNKNOWN = 0 -MALE = 1 -FEMALE = 2 -NOT_APPLICABLE = 9 diff --git a/src/resources/constants/ip_protocol_version.py b/src/resources/constants/ip_protocol_version.py deleted file mode 100755 index 1eaf778..0000000 --- a/src/resources/constants/ip_protocol_version.py +++ /dev/null @@ -1,12 +0,0 @@ -import ipaddress - -IPV4 = 0 -IPV6 = 1 - - -def get_version(ip_address: str) -> int: - try: - ip = ipaddress.ip_address(ip_address) - return IPV6 if ip.version == 6 else IPV4 - except ValueError: - return IPV4 diff --git a/src/resources/constants/recipient_category.py b/src/resources/constants/recipient_category.py deleted file mode 100755 index 01ddb40..0000000 --- a/src/resources/constants/recipient_category.py +++ /dev/null @@ -1,2 +0,0 @@ -PERSON = "Person" -COMPANY = "Company" diff --git a/src/resources/constants/response_status.py b/src/resources/constants/response_status.py deleted file mode 100755 index 82549e8..0000000 --- a/src/resources/constants/response_status.py +++ /dev/null @@ -1,16 +0,0 @@ -BUCKAROO_STATUSCODE_SUCCESS = "190" -BUCKAROO_STATUSCODE_FAILED = "490" -BUCKAROO_STATUSCODE_VALIDATION_FAILURE = "491" -BUCKAROO_STATUSCODE_TECHNICAL_ERROR = "492" -BUCKAROO_STATUSCODE_REJECTED = "690" -BUCKAROO_STATUSCODE_WAITING_ON_USER_INPUT = "790" -BUCKAROO_STATUSCODE_PENDING_PROCESSING = "791" -BUCKAROO_STATUSCODE_WAITING_ON_CONSUMER = "792" -BUCKAROO_STATUSCODE_PAYMENT_ON_HOLD = "793" -BUCKAROO_STATUSCODE_PENDING_APPROVAL = "794" -BUCKAROO_STATUSCODE_CANCELLED_BY_USER = "890" -BUCKAROO_STATUSCODE_CANCELLED_BY_MERCHANT = "891" - -BUCKAROO_AUTHORIZE_TYPE_CANCEL = "I014" -BUCKAROO_AUTHORIZE_TYPE_ACCEPT = "I013" -BUCKAROO_AUTHORIZE_TYPE_GROUP_TRANSACTION = "I150" diff --git a/src/services/service_list_parameters/default_parameters.py b/src/services/service_list_parameters/default_parameters.py deleted file mode 100644 index 0064b23..0000000 --- a/src/services/service_list_parameters/default_parameters.py +++ /dev/null @@ -1,11 +0,0 @@ -import src.services.service_list_parameters.service_list_parameter_mixin as service_list_parameter_mixin -import src.models.service_list_interface as service_list_interface - - -class DefaultParameters(service_list_parameter_mixin.ServiceListParameterMixin): - def __init__(self, new_service_list: service_list_interface.ServiceListInterface): - self._service_list = new_service_list - - @property - def service_list(self) -> service_list_interface.ServiceListInterface: - return self._service_list diff --git a/src/services/service_list_parameters/model_parameters.py b/src/services/service_list_parameters/model_parameters.py deleted file mode 100644 index 5d6169e..0000000 --- a/src/services/service_list_parameters/model_parameters.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Optional - -import src.models.model_interface as model_interface -import src.models.service_parameter_interface as service_parameter_interface -import src.services.service_list_parameters.service_list_parameter_interface as service_list_parameter_interface -import src.services.service_list_parameters.service_list_parameter_mixin as service_list_parameter_mixin -import src.models.service_list_interface as service_list_interface - - -class ModelParameters(service_list_parameter_mixin.ServiceListParameterMixin): - def __init__( - self, - service_list_parameter: service_list_parameter_interface.ServiceListParameterInterface, - model: model_interface.ModelInterface, - group_type: Optional[str] = "", - group_key: Optional[int] = None, - ): - self._model = model - self._group_type = group_type - self._group_key = group_key - self._service_list_parameter = service_list_parameter - self._service_list = self._service_list_parameter.data() - - @property - def service_list(self) -> service_list_interface.ServiceListInterface: - return self._service_list - - @property - def model(self) -> model_interface.ModelInterface: - return self._model - - def data(self): - for key, value in self.model.to_dict().items(): - if not isinstance(value, list): - self._append_parameter( - self.__group_key(key), - self.__group_type(key), - self.model.service_parameter_key_of(key), - value, - ) - return self.service_list - - def __group_key(self, key): - if ( - isinstance( - self.model, service_parameter_interface.ServiceParameterInterface - ) - and not self._group_key - ): - return self.model.get_group_key(key) - return self._group_key - - def __group_type(self, key): - if ( - isinstance( - self.model, - service_list_parameter_interface.ServiceListParameterInterface, - ) - and not self._group_type - ): - return self.model.get_group_type(key) - return self._group_type diff --git a/src/services/service_list_parameters/service_list_parameter_interface.py b/src/services/service_list_parameters/service_list_parameter_interface.py deleted file mode 100644 index 49c9bc3..0000000 --- a/src/services/service_list_parameters/service_list_parameter_interface.py +++ /dev/null @@ -1,10 +0,0 @@ -from abc import ABC, abstractmethod - -import src.models.service_list as service_list - - -class ServiceListParameterInterface(ABC): - - @abstractmethod - def data(self) -> "service_list.ServiceList": - pass diff --git a/src/services/service_list_parameters/service_list_parameter_mixin.py b/src/services/service_list_parameters/service_list_parameter_mixin.py deleted file mode 100644 index a34c887..0000000 --- a/src/services/service_list_parameters/service_list_parameter_mixin.py +++ /dev/null @@ -1,33 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Optional - -import src.services.service_list_parameters.service_list_parameter_interface as service_list_parameter_interface -import src.models.service_list as service_list - - -class ServiceListParameterMixin( - service_list_parameter_interface.ServiceListParameterInterface, ABC -): - - @property - @abstractmethod - def service_list(self) -> "service_list.ServiceList": - pass - - def data(self): - return self.service_list - - def _append_parameter( - self, group_key: Optional[int], group_type: Optional[str], name: str, value: Any - ) -> service_list_parameter_interface.ServiceListParameterInterface: - if value is not None: - self.service_list.append_parameter( - { - "Name": name, - "Value": value, - "GroupType": "" if group_type is None else group_type, - "GroupID": "" if group_key is None else group_key, - } - ) - - return self diff --git a/src/transaction/client.py b/src/transaction/client.py deleted file mode 100755 index c4f2fd9..0000000 --- a/src/transaction/client.py +++ /dev/null @@ -1,141 +0,0 @@ -from typing import Optional -import json - -import src.exceptions.buckaroo_exception as buckaroo_exception -import src.resources.constants.endpoints as endpoints -import src.handlers.config.config_interface as config_interface -import src.handlers.logging.default_logger as default_logger -import src.handlers.logging.subject_interface as subject_interface -import src.transaction.request.transaction_request as transaction_request -import src.transaction.request.request as request -import src.transaction.response.response as response -import src.transaction.response.transaction_response as transaction_response -import src.transaction.http_client.http_client_interface as http_client_interface -import src.transaction.http_client.http_client_factory as http_client_factory -import src.transaction.http_client.http_client_interface as http_client_interface - - -class Client: - _METHOD_GET = "GET" - _METHOD_POST = "POST" - _config: config_interface.ConfigInterface - _http_client: http_client_interface.HttpClientInterface - _logger: subject_interface.SubjectInterface - - def __init__(self, config: config_interface.ConfigInterface): - self._config = config - self._http_client = http_client_factory.create_client() - self._logger = default_logger.DefaultLogger() - self._request = request.Request() - - @property - def config(self) -> config_interface.ConfigInterface: - return self._config - - @config.setter - def config(self, config: Optional[config_interface.ConfigInterface] = None) -> None: - if config: - self._config = config - - if not self.config: - raise buckaroo_exception.BuckarooException( - self.logger, - "No config has been configured. Please pass your credentials to the constructor or set up a Config object.", - ) - - @property - def http_client(self) -> http_client_interface.HttpClientInterface: - return self._http_client - - @property - def logger(self) -> subject_interface.SubjectInterface: - return self._logger - - @property - def request(self) -> request.Request: - return self._request - - def add_header(self, header: dict) -> None: - self._request.add_header(header) - - def get_transaction_url(self) -> str: - return self.get_endpoint("json/Transaction/") - - def get_endpoint(self, path: str) -> str: - base_url = endpoints.LIVE if self.config.is_live_mode() else endpoints.TEST - return f"{base_url}{path}" - - def get( - self, end_point: Optional[str] = None - ) -> transaction_response.TransactionResponse: - return self._call(method=self._METHOD_GET, data=None, end_point=end_point) - - def get_with_generic_response( - self, end_point: Optional[str] = None - ) -> response.Response: - return self._call(method=self._METHOD_GET, data=None, end_point=end_point) - - def post( - self, data: transaction_request.TransactionRequest - ) -> transaction_response.TransactionResponse: - return self._call(self._METHOD_POST, data) - - def data_request( - self, data: transaction_request.TransactionRequest - ) -> transaction_response.TransactionResponse: - end_point = self.get_endpoint("json/DataRequest/") - return self._call(self._METHOD_POST, data, end_point) - - def data_batch_request( - self, data: transaction_request.TransactionRequest - ) -> transaction_response.TransactionResponse: - end_point = self.get_endpoint("json/batch/DataRequests") - return self._call(self._METHOD_POST, data, end_point) - - def transaction_batch_request( - self, data: transaction_request.TransactionRequest - ) -> transaction_response.TransactionResponse: - end_point = self.get_endpoint("json/batch/Transactions") - return self._call(self._METHOD_POST, data, end_point) - - def specification( - self, - payment_name: str, - data: transaction_request.TransactionRequest, - service_version: int = 0, - ) -> transaction_response.TransactionResponse: - end_point = self.get_endpoint( - f"json/Transaction/Specification/{payment_name}?serviceVersion={service_version}" - ) - return self._call(self._METHOD_GET, data, end_point) - - def _call( - self, - method: str, - data: transaction_request.TransactionRequest | None, - end_point: Optional[str] = None, - ) -> transaction_response.TransactionResponse: - end_point = end_point or self.get_transaction_url() - - headers = self.request.get_headers( - end_point, - method=method, - data=data.get_data_as_json() if data else "", - config=self.config, - ) - - if data: - headers.update(json.loads(data.get_data_as_json())) - - self.config.get_logger().info(f"{method} {end_point}") - self.config.get_logger().info(f"HEADERS: {headers}") - - if data: - self.config.get_logger().info(f"PAYLOAD: {data}") - - response, decoded_result = self.http_client.call( - end_point, headers, method, data.get_data_as_json() if data else "" - ) - response = transaction_response.TransactionResponse(response, decoded_result) - - return response diff --git a/src/transaction/http_client/curl_client.py b/src/transaction/http_client/curl_client.py deleted file mode 100755 index 1d8bc7b..0000000 --- a/src/transaction/http_client/curl_client.py +++ /dev/null @@ -1,31 +0,0 @@ -import subprocess -import json -from typing import Any - -import src.transaction.http_client.http_client_interface as http_client_interface - - -class CurlClient(http_client_interface.HttpClientInterface): - def call(self, url: str, headers: dict, method: str, data: str | None) -> Any: - try: - command = [ - "curl", - "-X", - method.upper(), - url, - ] - - for key, value in headers.items(): - command.extend(["-H", f"{key}: {value}"]) - - if data: - json_data = json.dumps(data) - command.extend(["-d", json_data]) - - result = subprocess.run(command, text=True, capture_output=True, check=True) - - return result.stdout - except subprocess.CalledProcessError as e: - raise Exception(f"Curl command failed: {e.stderr}") - except Exception as e: - raise Exception(f"An unexpected error occurred: {e}") diff --git a/src/transaction/http_client/default_http_client.py b/src/transaction/http_client/default_http_client.py deleted file mode 100755 index 4943cc1..0000000 --- a/src/transaction/http_client/default_http_client.py +++ /dev/null @@ -1,13 +0,0 @@ -import requests -from typing import Any - -import src.transaction.http_client.http_client_interface as http_client_interface - - -class DefaultHttpClient(http_client_interface.HttpClientInterface): - def call(self, url: str, headers: dict, method: str, data: str | None) -> Any: - try: - response = requests.request(method, url, headers=headers, data=data) - return response.json() - except requests.exceptions.RequestException as e: - raise Exception(f"Failed to call {url}") from e diff --git a/src/transaction/http_client/http_client_factory.py b/src/transaction/http_client/http_client_factory.py deleted file mode 100755 index 9ade7e4..0000000 --- a/src/transaction/http_client/http_client_factory.py +++ /dev/null @@ -1,8 +0,0 @@ -import src.transaction.http_client.curl_client as curl_client -import src.transaction.http_client.default_http_client as default_http_client -import src.transaction.http_client.http_client_interface as http_client_interface - - -# @TODO: We have two different clients, on what condition should CurlClient be returned? -def create_client() -> http_client_interface.HttpClientInterface: - return default_http_client.DefaultHttpClient() diff --git a/src/transaction/http_client/http_client_interface.py b/src/transaction/http_client/http_client_interface.py deleted file mode 100755 index 7ab1607..0000000 --- a/src/transaction/http_client/http_client_interface.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Mapping - - -class HttpClientInterface(ABC): - - @abstractmethod - def call(self, url: str, headers: dict, method: str, data: str | None) -> Any: - pass diff --git a/src/transaction/request/header/channel_header.py b/src/transaction/request/header/channel_header.py deleted file mode 100755 index 231f6e3..0000000 --- a/src/transaction/request/header/channel_header.py +++ /dev/null @@ -1,18 +0,0 @@ -import src.transaction.request.header.header_interface as header_interface -import src.handlers.config.config_interface as config_interface - - -class ChannelHeader(header_interface.HeaderInterface): - def __init__( - self, - header: header_interface.HeaderInterface, - config: config_interface.ConfigInterface, - ): - self._header = header - self._config = config - - def get_headers(self) -> dict: - headers = self._header.get_headers() - headers["channel"] = f"Channel{self._config.channel()}" - - return headers diff --git a/src/transaction/request/header/culture_header.py b/src/transaction/request/header/culture_header.py deleted file mode 100755 index 6bc727a..0000000 --- a/src/transaction/request/header/culture_header.py +++ /dev/null @@ -1,18 +0,0 @@ -import src.transaction.request.header.header_interface as header_interface -import src.handlers.config.config_interface as config_interface - - -class CultureHeader(header_interface.HeaderInterface): - def __init__( - self, - header: header_interface.HeaderInterface, - config: config_interface.ConfigInterface, - ): - self._header = header - self._config = config - - def get_headers(self) -> dict: - headers = self._header.get_headers() - headers["culture"] = f"Culture{self._config.culture()}" - - return headers diff --git a/src/transaction/request/header/default_header.py b/src/transaction/request/header/default_header.py deleted file mode 100755 index c896b11..0000000 --- a/src/transaction/request/header/default_header.py +++ /dev/null @@ -1,9 +0,0 @@ -import src.transaction.request.header.header_interface as header_interface - - -class DefaultHeader(header_interface.HeaderInterface): - def __init__(self, headers: dict[str, str]): - self._headers = headers - - def get_headers(self) -> dict: - return self._headers or {} diff --git a/src/transaction/request/header/header_interface.py b/src/transaction/request/header/header_interface.py deleted file mode 100755 index 3663fd3..0000000 --- a/src/transaction/request/header/header_interface.py +++ /dev/null @@ -1,7 +0,0 @@ -from abc import ABC, abstractmethod - - -class HeaderInterface(ABC): - @abstractmethod - def get_headers(self) -> dict: - pass diff --git a/src/transaction/request/header/hmac_header.py b/src/transaction/request/header/hmac_header.py deleted file mode 100755 index 271ba2c..0000000 --- a/src/transaction/request/header/hmac_header.py +++ /dev/null @@ -1,31 +0,0 @@ -import src.handlers.config.config_interface as config_interface -import src.handlers.hmac.generator as generator -import src.transaction.request.header.header_interface as header_interface - - -class HmacHeader(header_interface.HeaderInterface): - _hmac_generator: generator.Generator - - def __init__( - self, - header: header_interface.HeaderInterface, - config: config_interface.ConfigInterface, - request_uri: str, - data: str, - http_method: str, - ): - self._header = header - self._config = config - self._request_uri = request_uri - self._data = data - self._http_method = http_method - self._hmac_generator = generator.Generator( - config, data, request_uri, http_method - ) - - def get_headers(self) -> dict: - headers = self._header.get_headers() - - headers["Authorization: hmac"] = self._hmac_generator.generate() - - return headers diff --git a/src/transaction/request/header/software_header.py b/src/transaction/request/header/software_header.py deleted file mode 100755 index 88175bc..0000000 --- a/src/transaction/request/header/software_header.py +++ /dev/null @@ -1,31 +0,0 @@ -import json - - -import src.handlers.config.config_interface as config_interface -import src.transaction.request.header.header_interface as header_interface - - -class SoftwareHeader(header_interface.HeaderInterface): - - def __init__( - self, - header: header_interface.HeaderInterface, - config: config_interface.ConfigInterface, - ): - self._header = header - self._config = config - - def get_headers(self) -> dict: - headers = self._header.get_headers() - - software_info = { - "PlatformName": self._config.platform_name(), - "PlatformVersion": self._config.platform_version(), - "ModuleSupplier": self._config.module_supplier(), - "ModuleName": self._config.module_name(), - "ModuleVersion": self._config.module_version(), - } - - headers["Software"] = json.dumps(software_info) - - return headers diff --git a/src/transaction/request/request.py b/src/transaction/request/request.py deleted file mode 100644 index 2656143..0000000 --- a/src/transaction/request/request.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Any, Dict - -import src.transaction.request.header.default_header as default_header -import src.transaction.request.header.hmac_header as hmac_header -import src.transaction.request.header.culture_header as culture_header -import src.transaction.request.header.channel_header as channel_header -import src.transaction.request.header.software_header as software_header -import src.transaction.request.header.header_interface as header_interface -import src.handlers.config.config_interface as config_interface - - -class Request: - _headers: Dict[str, Any] = {} - _body: Dict[str, Any] = {} - - def __init__(self, headers: Dict[str, Any] = {}, body: Dict[str, Any] = {}): - self._headers = headers - self._body = body - - @property - def headers(self) -> Dict[str, Any]: - return self._headers - - @property - def body(self) -> Dict[str, Any]: - return self._body - - def add_header(self, header: Dict[str, Any]) -> None: - self._headers.update(header) - - def get_headers( - self, url: str, data: str, method: str, config: config_interface.ConfigInterface - ) -> dict: - internal_headers: header_interface.HeaderInterface = ( - default_header.DefaultHeader( - { - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - } - ) - ) - internal_headers = hmac_header.HmacHeader( - internal_headers, config, url, data, method - ) - internal_headers = culture_header.CultureHeader(internal_headers, config) - internal_headers = channel_header.ChannelHeader(internal_headers, config) - internal_headers = software_header.SoftwareHeader(internal_headers, config) - - complete_internal_headers = internal_headers.get_headers() - complete_internal_headers.update(self._headers) - - return complete_internal_headers diff --git a/src/transaction/request/transaction_request.py b/src/transaction/request/transaction_request.py deleted file mode 100644 index 1d35f4b..0000000 --- a/src/transaction/request/transaction_request.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Dict, Any, Self -import os -import json - -import src.models.model_interface as model_interface -import src.models.service_list as service_list -import src.transaction.request.request as request - - -class TransactionRequest(request.Request): - def __init__(self): - self._data: Dict[str, Any] = {} - self._data["ClientUserAgent"] = self.get_client_user_agent() - - @property - def data(self) -> Dict[str, Any]: - return self._data - - def set_payload(self, model: model_interface.ModelInterface) -> Self: - for key, value in model.to_dict().items(): - self.data[model.service_parameter_key_of(key)] = value - return self - - def set_data(self, key: str, value: Any) -> Self: - self.data[key] = value - return self - - def get_data(self) -> Dict[str, Any]: - return self.data - - def get_data_as_json(self) -> str: - data = self.get_data() - return json.dumps(self.get_data()) - - def set_services(self, service_list: service_list.ServiceList) -> None: - if "Services" in self.data: - self.data["Services"].extend(service_list) - else: - self.data["Services"] = service_list - - @staticmethod - def get_client_user_agent() -> str: - return os.getenv("HTTP_USER_AGENT", "") diff --git a/src/transaction/response/response.py b/src/transaction/response/response.py deleted file mode 100755 index e40d067..0000000 --- a/src/transaction/response/response.py +++ /dev/null @@ -1,19 +0,0 @@ -class Response: - def __init__(self, http_response: dict, data: dict): - self.http_response = http_response - self.data = data - - def __getattr__(self, name): - if name.startswith("get"): - param = name[3:] - if param in self.data: - return self.data[param] - raise AttributeError( - f"'{self.__class__.__name__}' object has no attribute '{name}'" - ) - - def get_http_response(self) -> dict: - return self.http_response - - def to_dict(self) -> dict: - return self.data diff --git a/src/transaction/response/transaction_response.py b/src/transaction/response/transaction_response.py deleted file mode 100755 index 392fd69..0000000 --- a/src/transaction/response/transaction_response.py +++ /dev/null @@ -1,221 +0,0 @@ -import src.resources.constants.response_status as response_status -import src.transaction.response.response as response - - -class TransactionResponse(response.Response): - def is_success(self): - return self.get_status_code() == response_status.BUCKAROO_STATUSCODE_SUCCESS - - def is_failed(self): - return self.get_status_code() == response_status.BUCKAROO_STATUSCODE_FAILED - - def is_canceled(self): - status_code = self.get_status_code() - return status_code in [ - response_status.BUCKAROO_STATUSCODE_CANCELLED_BY_USER, - response_status.BUCKAROO_STATUSCODE_CANCELLED_BY_MERCHANT, - ] - - def is_awaiting_consumer(self): - return ( - self.get_status_code() - == response_status.BUCKAROO_STATUSCODE_WAITING_ON_CONSUMER - ) - - def is_pending_processing(self): - return ( - self.get_status_code() - == response_status.BUCKAROO_STATUSCODE_PENDING_PROCESSING - ) - - def is_waiting_on_user_input(self): - return ( - self.get_status_code() - == response_status.BUCKAROO_STATUSCODE_WAITING_ON_USER_INPUT - ) - - def is_rejected(self): - return self.get_status_code() == response_status.BUCKAROO_STATUSCODE_REJECTED - - def is_pending_approval(self): - return ( - self.get_status_code() - == response_status.BUCKAROO_STATUSCODE_PENDING_APPROVAL - ) - - def is_validation_failure(self): - return ( - self.get_status_code() - == response_status.BUCKAROO_STATUSCODE_VALIDATION_FAILURE - ) - - def data(self, key=None): - if key and key in self.data: - return self.data[key] - return self.data - - def has_redirect(self): - return ( - "RequiredAction" in self.data - and "RedirectURL" in self.data["RequiredAction"] - and self.data["RequiredAction"]["Name"] == "Redirect" - ) - - def get_redirect_url(self): - if self.has_redirect(): - return self.data["RequiredAction"]["RedirectURL"] - return "" - - def get_method(self): - return self.data["Services"][0]["Name"] - - def get_service_action(self): - return self.data["Services"][0]["Action"] - - def get_service_parameters(self): - params = {} - if "Services" in self.data and "Parameters" in self.data["Services"][0]: - for parameter in self.data["Services"][0]["Parameters"]: - params[parameter["Name"].lower()] = parameter["Value"] - return params - - def get_custom_parameters(self): - params = {} - if "CustomParameters" in self.data and "List" in self.data["CustomParameters"]: - for parameter in self.data["CustomParameters"]["List"]: - params[parameter["Name"]] = parameter["Value"] - return params - - def get_additional_parameters(self): - params = {} - if ( - "AdditionalParameters" in self.data - and "AdditionalParameter" in self.data["AdditionalParameters"] - ): - for parameter in self.data["AdditionalParameters"]["AdditionalParameter"]: - params[parameter["Name"]] = parameter["Value"] - return params - - def get_transaction_key(self): - return self.data["Key"] - - def get_payment_key(self): - return self.data["PaymentKey"] - - def get_token(self): - params = self.get_additional_parameters() - return params.get("token", "").strip() - - def get_signature(self): - params = self.get_additional_parameters() - return params.get("signature", "").strip() - - def get_amount(self): - return str(self.data.get("AmountDebit", "")) - - def get_currency(self): - return self.data.get("Currency", "") - - def get_invoice(self): - return self.data.get("Invoice", "") - - def get_status_code(self): - if ( - "Status" in self.data - and "Code" in self.data["Status"] - and "Code" in self.data["Status"]["Code"] - ): - return self.data["Status"]["Code"]["Code"] - return None - - def get_sub_status_code(self): - if ( - "Status" in self.data - and "SubCode" in self.data["Status"] - and "Code" in self.data["Status"]["SubCode"] - ): - return self.data["Status"]["SubCode"]["Code"] - return None - - def has_some_error(self): - return bool(self.get_some_error()) - - def get_some_error(self): - if self.has_error(): - error = self.get_first_error() - return error["ErrorMessage"] if error else "" - if self.has_consumer_message(): - return self.get_consumer_message() - if self.has_message(): - return self.get_message() - if self.has_sub_code_message(): - return self.get_sub_code_message() - return "" - - def has_error(self): - return any( - [ - "RequestErrors" in self.data, - "ChannelErrors" in self.data["RequestErrors"], - "ServiceErrors" in self.data["RequestErrors"], - "ActionErrors" in self.data["RequestErrors"], - "ParameterErrors" in self.data["RequestErrors"], - "CustomParameterErrors" in self.data["RequestErrors"], - ] - ) - - def get_first_error(self): - error_types = [ - "ChannelErrors", - "ServiceErrors", - "ActionErrors", - "ParameterErrors", - "CustomParameterErrors", - ] - if self.has_error(): - for error_type in error_types: - if ( - error_type in self.data["RequestErrors"] - and self.data["RequestErrors"][error_type] - ): - return self.data["RequestErrors"][error_type][0] - return {} - - def has_message(self): - return "Message" in self.data and bool(self.data["Message"]) - - def get_message(self): - return self.data.get("Message", "") - - def has_consumer_message(self): - return ( - "ConsumerMessage" in self.data - and "HtmlText" in self.data["ConsumerMessage"] - ) - - def get_consumer_message(self): - return ( - self.data["ConsumerMessage"]["HtmlText"] - if self.has_consumer_message() - else "" - ) - - def has_sub_code_message(self): - return ( - "Status" in self.data - and "SubCode" in self.data["Status"] - and "Description" in self.data["Status"]["SubCode"] - ) - - def get_sub_code_message(self): - return ( - self.data["Status"]["SubCode"]["Description"] - if self.has_sub_code_message() - else "" - ) - - def get_customer_name(self): - return self.data.get("CustomerName", "") - - def get(self, key): - return self.data.get(key) diff --git a/src/transaction/transaction_service.py b/src/transaction/transaction_service.py deleted file mode 100644 index b263125..0000000 --- a/src/transaction/transaction_service.py +++ /dev/null @@ -1,21 +0,0 @@ -import src.transaction.client as client -import src.transaction.response.response as response -import src.transaction.response.transaction_response as transaction_response - - -class TransactionService: - def __init__(self, client: client.Client, transaction_key: str): - self.transaction_key = transaction_key - self.client = client - - def status(self) -> transaction_response.TransactionResponse: - endpoint = f"json/Transaction/Status/{self.transaction_key}" - return self.client.get(self.client.get_endpoint(endpoint)) - - def refund_info(self) -> response.Response: - endpoint = f"json/Transaction/RefundInfo/{self.transaction_key}" - return self.client.get_with_generic_response(self.client.get_endpoint(endpoint)) - - def cancel_info(self) -> response.Response: - endpoint = f"json/Transaction/Cancel/{self.transaction_key}" - return self.client.get_with_generic_response(self.client.get_endpoint(endpoint)) diff --git a/src/handlers/config/__init__.py b/tests/__init__.py similarity index 100% rename from src/handlers/config/__init__.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/src/models/__init__.py b/tests/feature/__init__.py similarity index 100% rename from src/models/__init__.py rename to tests/feature/__init__.py 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/src/payment_methods/__init__.py b/tests/feature/payments/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from src/payment_methods/__init__.py rename to tests/feature/payments/__init__.py 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..7d8eb90 --- /dev/null +++ b/tests/feature/payments/test_klarna.py @@ -0,0 +1,61 @@ +"""Feature test: klarna reserve() + pay-as-capture round-trips through full stack with MockBuckaroo.""" + +import json + +from tests.support.mock_request import BuckarooMockRequest +from tests.support.helpers import Helpers + + +class TestKlarnaFeature: + def test_klarna_reserve_returns_pending_with_redirect(self, buckaroo, mock_strategy): + response_body = Helpers.pending_redirect_response( + "klarna", action="Reserve", overrides={"AmountDebit": 50.00} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/DataRequest", response_body)) + response = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-002", + amount=50.00, + description="Test klarna reserve", + service_parameters={ + "article": [ + {"description": "Widget", "quantity": "2", "price": "25.00"}, + ], + "billingCustomer": [{"firstName": "John", "lastName": "Doe"}], + "shippingCustomer": [{"firstName": "John", "lastName": "Doe"}], + "operatingCountry": "NL", + }, + ), + ).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_klarna_pay_as_capture_attaches_data_request_key(self, buckaroo, mock_strategy): + """Pay-as-capture references the prior Reserve via the ``dataRequestKey`` + service parameter (callers add it before calling ``.pay()``).""" + capture_body = Helpers.success_response( + overrides={"Key": "PAY-K-1", "AmountDebit": 25.00, "ServiceCode": "klarna"} + ) + mock_strategy.queue(BuckarooMockRequest.json("POST", "*/json/transaction", capture_body)) + + builder = buckaroo.payments.create_payment( + "klarna", + Helpers.standard_payload( + invoice="INV-KLARNA-CAP-A", + amount=25.00, + description="Test klarna capture", + ), + ) + builder.add_parameter("dataRequestKey", "RES-DRK-1") + response = builder.pay() + + assert response.key == "PAY-K-1" + body = json.loads(mock_strategy.calls[-1]["data"]) + services = body["Services"]["ServiceList"] + names = [p["Name"] for p in services[0]["Parameters"]] + assert "Datarequestkey" in names 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/src/resources/__init__.py b/tests/feature/solutions/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from src/resources/__init__.py rename to tests/feature/solutions/__init__.py 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/src/services/__init__.py b/tests/support/__init__.py similarity index 100% rename from src/services/__init__.py rename to tests/support/__init__.py 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/src/transaction/__init__.py b/tests/unit/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from src/transaction/__init__.py rename to tests/unit/__init__.py 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..371295c --- /dev/null +++ b/tests/unit/builders/payments/capabilities/test_authorize_capture_capable.py @@ -0,0 +1,296 @@ +"""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`` does not contribute ``capture``; +# :meth:`BaseBuilder.capture` carries the full +# ``original_transaction_key`` / ``amount`` signature for every builder. +# Behavioural coverage lives in the BaseBuilder tests. +# +# --------------------------------------------------------------------------- +# 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_capture_resolves_to_base_builder(self): + _, client = wire_recording_http() + builder = _ready_builder(client) + + # ``capture`` lives only on BaseBuilder; the mixin no longer ships one. + assert type(builder).capture.__qualname__ == "BaseBuilder.capture" + assert not hasattr(AuthorizeCaptureCapable, "capture") + + +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` (the mixin no + longer ships one). 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..cbfc900 --- /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": False, + "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() + + 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..a66dbe8 --- /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", + ], + 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}, + "riverty": {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..cf92b4e --- /dev/null +++ b/tests/unit/builders/payments/test_klarna_builder.py @@ -0,0 +1,164 @@ +"""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): + """Pay-as-capture references the prior Reserve via ``dataRequestKey`` as a + service parameter. Cart contents are reused server-side from the Reserve.""" + assert KlarnaBuilder(client).get_allowed_service_parameters("Pay") == { + "dataRequestKey": { + "type": str, + "required": True, + "description": "Key of the prior Klarna Reserve", + }, + } + + +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_reserve_snapshot(client): + """Reserve action drives the Klarna MOR hosted-page flow. Same required + cart trio as Pay (billingCustomer, shippingCustomer, article) plus + optional Klarna-specific keys (operatingCountry, pno, gender, locale).""" + assert KlarnaBuilder(client).get_allowed_service_parameters("Reserve") == { + "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", + }, + "operatingCountry": { + "type": str, + "required": False, + "description": "Operating country code", + }, + "pno": { + "type": str, + "required": False, + "description": "Personal identification number", + }, + "gender": { + "type": str, + "required": False, + "description": "Customer gender", + }, + "locale": { + "type": str, + "required": False, + "description": "Customer locale", + }, + } + + +def test_get_allowed_service_parameters_is_case_insensitive_for_reserve(client): + builder = KlarnaBuilder(client) + assert builder.get_allowed_service_parameters( + "reserve" + ) == builder.get_allowed_service_parameters("Reserve") + + +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() + + +def test_get_allowed_service_parameters_cancelreservation_is_empty(client): + """CancelReservation only needs OriginalTransactionKey at request level.""" + assert KlarnaBuilder(client).get_allowed_service_parameters("CancelReservation") == {} + assert KlarnaBuilder(client).get_allowed_service_parameters("cancelreservation") == {} + + +def test_cancel_reservation_requires_original_transaction_key(client): + """Missing key raises ValueError, mirroring cancelAuthorize.""" + import pytest + + builder = populate_required_fields(KlarnaBuilder(client), amount=10.0) + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.cancelReservation(original_transaction_key="") + + +def test_cancel_reservation_dispatches_action_with_original_transaction_key(): + """cancelReservation builds action="CancelReservation" with the key in payload.""" + from unittest.mock import MagicMock + + class _StubResponse: + def to_dict(self): + return {"Status": {"Code": {"Code": 190}}} + + captured = {} + + class _StubHttp: + def post(self, path, data): + captured["path"] = path + captured["data"] = data + return _StubResponse() + + stub_client = MagicMock() + stub_client.http_client = _StubHttp() + + builder = populate_required_fields(KlarnaBuilder(stub_client), amount=49.95) + response = builder.cancelReservation(original_transaction_key="RES-KEY-9") + + assert response.to_dict()["Status"]["Code"]["Code"] == 190 + assert captured["path"] == "/json/transaction" + sent = captured["data"] + assert sent["OriginalTransactionKey"] == "RES-KEY-9" + assert sent["Services"]["ServiceList"][0]["Action"] == "CancelReservation" 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..4da69fa --- /dev/null +++ b/tests/unit/builders/payments/test_riverty_builder.py @@ -0,0 +1,177 @@ +"""Unit coverage for :class:`RivertyBuilder`. + +Riverty is a buy-now-pay-later method (formerly AfterPay). The builder +mixes in :class:`AuthorizeCaptureCapable` for authorize/capture/void, and +exposes a cart-line-item oriented parameter spec shared between ``Pay`` +and ``Authorize``. Per-action spec and end-to-end ``pay()`` / +``authorize()`` 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.capabilities.authorize_capture_capable import ( + AuthorizeCaptureCapable, +) +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_builder_mixes_in_authorize_capture_capable(builder: RivertyBuilder) -> None: + assert isinstance(builder, AuthorizeCaptureCapable) + + +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" + + +_PAY_AUTHORIZE_SPEC = { + "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_snapshot(builder: RivertyBuilder) -> None: + assert builder.get_allowed_service_parameters("Pay") == _PAY_AUTHORIZE_SPEC + + +def test_get_allowed_service_parameters_authorize_matches_pay(builder: RivertyBuilder) -> None: + """Authorize uses the same cart trio as Pay — same billingCustomer, + shippingCustomer, and article requirements.""" + assert builder.get_allowed_service_parameters("Authorize") == _PAY_AUTHORIZE_SPEC + + +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" + ) + assert builder.get_allowed_service_parameters( + "authorize" + ) == builder.get_allowed_service_parameters("Authorize") + + +@pytest.mark.parametrize("action", ["Refund", "Capture", "CancelAuthorize", ""]) +def test_get_allowed_service_parameters_non_cart_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" + + +def test_authorize_end_to_end_via_mock_buckaroo( + builder: RivertyBuilder, mock_strategy: MockBuckaroo +) -> None: + """Authorize round-trips through the full stack and uses the cart trio. + Pinned to surface a regression if the Authorize action plumbing breaks.""" + mock_strategy.queue( + BuckarooMockRequest.json( + "POST", + "*/json/transaction*", + {"Key": "riverty-auth-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, + }, + ], + } + } + ) + .authorize() + ) + + assert response.key == "riverty-auth-key" + + +def test_cancel_authorize_requires_original_transaction_key(client: BuckarooClient) -> None: + """Missing key raises ValueError — same contract as creditcard.""" + builder = populate_required_fields(RivertyBuilder(client), amount=10.0) + with pytest.raises(ValueError, match="Original transaction key is required"): + builder.cancelAuthorize(original_transaction_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..e76421c --- /dev/null +++ b/tests/unit/models/test_payment_response.py @@ -0,0 +1,677 @@ +"""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": ""}}, + } + } + ) + + +def test_get_some_error_returns_first_request_error_with_priority(): + """RequestErrors take precedence over ConsumerMessage/Message/SubCode.""" + response = PaymentResponse( + { + "data": { + "RequestErrors": { + "ChannelErrors": [{"ErrorMessage": "Channel boom"}], + "ServiceErrors": [{"ErrorMessage": "Service boom"}], + }, + "ConsumerMessage": {"HtmlText": "Consumer boom"}, + "Message": "Top-level boom", + "Status": { + "SubCode": {"Code": "S", "Description": "Sub boom"}, + }, + } + } + ) + assert response.has_some_error() is True + assert response.get_some_error() == "Channel boom" + + +def test_get_some_error_walks_request_error_buckets_in_order(): + """When ChannelErrors empty, fall through to ServiceErrors etc.""" + response = PaymentResponse( + { + "data": { + "RequestErrors": { + "ChannelErrors": [], + "ServiceErrors": [{"ErrorMessage": "Service boom"}], + "ParameterErrors": [{"ErrorMessage": "Param boom"}], + } + } + } + ) + assert response.get_some_error() == "Service boom" + + +def test_get_some_error_falls_back_to_consumer_message(): + response = PaymentResponse( + { + "data": { + "ConsumerMessage": {"HtmlText": "Card declined"}, + "Message": "Top-level boom", + } + } + ) + assert response.get_some_error() == "Card declined" + + +def test_get_some_error_falls_back_to_top_level_message(): + response = PaymentResponse({"data": {"Message": "Gateway down"}}) + assert response.get_some_error() == "Gateway down" + + +def test_get_some_error_falls_back_to_sub_code_description(): + """Riverty 491 stuffs the ``Authorize rejected. ...`` text here.""" + riverty_msg = "Authorize rejected. The following errors occurred: File format is not supported." + response = PaymentResponse( + { + "data": { + "Status": { + "Code": {"Code": 491, "Description": "Validation failure"}, + "SubCode": {"Code": "S001", "Description": riverty_msg}, + } + } + } + ) + assert response.get_some_error() == riverty_msg + + +def test_get_some_error_returns_empty_when_no_source(): + response = PaymentResponse({"data": {}}) + assert response.has_some_error() is False + assert response.get_some_error() == "" + + +def test_has_error_ignores_empty_buckets(): + response = PaymentResponse( + {"data": {"RequestErrors": {"ChannelErrors": [], "ServiceErrors": []}}} + ) + assert response.has_error() is False + assert response.get_first_error() == {} + + +def test_has_consumer_message_handles_missing_html_text(): + response = PaymentResponse({"data": {"ConsumerMessage": {}}}) + assert response.has_consumer_message() is False + assert response.get_consumer_message() == "" + + +def test_has_sub_code_message_false_when_description_blank(): + response = PaymentResponse({"data": {"Status": {"SubCode": {"Code": "X", "Description": ""}}}}) + assert response.has_sub_code_message() is False + assert response.get_sub_code_message() == "" + + +# --- RequestErrors defensive parsing --- + + +def test_has_error_handles_dict_bucket(): + """RequestErrors bucket is a single dict (not a list).""" + response = PaymentResponse( + { + "data": { + "RequestErrors": { + "ChannelErrors": {"ErrorMessage": "Single bucket boom"}, + } + } + } + ) + assert response.has_error() is True + assert response.get_first_error() == {"ErrorMessage": "Single bucket boom"} + assert response.get_some_error() == "Single bucket boom" + + +def test_has_error_skips_non_list_non_dict_bucket(): + """Bucket value that is neither list nor dict is ignored.""" + response = PaymentResponse( + { + "data": { + "RequestErrors": { + "ChannelErrors": "not a real shape", + "ServiceErrors": [{"ErrorMessage": "Service boom"}], + } + } + } + ) + assert response.has_error() is True + assert response.get_some_error() == "Service boom" + + +def test_get_some_error_skips_entries_missing_error_message(): + """Entries without ``ErrorMessage`` fall through to the next entry.""" + response = PaymentResponse( + { + "data": { + "RequestErrors": { + "ChannelErrors": [ + {"Name": "no message here"}, + {"ErrorMessage": "Found it"}, + ] + } + } + } + ) + assert response.get_some_error() == "Found it" + + +def test_get_some_error_skips_blank_error_message(): + """Blank ``ErrorMessage`` falls through to the next non-blank entry, + even across buckets.""" + response = PaymentResponse( + { + "data": { + "RequestErrors": { + "ChannelErrors": [{"ErrorMessage": ""}], + "ServiceErrors": [{"ErrorMessage": "Real one"}], + } + } + } + ) + assert response.get_some_error() == "Real one" + + +def test_has_error_false_when_request_errors_is_scalar(): + """Non-dict RequestErrors (e.g. unexpected scalar) is treated as empty.""" + response = PaymentResponse({"data": {"RequestErrors": "boom"}}) + assert response.has_error() is False + assert response.get_first_error() == {} + assert response.get_some_error() == "" 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 "