From 5880a000ccaab7fea97a33218f710ad974527601 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Mon, 13 Apr 2026 09:52:51 +0200 Subject: [PATCH 1/3] PTHMINT-108: Add event stream, Cloud POS examples & scoped auth Introduce SSE event stream support and Cloud POS/terminal examples. Added EventStream parsing (SSE) and EventManager helpers, plus unit tests for both. Updated Order response to normalize legacy/singular event fields (events_token/events_stream_url) and updated OrderManager.create to accept terminal_group_id and pass a terminal-group AuthScope. Made Client and Sdk accept optional api_key (require credential_resolver when missing) and added validation in ScopedCredentialResolver; improved credential resolution for partner and default scopes. Small API tweaks: CreateTerminalRequest default values and param type adjustments. Many example scripts and integration/unit tests added to cover the new functionality. --- .env.example | 7 + examples/event_manager/subscribe_events.py | 89 ++++++++ examples/order_manager/cancel.py | 90 ++++++++ examples/order_manager/cloud_pos_order.py | 91 ++++++++ examples/pos_manager/get_receipt.py | 121 ++++++++++ .../get_terminals_by_group.py | 57 +++++ examples/terminal_manager/create.py | 62 +++++ examples/terminal_manager/get_terminals.py | 41 ++++ src/multisafepay/api/paths/__init__.py | 2 + src/multisafepay/api/paths/events/__init__.py | 7 + .../api/paths/events/event_manager.py | 89 ++++++++ .../api/paths/events/stream/__init__.py | 8 + .../api/paths/events/stream/event_stream.py | 215 ++++++++++++++++++ .../api/paths/orders/order_manager.py | 9 + .../paths/orders/response/order_response.py | 27 ++- .../request/create_terminal_request.py | 103 +++++++++ src/multisafepay/client/client.py | 7 +- .../client/credential_resolver.py | 106 +++++++++ src/multisafepay/sdk.py | 22 +- .../test_integration_order_manager_create.py | 47 +++- .../events/stream/test_unit_event_stream.py | 137 +++++++++++ .../path/events/test_unit_event_manager.py | 121 ++++++++++ .../orders/manager/test_unit_order_manager.py | 95 ++++++++ .../response/test_unit_order_response.py | 74 ++++++ .../test_unit_create_terminal_request.py | 63 +++++ .../response/test_unit_terminal_response.py | 96 ++++++++ .../unit/client/test_unit_client.py | 52 +++++ .../client/test_unit_credential_resolver.py | 96 ++++++++ tests/multisafepay/unit/test_unit_sdk.py | 81 +++++++ 29 files changed, 2006 insertions(+), 9 deletions(-) create mode 100644 examples/event_manager/subscribe_events.py create mode 100644 examples/order_manager/cancel.py create mode 100644 examples/order_manager/cloud_pos_order.py create mode 100644 examples/pos_manager/get_receipt.py create mode 100644 examples/terminal_group_manager/get_terminals_by_group.py create mode 100644 examples/terminal_manager/create.py create mode 100644 examples/terminal_manager/get_terminals.py create mode 100644 src/multisafepay/api/paths/events/__init__.py create mode 100644 src/multisafepay/api/paths/events/event_manager.py create mode 100644 src/multisafepay/api/paths/events/stream/__init__.py create mode 100644 src/multisafepay/api/paths/events/stream/event_stream.py create mode 100644 src/multisafepay/api/paths/terminals/request/create_terminal_request.py create mode 100644 src/multisafepay/client/credential_resolver.py create mode 100644 tests/multisafepay/unit/api/path/events/stream/test_unit_event_stream.py create mode 100644 tests/multisafepay/unit/api/path/events/test_unit_event_manager.py create mode 100644 tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py create mode 100644 tests/multisafepay/unit/api/path/orders/response/test_unit_order_response.py create mode 100644 tests/multisafepay/unit/api/path/terminals/request/test_unit_create_terminal_request.py create mode 100644 tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py create mode 100644 tests/multisafepay/unit/client/test_unit_credential_resolver.py diff --git a/.env.example b/.env.example index 7a2af63..1725e84 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ API_KEY= E2E_API_KEY= E2E_BASE_URL=https://testapi.multisafepay.com/v1/ +PARTNER_API_KEY= +CLOUD_POS_TERMINAL_GROUP_ID= + +# Terminal group API keys — one entry per group_id +TERMINAL_GROUP_API_KEY_GROUP_DEFAULT= +# TERMINAL_GROUP_API_KEY_GROUP_A= +# TERMINAL_GROUP_API_KEY_GROUP_B= diff --git a/examples/event_manager/subscribe_events.py b/examples/event_manager/subscribe_events.py new file mode 100644 index 0000000..6eec22b --- /dev/null +++ b/examples/event_manager/subscribe_events.py @@ -0,0 +1,89 @@ +"""Create a Cloud POS order and subscribe to its event stream.""" + +import os +import time + +from dotenv import load_dotenv +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +DEFAULT_ACCOUNT_API_KEY = (os.getenv("API_KEY") or "").strip() +PARTNER_AFFILIATE_API_KEY = (os.getenv("PARTNER_API_KEY") or "").strip() +TERMINAL_GROUP_DEFAULT_API_KEY = ( + os.getenv("TERMINAL_GROUP_API_KEY_GROUP_DEFAULT") or "" +).strip() +CLOUD_POS_TERMINAL_GROUP_ID = os.getenv( + "CLOUD_POS_TERMINAL_GROUP_ID", + "Default", +) + +if __name__ == "__main__": + if not TERMINAL_GROUP_DEFAULT_API_KEY: + raise RuntimeError( + "TERMINAL_GROUP_API_KEY_GROUP_DEFAULT is required", + ) + + resolver_bootstrap_api_key = ( + DEFAULT_ACCOUNT_API_KEY or TERMINAL_GROUP_DEFAULT_API_KEY + ) + resolver_kwargs = { + "default_api_key": resolver_bootstrap_api_key, + "terminal_group_api_keys": { + CLOUD_POS_TERMINAL_GROUP_ID: TERMINAL_GROUP_DEFAULT_API_KEY, + }, + } + if PARTNER_AFFILIATE_API_KEY: + resolver_kwargs["partner_affiliate_api_key"] = ( + PARTNER_AFFILIATE_API_KEY + ) + + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + order_manager = multisafepay_sdk.get_order_manager() + event_manager = multisafepay_sdk.get_event_manager() + + # Temporary override for local runs; comment this line to force literal placeholder. + # terminal_id = os.getenv("CLOUD_POS_TERMINAL_ID", "") + + order_id = f"cloud-pos-{int(time.time())}" + + order_request = ( + OrderRequest() + .add_type("redirect") + .add_order_id(order_id) + .add_description("Cloud POS order") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info( + { + "terminal_id": terminal_id, + }, + ) + ) + + create_response = order_manager.create( + order_request, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + order = create_response.get_data() + + if order is None: + raise RuntimeError("Order creation did not return order data") + + print(f"Created Cloud POS order: {order.order_id}") + print("Listening for events. Press Ctrl+C to stop.") + + try: + with event_manager.subscribe_order_events(order, timeout=45.0) as stream: + for event in stream: + print(event) + except KeyboardInterrupt: + print("Stream interrupted by user.") diff --git a/examples/order_manager/cancel.py b/examples/order_manager/cancel.py new file mode 100644 index 0000000..9cfa74d --- /dev/null +++ b/examples/order_manager/cancel.py @@ -0,0 +1,90 @@ +"""Create a Cloud POS order, wait 5 seconds, and cancel it.""" + +import os +import time + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +DEFAULT_ACCOUNT_API_KEY = (os.getenv("API_KEY") or "").strip() +PARTNER_AFFILIATE_API_KEY = (os.getenv("PARTNER_API_KEY") or "").strip() +TERMINAL_GROUP_DEFAULT_API_KEY = ( + os.getenv("TERMINAL_GROUP_API_KEY_GROUP_DEFAULT") or "" +).strip() +CLOUD_POS_TERMINAL_GROUP_ID = os.getenv( + "CLOUD_POS_TERMINAL_GROUP_ID", + "Default", +) + +if __name__ == "__main__": + if not TERMINAL_GROUP_DEFAULT_API_KEY: + raise RuntimeError( + "TERMINAL_GROUP_API_KEY_GROUP_DEFAULT is required", + ) + + resolver_bootstrap_api_key = ( + DEFAULT_ACCOUNT_API_KEY or TERMINAL_GROUP_DEFAULT_API_KEY + ) + resolver_kwargs = { + "default_api_key": resolver_bootstrap_api_key, + "terminal_group_api_keys": { + CLOUD_POS_TERMINAL_GROUP_ID: TERMINAL_GROUP_DEFAULT_API_KEY, + }, + } + if PARTNER_AFFILIATE_API_KEY: + resolver_kwargs["partner_affiliate_api_key"] = ( + PARTNER_AFFILIATE_API_KEY + ) + + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + order_manager = multisafepay_sdk.get_order_manager() + + # Temporary override for local runs; comment this line to force literal placeholder. + terminal_id = os.getenv("CLOUD_POS_TERMINAL_ID", "") + + order_request = ( + OrderRequest() + .add_type("redirect") + .add_order_id(f"cloud-pos-cancel-{int(time.time())}") + .add_description("Cloud POS cancel order") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info( + { + "terminal_id": terminal_id, + }, + ) + ) + + create_response = order_manager.create( + order_request, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + order = create_response.get_data() + + if order is None or not order.order_id: + raise RuntimeError("Order creation did not return order_id") + + order_id = order.order_id + print(f"Created Cloud POS order: {order_id}") + print("Waiting 5 seconds before cancel...") + time.sleep(5) + + cancel_response = order_manager.cancel_transaction( + order_id, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + + print(f"Canceled Cloud POS order: {order_id}") + print(cancel_response.get_data()) diff --git a/examples/order_manager/cloud_pos_order.py b/examples/order_manager/cloud_pos_order.py new file mode 100644 index 0000000..791e652 --- /dev/null +++ b/examples/order_manager/cloud_pos_order.py @@ -0,0 +1,91 @@ +"""Create a Cloud POS order and print its event stream credentials.""" + +import os +import time + +from dotenv import load_dotenv +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +DEFAULT_ACCOUNT_API_KEY = (os.getenv("API_KEY") or "").strip() +PARTNER_AFFILIATE_API_KEY = (os.getenv("PARTNER_API_KEY") or "").strip() +TERMINAL_GROUP_DEFAULT_API_KEY = ( + os.getenv("TERMINAL_GROUP_API_KEY_GROUP_DEFAULT") or "" +).strip() +CLOUD_POS_TERMINAL_GROUP_ID = os.getenv( + "CLOUD_POS_TERMINAL_GROUP_ID", + "Default", +) + +if __name__ == "__main__": + if not TERMINAL_GROUP_DEFAULT_API_KEY: + raise RuntimeError( + "TERMINAL_GROUP_API_KEY_GROUP_DEFAULT is required", + ) + + # Reuse one SDK for mixed traffic. The resolver is the source of truth for + # which key is used per endpoint/scope. + resolver_bootstrap_api_key = ( + DEFAULT_ACCOUNT_API_KEY or TERMINAL_GROUP_DEFAULT_API_KEY + ) + resolver_kwargs = { + "default_api_key": resolver_bootstrap_api_key, + "terminal_group_api_keys": { + CLOUD_POS_TERMINAL_GROUP_ID: TERMINAL_GROUP_DEFAULT_API_KEY, + }, + } + if PARTNER_AFFILIATE_API_KEY: + resolver_kwargs["partner_affiliate_api_key"] = ( + PARTNER_AFFILIATE_API_KEY + ) + + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + order_manager = multisafepay_sdk.get_order_manager() + + terminal_id = "" + # Uncomment this line to override with CLOUD_POS_TERMINAL_ID from .env. + # terminal_id = os.getenv("CLOUD_POS_TERMINAL_ID", terminal_id) + + order_id = f"cloud-pos-{int(time.time())}" + + order_request = ( + OrderRequest() + .add_type("redirect") + .add_order_id(order_id) + .add_description("Cloud POS order") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info( + { + "terminal_id": terminal_id, + }, + ) + ) + + create_response = order_manager.create( + order_request, + terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + ) + order = create_response.get_data() + + if order is None: + raise RuntimeError("Order creation did not return order data") + + print(f"Created Cloud POS order: {order.order_id}") + + events_token = order.events_token or order.event_token + events_stream_url = order.events_stream_url or order.event_stream_url + + if events_token and events_stream_url: + print("Event stream credentials:") + print(f"EVENTS_TOKEN={events_token}") + print(f"EVENTS_STREAM_URL={events_stream_url}") diff --git a/examples/pos_manager/get_receipt.py b/examples/pos_manager/get_receipt.py new file mode 100644 index 0000000..8096d9b --- /dev/null +++ b/examples/pos_manager/get_receipt.py @@ -0,0 +1,121 @@ +import os +import time + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.api.paths.orders.request import OrderRequest +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +default_account_api_key = (os.getenv("API_KEY") or "").strip() +terminal_group_default_api_key = ( + os.getenv("TERMINAL_GROUP_API_KEY_GROUP_DEFAULT") or "" +).strip() +partner_affiliate_api_key = (os.getenv("PARTNER_API_KEY") or "").strip() + +terminal_group_id = "Default" +# Temporary override for local runs via .env. +terminal_group_id = os.getenv("CLOUD_POS_TERMINAL_GROUP_ID", terminal_group_id) + +terminal_id = "" +# Temporary override for local runs via .env. +terminal_id = os.getenv("CLOUD_POS_TERMINAL_ID", terminal_id) + + +def _is_completed_event(event: object) -> bool: + """Return True when the SSE payload indicates a completed payment.""" + payload = getattr(event, "data", None) + if isinstance(payload, dict): + status = payload.get("status") + if isinstance(status, str) and status.lower() == "completed": + return True + + nested_payload = payload.get("data") + if isinstance(nested_payload, dict): + nested_status = nested_payload.get("status") + if ( + isinstance(nested_status, str) + and nested_status.lower() == "completed" + ): + return True + + return False + +if __name__ == "__main__": + if not terminal_group_default_api_key: + raise RuntimeError( + "TERMINAL_GROUP_API_KEY_GROUP_DEFAULT is required", + ) + + if terminal_id == "": + raise RuntimeError("Replace or set CLOUD_POS_TERMINAL_ID") + + resolver_bootstrap_api_key = ( + default_account_api_key or terminal_group_default_api_key + ) + resolver_kwargs = { + "default_api_key": resolver_bootstrap_api_key, + "terminal_group_api_keys": { + terminal_group_id: terminal_group_default_api_key, + }, + } + if partner_affiliate_api_key: + resolver_kwargs["partner_affiliate_api_key"] = ( + partner_affiliate_api_key + ) + + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + + # Get the managers from the SDK + order_manager = multisafepay_sdk.get_order_manager() + event_manager = multisafepay_sdk.get_event_manager() + pos_manager = multisafepay_sdk.get_pos_manager() + + order_request = ( + OrderRequest() + .add_type("redirect") + .add_order_id(f"cloud-pos-receipt-{int(time.time())}") + .add_description("Cloud POS order for receipt") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info( + { + "terminal_id": terminal_id, + }, + ) + ) + + create_response = order_manager.create( + order_request, + terminal_group_id=terminal_group_id, + ) + order = create_response.get_data() + + if order is None or not order.order_id: + raise RuntimeError("Order creation did not return order_id") + + print(f"Created Cloud POS order: {order.order_id}") + print("Waiting for completed event...") + + with event_manager.subscribe_order_events(order, timeout=45.0) as stream: + for event in stream: + print(event) + + if not _is_completed_event(event): + continue + + print("Completed event detected. Fetching receipt...") + receipt_response = pos_manager.get_receipt( + order_id=order.order_id, + terminal_group_id=terminal_group_id, + ) + print(receipt_response.get_data()) + break diff --git a/examples/terminal_group_manager/get_terminals_by_group.py b/examples/terminal_group_manager/get_terminals_by_group.py new file mode 100644 index 0000000..18e7009 --- /dev/null +++ b/examples/terminal_group_manager/get_terminals_by_group.py @@ -0,0 +1,57 @@ +import os + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +default_account_api_key = (os.getenv("API_KEY") or "").strip() +partner_affiliate_api_key = (os.getenv("PARTNER_API_KEY") or "").strip() + +terminal_group_id = os.getenv( + "CLOUD_POS_TERMINAL_GROUP_ID", + "", +).strip() + +if __name__ == "__main__": + if not partner_affiliate_api_key: + raise RuntimeError("PARTNER_API_KEY is required") + + if not terminal_group_id: + raise RuntimeError("CLOUD_POS_TERMINAL_GROUP_ID is required") + + if not terminal_group_id.isdigit(): + raise RuntimeError( + "CLOUD_POS_TERMINAL_GROUP_ID must be a numeric group id", + ) + + credential_resolver = ScopedCredentialResolver( + default_api_key=default_account_api_key, + partner_affiliate_api_key=partner_affiliate_api_key, + ) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + + # Get the 'TerminalGroup' manager from the SDK + terminal_group_manager = multisafepay_sdk.get_terminal_group_manager() + + # Define optional pagination parameters + options = { + "limit": 10, + "page": 1, + } + + # Fetch terminals assigned to the specified terminal group + terminals_by_group_response = terminal_group_manager.get_terminals_by_group( + terminal_group_id=terminal_group_id, + options=options, + ) + + # Print the terminal listing data + print(terminals_by_group_response.get_data()) diff --git a/examples/terminal_manager/create.py b/examples/terminal_manager/create.py new file mode 100644 index 0000000..d6520ad --- /dev/null +++ b/examples/terminal_manager/create.py @@ -0,0 +1,62 @@ +import os + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.api.paths.terminals.request.create_terminal_request import ( + CreateTerminalRequest, +) +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +default_account_api_key = (os.getenv("API_KEY") or "").strip() +partner_affiliate_api_key = (os.getenv("PARTNER_API_KEY") or "").strip() + +terminal_group_id_raw = os.getenv( + "CLOUD_POS_TERMINAL_GROUP_ID", + "", +).strip() + +if __name__ == "__main__": + if not partner_affiliate_api_key: + raise RuntimeError("PARTNER_API_KEY is required") + + if not terminal_group_id_raw: + raise RuntimeError("CLOUD_POS_TERMINAL_GROUP_ID is required") + + if not terminal_group_id_raw.isdigit(): + raise RuntimeError( + "CLOUD_POS_TERMINAL_GROUP_ID must be a numeric group id", + ) + + terminal_group_id = int(terminal_group_id_raw) + + credential_resolver = ScopedCredentialResolver( + default_api_key=default_account_api_key, + partner_affiliate_api_key=partner_affiliate_api_key, + ) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + + # Get the 'Terminal' manager from the SDK + terminal_manager = multisafepay_sdk.get_terminal_manager() + + # Build the create terminal request + create_request = ( + CreateTerminalRequest() + .add_provider("CTAP") + .add_group_id(terminal_group_id) + .add_name("Demo POS Terminal") + ) + + # Create a new POS terminal + terminal_response = terminal_manager.create_terminal(create_request) + + # Print the created terminal data + terminal_data = terminal_response.get_data() + print(terminal_data) diff --git a/examples/terminal_manager/get_terminals.py b/examples/terminal_manager/get_terminals.py new file mode 100644 index 0000000..2c1eb54 --- /dev/null +++ b/examples/terminal_manager/get_terminals.py @@ -0,0 +1,41 @@ +import os + +from dotenv import load_dotenv + +from multisafepay import Sdk +from multisafepay.client import ScopedCredentialResolver + +# Load environment variables from a .env file +load_dotenv() + +default_account_api_key = (os.getenv("API_KEY") or "").strip() +partner_affiliate_api_key = (os.getenv("PARTNER_API_KEY") or "").strip() + +if __name__ == "__main__": + if not partner_affiliate_api_key: + raise RuntimeError("PARTNER_API_KEY is required") + + credential_resolver = ScopedCredentialResolver( + default_api_key=default_account_api_key, + partner_affiliate_api_key=partner_affiliate_api_key, + ) + + multisafepay_sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + + # Get the 'Terminal' manager from the SDK + terminal_manager = multisafepay_sdk.get_terminal_manager() + + # Define optional pagination parameters + options = { + "limit": 10, + "page": 1, + } + + # Fetch terminals for the account + terminals_response = terminal_manager.get_terminals(options=options) + + # Print the terminal listing data + print(terminals_response.get_data()) diff --git a/src/multisafepay/api/paths/__init__.py b/src/multisafepay/api/paths/__init__.py index 338bcd5..1b8d1dd 100644 --- a/src/multisafepay/api/paths/__init__.py +++ b/src/multisafepay/api/paths/__init__.py @@ -5,6 +5,7 @@ from multisafepay.api.paths.categories.category_manager import ( CategoryManager, ) +from multisafepay.api.paths.events.event_manager import EventManager from multisafepay.api.paths.gateways.gateway_manager import GatewayManager from multisafepay.api.paths.issuers.issuer_manager import IssuerManager from multisafepay.api.paths.me.me_manager import MeManager @@ -23,6 +24,7 @@ "AuthManager", "CaptureManager", "CategoryManager", + "EventManager", "GatewayManager", "IssuerManager", "MeManager", diff --git a/src/multisafepay/api/paths/events/__init__.py b/src/multisafepay/api/paths/events/__init__.py new file mode 100644 index 0000000..a0e525e --- /dev/null +++ b/src/multisafepay/api/paths/events/__init__.py @@ -0,0 +1,7 @@ +"""Events API endpoints.""" + +from multisafepay.api.paths.events.event_manager import EventManager + +__all__ = [ + "EventManager", +] diff --git a/src/multisafepay/api/paths/events/event_manager.py b/src/multisafepay/api/paths/events/event_manager.py new file mode 100644 index 0000000..6c98547 --- /dev/null +++ b/src/multisafepay/api/paths/events/event_manager.py @@ -0,0 +1,89 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Event manager for event stream subscription helpers.""" + +from __future__ import annotations + +from multisafepay.api.base.abstract_manager import AbstractManager +from multisafepay.api.paths.events.stream.event_stream import EventStream +from multisafepay.api.paths.orders.response.order_response import Order +from multisafepay.client.client import Client + + +class EventManager(AbstractManager): + """Manages event stream subscriptions for order events.""" + + def __init__(self: EventManager, client: Client) -> None: + """Initialize the EventManager with a client.""" + super().__init__(client) + + def subscribe_events( + self: EventManager, + events_token: str, + events_stream_url: str, + last_event_id: str | None = None, + timeout: float = 30.0, + ) -> EventStream: + """ + Subscribe to order events using the SSE stream endpoint. + + Parameters + ---------- + events_token (str): Token returned by order creation for event auth. + events_stream_url (str): Full SSE stream URL. + last_event_id (str | None): Optional resume cursor. + timeout (float): Socket timeout in seconds. + + Returns + ------- + EventStream: An iterator over incoming SSE messages. + + """ + return EventStream.open( + events_token=events_token, + events_stream_url=events_stream_url, + last_event_id=last_event_id, + timeout=timeout, + ) + + def subscribe_order_events( + self: EventManager, + order: Order, + last_event_id: str | None = None, + timeout: float = 30.0, + ) -> EventStream: + """ + Subscribe to events for an existing order response object. + + Parameters + ---------- + order (Order): Order response that contains event credentials. + last_event_id (str | None): Optional resume cursor. + timeout (float): Socket timeout in seconds. + + Returns + ------- + EventStream: An iterator over incoming SSE messages. + + """ + events_token = order.events_token or order.event_token + events_stream_url = ( + order.events_stream_url or order.event_stream_url + ) + + if not events_token or not events_stream_url: + raise ValueError( + "Order does not contain events_token/events_stream_url.", + ) + + return self.subscribe_events( + events_token=events_token, + events_stream_url=events_stream_url, + last_event_id=last_event_id, + timeout=timeout, + ) diff --git a/src/multisafepay/api/paths/events/stream/__init__.py b/src/multisafepay/api/paths/events/stream/__init__.py new file mode 100644 index 0000000..21fa7fd --- /dev/null +++ b/src/multisafepay/api/paths/events/stream/__init__.py @@ -0,0 +1,8 @@ +"""Event stream API helpers and manager.""" + +from multisafepay.api.paths.events.stream.event_stream import Event, EventStream + +__all__ = [ + "Event", + "EventStream", +] diff --git a/src/multisafepay/api/paths/events/stream/event_stream.py b/src/multisafepay/api/paths/events/stream/event_stream.py new file mode 100644 index 0000000..a47e1a6 --- /dev/null +++ b/src/multisafepay/api/paths/events/stream/event_stream.py @@ -0,0 +1,215 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Server-Sent Events stream parsing for order event subscriptions.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Protocol +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +from typing_extensions import Self + + +class StreamingResponse(Protocol): + """Protocol for the minimal stream response interface used by EventStream.""" + + def readline(self: StreamingResponse) -> bytes: + """Read one line from the stream response.""" + + def close(self: StreamingResponse) -> None: + """Close the stream response.""" + + +@dataclass(frozen=True) +class Event: + """Represents one event message from an SSE stream.""" + + event: str | None = None + data: object | None = None + event_id: str | None = None + retry: int | None = None + raw_data: str | None = None + + +@dataclass +class _EventBuilder: + """Mutable container used while parsing one SSE message.""" + + event_name: str | None = None + event_id: str | None = None + event_retry: int | None = None + data_lines: list[str] = field(default_factory=list) + has_fields: bool = False + + def consume_line(self: _EventBuilder, line: str) -> None: + """Consume one SSE line and update the builder state.""" + if line.startswith(":"): + return + + field_name, field_value = _parse_line(line) + if field_name is None: + return + + self.has_fields = True + if field_name == "event": + self.event_name = field_value + return + + if field_name == "data": + self.data_lines.append(field_value) + return + + if field_name == "id": + self.event_id = field_value + return + + if field_name == "retry": + try: + self.event_retry = int(field_value) + except ValueError: + return + + def to_event(self: _EventBuilder) -> Event | None: + """Build an immutable event object or None when no message exists.""" + if not self.has_fields and not self.data_lines: + return None + + raw_data = "\n".join(self.data_lines) if self.data_lines else None + return Event( + event=self.event_name, + data=_parse_data(raw_data), + event_id=self.event_id, + retry=self.event_retry, + raw_data=raw_data, + ) + + +class EventStream: + """Iterator over events received from an SSE endpoint.""" + + def __init__(self: EventStream, response: StreamingResponse) -> None: + """Initialize the stream from an already-open HTTP response.""" + self._response = response + self._closed = False + + @classmethod + def open( + cls: type[EventStream], + events_token: str, + events_stream_url: str, + last_event_id: str | None = None, + timeout: float = 30.0, + ) -> EventStream: + """Open a new SSE stream using the event token and stream URL.""" + cls._validate_stream_url(events_stream_url) + + headers = { + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + "Authorization": f"Bearer {events_token}", + } + if last_event_id is not None: + headers["Last-Event-ID"] = last_event_id + + request = Request( # noqa: S310 + url=events_stream_url, + headers=headers, + method="GET", + ) + response = urlopen(request, timeout=timeout) # noqa: S310 + + return cls(response=response) + + @staticmethod + def _validate_stream_url(events_stream_url: str) -> None: + """Validate the stream URL before opening the network connection.""" + parsed = urlparse(events_stream_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Invalid events stream URL.") + + @property + def closed(self: EventStream) -> bool: + """Return whether this stream is already closed.""" + return self._closed + + def close(self: EventStream) -> None: + """Close the underlying HTTP response stream.""" + if self._closed: + return + + self._response.close() + self._closed = True + + def __iter__(self: EventStream) -> EventStream: + """Return self as an iterator over events.""" + return self + + def __next__(self: EventStream) -> Event: + """Read the next SSE message and return it as an Event.""" + if self._closed: + raise StopIteration + + builder = _EventBuilder() + while True: + line = self._read_line() + if line is None: + self.close() + raise StopIteration + + if line == "": + event = builder.to_event() + if event is not None: + return event + builder = _EventBuilder() + continue + + builder.consume_line(line) + + def _read_line(self: EventStream) -> str | None: + """Read and decode one line from the underlying stream response.""" + raw_line = self._response.readline() + if not raw_line: + return None + + return raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + + def __enter__(self: Self) -> Self: + """Support context manager protocol.""" + return self + + def __exit__(self: EventStream, *args: object) -> None: + """Close stream when exiting context manager.""" + self.close() + + +def _parse_line(line: str) -> tuple[str | None, str]: + """Parse one SSE line into field and value parts.""" + if ":" not in line: + return line or None, "" + + field_name, field_value = line.split(":", 1) + if field_name == "": + return None, "" + if field_value.startswith(" "): + field_value = field_value[1:] + + return field_name, field_value + + +def _parse_data(raw_data: str | None) -> object | None: + """Try to parse data as JSON and fall back to plain text.""" + if raw_data is None: + return None + + try: + return json.loads(raw_data) + except json.JSONDecodeError: + return raw_data diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index 9ae9092..98d870e 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -115,6 +115,7 @@ def get(self: "OrderManager", order_id: str) -> CustomApiResponse: def create( self: "OrderManager", request_order: OrderRequest, + terminal_group_id: str = None, ) -> CustomApiResponse: """ Create a new order. @@ -122,6 +123,8 @@ def create( Parameters ---------- request_order (OrderRequest): The request object containing order details. + terminal_group_id (str): Optional terminal group identifier for + scoped auth resolution. Returns ------- @@ -132,6 +135,12 @@ def create( response: ApiResponse = self.client.create_post_request( "json/orders", request_body=json_data, + auth_scope=AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None, ) return OrderManager.__custom_api_response(response) diff --git a/src/multisafepay/api/paths/orders/response/order_response.py b/src/multisafepay/api/paths/orders/response/order_response.py index 4581929..89e8f04 100644 --- a/src/multisafepay/api/paths/orders/response/order_response.py +++ b/src/multisafepay/api/paths/orders/response/order_response.py @@ -103,6 +103,11 @@ class Order(ResponseModel): payment_url: Optional[str] cancel_url: Optional[str] session_id: Optional[str] + events_token: Optional[str] + events_url: Optional[str] + events_stream_url: Optional[str] + + # Backward compatibility aliases for older API payloads. event_token: Optional[str] event_url: Optional[str] event_stream_url: Optional[str] @@ -118,6 +123,23 @@ def get_order_id(self: "Order") -> str: """ return self.order_id + @staticmethod + def _normalize_event_fields(d: dict) -> dict: + """Normalize singular/plural event keys for compatibility.""" + mapping = [ + ("events_token", "event_token"), + ("events_url", "event_url"), + ("events_stream_url", "event_stream_url"), + ] + + for plural_key, singular_key in mapping: + if d.get(plural_key) is None and d.get(singular_key) is not None: + d[plural_key] = d[singular_key] + if d.get(singular_key) is None and d.get(plural_key) is not None: + d[singular_key] = d[plural_key] + + return d + @staticmethod def from_dict(d: dict) -> Optional["Order"]: """ @@ -134,7 +156,10 @@ def from_dict(d: dict) -> Optional["Order"]: """ if d is None: return None - order_dependency_adapter = Decorator(dependencies=d) + normalized_dependencies = Order._normalize_event_fields(d.copy()) + order_dependency_adapter = Decorator( + dependencies=normalized_dependencies, + ) dependencies = ( order_dependency_adapter.adapt_order_adjustment( d.get("order_adjustment"), diff --git a/src/multisafepay/api/paths/terminals/request/create_terminal_request.py b/src/multisafepay/api/paths/terminals/request/create_terminal_request.py new file mode 100644 index 0000000..69cbe0b --- /dev/null +++ b/src/multisafepay/api/paths/terminals/request/create_terminal_request.py @@ -0,0 +1,103 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Request model for creating POS terminals.""" + +from typing import Optional + +from multisafepay.exception.invalid_argument import InvalidArgumentException +from multisafepay.model.request_model import RequestModel + +CTAP_PROVIDER = "CTAP" +ALLOWED_PROVIDERS = [ + CTAP_PROVIDER, +] + + +class CreateTerminalRequest(RequestModel): + """ + Request body for the create terminal endpoint. + + Attributes + ---------- + provider (Optional[str]): The terminal provider. + group_id (Optional[int]): The terminal group id. + name (Optional[str]): The terminal name. + + """ + + provider: Optional[str] = None + group_id: Optional[int] = None + name: Optional[str] = None + + def add_provider( + self: "CreateTerminalRequest", + provider: Optional[str], + ) -> "CreateTerminalRequest": + """ + Add a terminal provider. + + Parameters + ---------- + provider (Optional[str]): The provider value. + + Raises + ------ + InvalidArgumentException: If provider is not one of the allowed values. + + Returns + ------- + CreateTerminalRequest: The current request object. + + """ + if provider is not None and provider not in ALLOWED_PROVIDERS: + msg = ( + f'Provider "{provider}" is not a known provider. ' + f'Available providers: {", ".join(ALLOWED_PROVIDERS)}' + ) + raise InvalidArgumentException(msg) + + self.provider = provider + return self + + def add_group_id( + self: "CreateTerminalRequest", + group_id: str, + ) -> "CreateTerminalRequest": + """ + Add a terminal group id. + + Parameters + ---------- + group_id (str): The terminal group identifier. + + Returns + ------- + CreateTerminalRequest: The current request object. + + """ + self.group_id = group_id + return self + + def add_name( + self: "CreateTerminalRequest", + name: Optional[str], + ) -> "CreateTerminalRequest": + """ + Add a terminal name. + + Parameters + ---------- + name (Optional[str]): The terminal name. + + Returns + ------- + CreateTerminalRequest: The current request object. + + """ + self.name = name + return self diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index d6d5e00..5a17b87 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -46,8 +46,8 @@ class Client: def __init__( self: "Client", - api_key: str, - is_production: bool, + api_key: Optional[str] = None, + is_production: bool = False, transport: Optional[HTTPTransport] = None, locale: str = "en_US", base_url: Optional[str] = None, @@ -57,7 +57,8 @@ def __init__( Parameters ---------- - api_key (str): The API key for authentication. + api_key (Optional[str]): The API key for authentication. + Optional only when `credential_resolver` is provided. is_production (bool): Flag indicating if the client is in production mode. transport (Optional[HTTPTransport], optional): Custom HTTP transport implementation. Defaults to RequestsTransport if not provided. diff --git a/src/multisafepay/client/credential_resolver.py b/src/multisafepay/client/credential_resolver.py new file mode 100644 index 0000000..a9119a2 --- /dev/null +++ b/src/multisafepay/client/credential_resolver.py @@ -0,0 +1,106 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Credential resolver contracts and default scoped resolver.""" + +from dataclasses import dataclass +from typing import Optional, Protocol + + +@dataclass(frozen=True) +class AuthScope: + """Auth scope selection payload for credential resolution.""" + + scope: str + group_id: Optional[str] = None + + +class CredentialResolver(Protocol): + """Protocol for resolving API keys by auth scope and context.""" + + def resolve( + self: "CredentialResolver", + auth_scope: str, + group_id: Optional[str] = None, + ) -> str: + """Resolve the API key to use for a given scope and context.""" + + +class ScopedCredentialResolver: + """Default resolver implementation for account, partner and group scopes.""" + + AUTH_SCOPE_DEFAULT = "default_account" + AUTH_SCOPE_PARTNER_AFFILIATE = "partner_affiliate" + AUTH_SCOPE_TERMINAL_GROUP = "terminal_group" + + def __init__( + self: "ScopedCredentialResolver", + default_api_key: str, + partner_affiliate_api_key: Optional[str] = None, + terminal_group_api_keys: Optional[dict[str, str]] = None, + ) -> None: + """ + Initialize a scoped credential resolver. + + Parameters + ---------- + default_api_key (str): Fallback/default account API key. + partner_affiliate_api_key (Optional[str]): Partner/affiliate API key. + terminal_group_api_keys (Optional[dict[str, str]]): Mapping of + terminal_group_id to API key. + + """ + self.default_api_key = (default_api_key or "").strip() + self.partner_affiliate_api_key = ( + (partner_affiliate_api_key or "").strip() or None + ) + self.terminal_group_api_keys = { + group_id: api_key.strip() + for group_id, api_key in (terminal_group_api_keys or {}).items() + if api_key and api_key.strip() + } + + if ( + not self.default_api_key + and self.partner_affiliate_api_key is None + and not self.terminal_group_api_keys + ): + raise ValueError( + "ScopedCredentialResolver requires at least one API key.", + ) + + def resolve( + self: "ScopedCredentialResolver", + auth_scope: str, + group_id: Optional[str] = None, + ) -> str: + """Resolve API key for the given scope and auth context.""" + if auth_scope == self.AUTH_SCOPE_TERMINAL_GROUP: + if not group_id: + raise ValueError( + "Missing terminal_group_id in auth scope.", + ) + api_key = self.terminal_group_api_keys.get(group_id) + if not api_key: + raise ValueError( + "No API key configured for terminal_group_id " + f"'{group_id}'.", + ) + return api_key + + if auth_scope == self.AUTH_SCOPE_PARTNER_AFFILIATE: + api_key = self.partner_affiliate_api_key or self.default_api_key + if not api_key: + raise ValueError( + "No API key configured for partner_affiliate scope.", + ) + return api_key + + if not self.default_api_key: + raise ValueError("No API key configured for default scope.") + + return self.default_api_key diff --git a/src/multisafepay/sdk.py b/src/multisafepay/sdk.py index b22dc3b..7c4a566 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -11,6 +11,7 @@ from multisafepay.api.paths.auth.auth_manager import AuthManager from multisafepay.api.paths.categories.category_manager import CategoryManager +from multisafepay.api.paths.events.event_manager import EventManager from multisafepay.api.paths.gateways.gateway_manager import GatewayManager from multisafepay.api.paths.issuers.issuer_manager import IssuerManager from multisafepay.api.paths.orders.order_manager import OrderManager @@ -38,8 +39,8 @@ class Sdk: def __init__( self: "Sdk", - api_key: str, - is_production: bool, + api_key: Optional[str] = None, + is_production: bool = False, transport: Optional[HTTPTransport] = None, locale: str = "en_US", base_url: Optional[str] = None, @@ -49,8 +50,9 @@ def __init__( Parameters ---------- - api_key : str + api_key : Optional[str] The API key for authenticating with the MultiSafePay API. + Optional only when `credential_resolver` is provided. is_production : bool Flag indicating whether to use the production environment. transport : Optional[HTTPTransport], optional @@ -63,7 +65,7 @@ def __init__( """ self.client = Client( - api_key.strip(), + api_key, is_production, transport, locale, @@ -167,6 +169,18 @@ def get_category_manager(self: "Sdk") -> CategoryManager: """ return CategoryManager(self.client) + def get_event_manager(self: "Sdk") -> EventManager: + """ + Get the event manager. + + Returns + ------- + EventManager + The event manager instance. + + """ + return EventManager(self.client) + def get_order_manager(self: "Sdk") -> OrderManager: """ Get the order manager. diff --git a/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py b/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py index 4b14730..8942814 100644 --- a/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py +++ b/tests/multisafepay/integration/api/path/orders/manager/test_integration_order_manager_create.py @@ -21,6 +21,8 @@ from multisafepay.api.paths.orders.request.order_request import OrderRequest from multisafepay.api.paths.orders.response.order_response import Order from multisafepay.api.shared.customer import Customer +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope def test_integration_order_manager_create_redirect(): @@ -88,4 +90,47 @@ def test_integration_order_manager_create_redirect(): assert isinstance(response, CustomApiResponse) assert isinstance(response.get_data(), Order) - assert response.get_data() == Order(**data_response) + assert response.get_data() == Order.from_dict(data_response) + + +def test_integration_order_manager_create_with_terminal_group_scope(): + """Use terminal-group auth scope when terminal_group_id is provided.""" + client = MagicMock() + client.create_post_request.return_value = ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": "cloud-pos-order", + }, + }, + ) + order_request = ( + OrderRequest() + .add_type("direct") + .add_order_id("cloud-pos-order") + .add_currency("EUR") + .add_amount(100) + ) + + order_manager = OrderManager(client) + response = order_manager.create( + request_order=order_request, + terminal_group_id="Default", + ) + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert response.get_data().order_id == "cloud-pos-order" + + called_endpoint = client.create_post_request.call_args.args[0] + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert called_endpoint == "json/orders" + assert called_auth_scope == AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id="Default", + ) diff --git a/tests/multisafepay/unit/api/path/events/stream/test_unit_event_stream.py b/tests/multisafepay/unit/api/path/events/stream/test_unit_event_stream.py new file mode 100644 index 0000000..9c63383 --- /dev/null +++ b/tests/multisafepay/unit/api/path/events/stream/test_unit_event_stream.py @@ -0,0 +1,137 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for SSE event stream parsing.""" + +from __future__ import annotations + +import io + +import pytest + +from multisafepay.api.paths.events.stream.event_stream import EventStream + +EVENTS_STREAM_URL = "https://testapi.multisafepay.com/events/stream/" +EVENTS_TOKEN = "events-token" +LAST_EVENT_ID = "last-10" +PING_PAYLOAD = b"data: ping\n\n" +INVALID_EVENTS_STREAM_URL = "not-a-valid-url" +INVALID_EVENTS_STREAM_URL_ERROR = "Invalid events stream URL" + + +class _FakeStreamingResponse: + """Small streaming response stub used for unit testing.""" + + def __init__(self: _FakeStreamingResponse, payload: bytes) -> None: + self._buffer = io.BytesIO(payload) + self.closed = False + + def readline(self: _FakeStreamingResponse) -> bytes: + return self._buffer.readline() + + def close(self: _FakeStreamingResponse) -> None: + self._buffer.close() + self.closed = True + + +def test_open_builds_expected_headers(monkeypatch: pytest.MonkeyPatch) -> None: + """Build auth and SSE headers when opening an event stream.""" + captured: dict[str, object] = {} + + def fake_urlopen(request: object, timeout: float = 30.0) -> object: + request_headers = { + key.lower(): value + for key, value in dict(request.header_items()).items() + } + captured["url"] = request.full_url + captured["timeout"] = timeout + captured["headers"] = request_headers + return _FakeStreamingResponse(payload=PING_PAYLOAD) + + monkeypatch.setattr( + "multisafepay.api.paths.events.stream.event_stream.urlopen", + fake_urlopen, + ) + + stream = EventStream.open( + events_token=EVENTS_TOKEN, + events_stream_url=EVENTS_STREAM_URL, + last_event_id=LAST_EVENT_ID, + timeout=9.5, + ) + event = next(stream) + + assert event.data == "ping" + assert captured["url"] == EVENTS_STREAM_URL + assert captured["timeout"] == 9.5 + headers = captured["headers"] + assert headers["authorization"] == f"Bearer {EVENTS_TOKEN}" + assert headers["accept"] == "text/event-stream" + assert headers["cache-control"] == "no-cache" + assert headers["last-event-id"] == LAST_EVENT_ID + + +def test_parses_sse_event_fields_and_json_data() -> None: + """Parse event name/id/retry and deserialize JSON data payload.""" + payload = ( + b"event: order.updated\n" + b"id: 15\n" + b"retry: 1000\n" + b'data: {"status": "completed", "order_id": "123"}\n\n' + ) + stream = EventStream(response=_FakeStreamingResponse(payload)) + + event = next(stream) + + assert event.event == "order.updated" + assert event.event_id == "15" + assert event.retry == 1000 + assert event.data == {"status": "completed", "order_id": "123"} + + +def test_parses_multiline_text_data() -> None: + """Join multiple data lines with newlines for plain text payloads.""" + payload = b"event: qr\ndata: line one\ndata: line two\n\n" + stream = EventStream(response=_FakeStreamingResponse(payload)) + + event = next(stream) + + assert event.event == "qr" + assert event.raw_data == "line one\nline two" + assert event.data == "line one\nline two" + + +def test_stops_iteration_and_closes_response_on_eof() -> None: + """Stop iteration and close response when stream reaches EOF.""" + response = _FakeStreamingResponse(payload=b"") + stream = EventStream(response=response) + + with pytest.raises(StopIteration): + next(stream) + + assert response.closed is True + assert stream.closed is True + + +def test_rejects_invalid_stream_url() -> None: + """Reject opening streams with invalid URL format.""" + with pytest.raises(ValueError, match=INVALID_EVENTS_STREAM_URL_ERROR): + EventStream.open( + events_token=EVENTS_TOKEN, + events_stream_url=INVALID_EVENTS_STREAM_URL, + ) + + +def test_context_manager_closes_stream_on_exit() -> None: + """Close the underlying stream when context manager exits.""" + response = _FakeStreamingResponse(payload=PING_PAYLOAD) + + with EventStream(response=response) as stream: + assert next(stream).data == "ping" + + assert response.closed is True + assert stream.closed is True diff --git a/tests/multisafepay/unit/api/path/events/test_unit_event_manager.py b/tests/multisafepay/unit/api/path/events/test_unit_event_manager.py new file mode 100644 index 0000000..efbe3d0 --- /dev/null +++ b/tests/multisafepay/unit/api/path/events/test_unit_event_manager.py @@ -0,0 +1,121 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for event manager subscription helpers.""" + +from typing import Optional +from unittest.mock import MagicMock + +import pytest + +from multisafepay.api.paths.events.event_manager import EventManager +from multisafepay.api.paths.orders.response.order_response import Order + +TEST_EVENTS_STREAM_URL = "https://testapi.multisafepay.com/events/stream/" +ORDER_EVENTS_STREAM_URL = "https://stream.example/events/stream/" +LEGACY_EVENTS_STREAM_URL = "https://legacy.example/events/stream/" +MISSING_EVENTS_ERROR = "events_token/events_stream_url" + + +def _patch_event_stream_open( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[dict[str, object], object]: + """Patch EventStream.open and return capture dict plus sentinel stream.""" + captured: dict[str, object] = {} + expected_stream = object() + + def fake_open( + events_token: str, + events_stream_url: str, + last_event_id: Optional[str] = None, + timeout: float = 30.0, + ) -> object: + captured["events_token"] = events_token + captured["events_stream_url"] = events_stream_url + captured["last_event_id"] = last_event_id + captured["timeout"] = timeout + return expected_stream + + monkeypatch.setattr( + "multisafepay.api.paths.events.event_manager.EventStream.open", + fake_open, + ) + + return captured, expected_stream + + +def test_subscribe_events_delegates_to_stream_open( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Delegate direct subscriptions to EventStream.open.""" + captured, expected_stream = _patch_event_stream_open(monkeypatch) + + manager = EventManager(MagicMock()) + stream = manager.subscribe_events( + events_token="token-abc", + events_stream_url=TEST_EVENTS_STREAM_URL, + last_event_id="last-15", + timeout=10.0, + ) + + assert stream is expected_stream + assert captured["events_token"] == "token-abc" + assert captured["events_stream_url"] == TEST_EVENTS_STREAM_URL + assert captured["last_event_id"] == "last-15" + assert captured["timeout"] == 10.0 + + +def test_subscribe_order_events_uses_plural_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Read events credentials from events_* fields when present.""" + captured, expected_stream = _patch_event_stream_open(monkeypatch) + + manager = EventManager(MagicMock()) + order = Order( + order_id="order-1", + events_token="events-token", + events_stream_url=ORDER_EVENTS_STREAM_URL, + ) + + stream = manager.subscribe_order_events(order) + + assert stream is expected_stream + assert captured["events_token"] == "events-token" + assert captured["events_stream_url"] == ORDER_EVENTS_STREAM_URL + + +def test_subscribe_order_events_falls_back_to_legacy_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Support old event_* field names for backward compatibility.""" + captured, expected_stream = _patch_event_stream_open(monkeypatch) + + manager = EventManager(MagicMock()) + order = Order( + order_id="order-2", + event_token="legacy-token", + event_stream_url=LEGACY_EVENTS_STREAM_URL, + ) + + stream = manager.subscribe_order_events(order) + + assert stream is expected_stream + assert captured["events_token"] == "legacy-token" + assert captured["events_stream_url"] == LEGACY_EVENTS_STREAM_URL + + +def test_subscribe_order_events_requires_token_and_stream_url() -> None: + """Raise a clear error when event credentials are missing in order.""" + manager = EventManager(MagicMock()) + order = Order(order_id="order-3") + + with pytest.raises( + ValueError, + match=MISSING_EVENTS_ERROR, + ): + manager.subscribe_order_events(order) diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py new file mode 100644 index 0000000..3427c3b --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py @@ -0,0 +1,95 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for basic order manager create behavior.""" + +from unittest.mock import MagicMock + +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import CustomApiResponse +from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.api.paths.orders.request.order_request import OrderRequest +from multisafepay.api.paths.orders.response.order_response import Order +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope + +ORDERS_ENDPOINT = "json/orders" +TERMINAL_GROUP_ID = "Default" +SCOPED_ORDER_ID = "cloud-pos-order" +DEFAULT_ORDER_ID = "default-order" + + +def _build_api_response(order_id: str) -> ApiResponse: + """Create a minimal successful ApiResponse for order manager tests.""" + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": order_id, + }, + }, + ) + + +def _build_order_request(order_id: str) -> OrderRequest: + """Create a minimal valid order request used in create() tests.""" + return ( + OrderRequest() + .add_type("direct") + .add_order_id(order_id) + .add_currency("EUR") + .add_amount(100) + ) + + +def test_create_uses_terminal_group_auth_scope_when_provided() -> None: + """Use terminal-group scope only when terminal_group_id is passed.""" + client = MagicMock() + client.create_post_request.return_value = _build_api_response( + SCOPED_ORDER_ID, + ) + request_order = _build_order_request(SCOPED_ORDER_ID) + + manager = OrderManager(client) + response = manager.create( + request_order=request_order, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + called_auth_scope = client.create_post_request.call_args.kwargs["auth_scope"] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert called_endpoint == ORDERS_ENDPOINT + assert called_auth_scope == AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + + +def test_create_omits_auth_scope_when_terminal_group_id_is_not_passed() -> None: + """Do not set auth_scope when create request has no terminal group id.""" + client = MagicMock() + client.create_post_request.return_value = _build_api_response( + DEFAULT_ORDER_ID, + ) + request_order = _build_order_request(DEFAULT_ORDER_ID) + + manager = OrderManager(client) + response = manager.create(request_order=request_order) + + called_endpoint = client.create_post_request.call_args.args[0] + called_auth_scope = client.create_post_request.call_args.kwargs["auth_scope"] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert response.get_data().order_id == DEFAULT_ORDER_ID + assert called_endpoint == ORDERS_ENDPOINT + assert called_auth_scope is None diff --git a/tests/multisafepay/unit/api/path/orders/response/test_unit_order_response.py b/tests/multisafepay/unit/api/path/orders/response/test_unit_order_response.py new file mode 100644 index 0000000..60a5b5c --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/response/test_unit_order_response.py @@ -0,0 +1,74 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for order response event fields compatibility.""" + +from typing import Optional + +from multisafepay.api.paths.orders.response.order_response import Order + +PLURAL_EVENTS_TOKEN = "token-123" +PLURAL_EVENTS_URL = "wss://testapi.multisafepay.com/events/" +PLURAL_EVENTS_STREAM_URL = "https://testapi.multisafepay.com/events/stream/" +LEGACY_EVENTS_TOKEN = "legacy-token" +LEGACY_EVENTS_URL = "wss://legacy.example.com/events/" +LEGACY_EVENTS_STREAM_URL = "https://legacy.example.com/events/stream/" + + +def _assert_event_fields( + order: Optional[Order], + expected_token: str, + expected_url: str, + expected_stream_url: str, +) -> None: + """Assert both plural and legacy event fields are populated consistently.""" + assert order is not None + + assert order.events_token == expected_token + assert order.events_url == expected_url + assert order.events_stream_url == expected_stream_url + assert order.event_token == expected_token + assert order.event_url == expected_url + assert order.event_stream_url == expected_stream_url + + +def test_from_dict_maps_plural_event_fields_to_legacy_aliases() -> None: + """Map events_* fields to both plural and legacy singular attributes.""" + data = { + "order_id": "order-1", + "events_token": PLURAL_EVENTS_TOKEN, + "events_url": PLURAL_EVENTS_URL, + "events_stream_url": PLURAL_EVENTS_STREAM_URL, + } + + order = Order.from_dict(data) + + _assert_event_fields( + order=order, + expected_token=PLURAL_EVENTS_TOKEN, + expected_url=PLURAL_EVENTS_URL, + expected_stream_url=PLURAL_EVENTS_STREAM_URL, + ) + + +def test_from_dict_maps_legacy_event_fields_to_plural_names() -> None: + """Map event_* fields to newer plural names for consistency.""" + data = { + "order_id": "order-2", + "event_token": LEGACY_EVENTS_TOKEN, + "event_url": LEGACY_EVENTS_URL, + "event_stream_url": LEGACY_EVENTS_STREAM_URL, + } + + order = Order.from_dict(data) + + _assert_event_fields( + order=order, + expected_token=LEGACY_EVENTS_TOKEN, + expected_url=LEGACY_EVENTS_URL, + expected_stream_url=LEGACY_EVENTS_STREAM_URL, + ) diff --git a/tests/multisafepay/unit/api/path/terminals/request/test_unit_create_terminal_request.py b/tests/multisafepay/unit/api/path/terminals/request/test_unit_create_terminal_request.py new file mode 100644 index 0000000..3ae2196 --- /dev/null +++ b/tests/multisafepay/unit/api/path/terminals/request/test_unit_create_terminal_request.py @@ -0,0 +1,63 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for the terminal create request model.""" + +import pytest + +from multisafepay.api.paths.terminals.request.create_terminal_request import ( + CTAP_PROVIDER, + CreateTerminalRequest, +) +from multisafepay.exception.invalid_argument import InvalidArgumentException + + +def test_initializes_with_default_values() -> None: + """Initialize request model with empty/default values.""" + request = CreateTerminalRequest() + + assert request.provider is None + assert request.group_id is None + assert request.name is None + + +def test_add_provider_updates_value() -> None: + """Store a valid provider and return the current request object.""" + request = CreateTerminalRequest() + + returned = request.add_provider(CTAP_PROVIDER) + + assert request.provider == CTAP_PROVIDER + assert returned is request + + +def test_add_provider_raises_for_invalid_provider() -> None: + """Reject provider values that are not whitelisted.""" + request = CreateTerminalRequest() + + with pytest.raises(InvalidArgumentException, match="not a known provider"): + request.add_provider("UNKNOWN") + + +def test_add_group_id_updates_value() -> None: + """Store terminal group id and return current request object.""" + request = CreateTerminalRequest() + + returned = request.add_group_id("1234") + + assert request.group_id == "1234" + assert returned is request + + +def test_add_name_updates_value() -> None: + """Store terminal display name and return current request object.""" + request = CreateTerminalRequest() + + returned = request.add_name("Demo POS Terminal") + + assert request.name == "Demo POS Terminal" + assert returned is request diff --git a/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py b/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py new file mode 100644 index 0000000..a866b04 --- /dev/null +++ b/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py @@ -0,0 +1,96 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for the terminal response model.""" + +from multisafepay.api.paths.terminals.response.terminal import Terminal + +TERMINAL_DATA = { + "id": "term-001", + "provider": "CTAP", + "name": "My Terminal", + "code": "T001", + "created": "2024-01-01T00:00:00", + "last_updated": "2024-06-01T00:00:00", + "manufacturer_id": "MFR-123", + "serial_number": "SN-456", + "active": True, + "group_id": 12345, + "country": "NL", +} + +EMPTY_TERMINAL_DATA = { + field: None for field in TERMINAL_DATA +} + + +def _assert_terminal_data(terminal: Terminal, expected: dict) -> None: + """Assert terminal attributes against expected fixture data.""" + for field, expected_value in expected.items(): + assert getattr(terminal, field) == expected_value + + +def test_initializes_with_all_fields(): + """ + Test that the Terminal object initializes correctly with all fields. + + This test verifies that the Terminal object stores the correct values for + all its attributes when instantiated with explicit data. + """ + terminal = Terminal(**TERMINAL_DATA) + + _assert_terminal_data(terminal, TERMINAL_DATA) + + +def test_initializes_with_none_values(): + """ + Test that the Terminal object initializes correctly with None values. + + This test verifies that all attributes default to None when the Terminal + object is instantiated without any arguments. + """ + terminal = Terminal() + + _assert_terminal_data(terminal, EMPTY_TERMINAL_DATA) + + +def test_from_dict_creates_instance_from_dict(): + """ + Test that the from_dict method creates a Terminal from a valid dictionary. + + This test verifies that from_dict correctly maps all dictionary keys to + the corresponding Terminal attributes. + """ + terminal: Terminal | None = Terminal.from_dict(TERMINAL_DATA) + + assert terminal is not None + _assert_terminal_data(terminal, TERMINAL_DATA) + + +def test_from_dict_returns_none_for_none_input(): + """ + Test that the from_dict method returns None when the input is None. + + This test verifies that from_dict returns None when None is provided + as the input dictionary. + """ + terminal = Terminal.from_dict(None) + assert terminal is None + + +def test_from_dict_handles_missing_fields(): + """ + Test that the from_dict method handles missing fields by setting them to None. + + This test verifies that from_dict correctly creates a Terminal from a + dictionary with missing fields, resulting in None values for those attributes. + """ + data = {} + terminal: Terminal | None = Terminal.from_dict(data) + + assert terminal is not None + _assert_terminal_data(terminal, EMPTY_TERMINAL_DATA) diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 92af510..bf2bfc7 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -11,10 +11,62 @@ import pytest from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ( + AuthScope, + ScopedCredentialResolver, +) from multisafepay.transport import RequestsTransport requests = pytest.importorskip("requests") +DEFAULT_API_KEY = "default_api_key" +TERMINAL_GROUP_ID = "Default" +TERMINAL_GROUP_API_KEY = "terminal_group_api_key" +ORDERS_ENDPOINT = "json/orders" +API_KEY_REQUIRED_ERROR = "api_key is required" + + +class _FakeResponse: + """Small HTTP response stub for unit tests.""" + + status_code = 200 + headers = {} + + @staticmethod + def json() -> dict: + return { + "success": True, + "data": {}, + } + + @staticmethod + def raise_for_status() -> None: + return + + +class _CaptureTransport: + """Transport stub that captures the request headers.""" + + def __init__(self: "_CaptureTransport") -> None: + self.headers = {} + + def request(self: "_CaptureTransport", **kwargs: dict) -> _FakeResponse: + self.headers = kwargs.get("headers", {}) + return _FakeResponse() + + +def _build_resolver_client( + resolver: ScopedCredentialResolver, + transport: _CaptureTransport, +) -> Client: + """Build a client configured for resolver-based auth tests.""" + return Client( + api_key=None, + is_production=False, + transport=transport, + credential_resolver=resolver, + ) + def test_initializes_with_default_requests_transport(): """Test that the Client initializes with the default requests transport.""" diff --git a/tests/multisafepay/unit/client/test_unit_credential_resolver.py b/tests/multisafepay/unit/client/test_unit_credential_resolver.py new file mode 100644 index 0000000..2d9d2b7 --- /dev/null +++ b/tests/multisafepay/unit/client/test_unit_credential_resolver.py @@ -0,0 +1,96 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for scoped credential resolver behavior.""" + +import pytest + +from multisafepay.client.credential_resolver import ScopedCredentialResolver + +DEFAULT_API_KEY = "default_api_key" +PARTNER_API_KEY = "partner_api_key" +TERMINAL_GROUP_ID = "Default" +TERMINAL_GROUP_API_KEY = "terminal_group_api_key" +MISSING_GROUP_ID_ERROR = "Missing terminal_group_id" +NO_DEFAULT_SCOPE_ERROR = "No API key configured for default scope" + + +def _resolver_with_terminal_group() -> ScopedCredentialResolver: + """Create resolver fixture data for terminal-group scope tests.""" + return ScopedCredentialResolver( + default_api_key=DEFAULT_API_KEY, + terminal_group_api_keys={ + TERMINAL_GROUP_ID: TERMINAL_GROUP_API_KEY, + }, + ) + + +def test_rejects_resolver_without_any_api_key() -> None: + """Require at least one API key across all resolver sources.""" + with pytest.raises( + ValueError, + match="requires at least one API key", + ): + ScopedCredentialResolver(default_api_key="") + + +def test_resolves_default_scope_with_default_api_key() -> None: + """Resolve default scope using the configured default API key.""" + resolver = ScopedCredentialResolver(default_api_key=DEFAULT_API_KEY) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) + == DEFAULT_API_KEY + ) + + +def test_resolves_partner_scope_with_partner_api_key() -> None: + """Prefer partner key for partner_affiliate scope.""" + resolver = ScopedCredentialResolver( + default_api_key=DEFAULT_API_KEY, + partner_affiliate_api_key=PARTNER_API_KEY, + ) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE) + == PARTNER_API_KEY + ) + + +def test_resolves_terminal_group_scope_with_group_key() -> None: + """Resolve terminal_group scope using group-specific API key mapping.""" + resolver = _resolver_with_terminal_group() + + assert ( + resolver.resolve( + ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + == TERMINAL_GROUP_API_KEY + ) + + +def test_raises_for_terminal_group_scope_without_group_id() -> None: + """Reject terminal_group scope when group_id is missing.""" + resolver = _resolver_with_terminal_group() + + with pytest.raises(ValueError, match=MISSING_GROUP_ID_ERROR): + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP) + + +def test_raises_for_default_scope_without_default_key() -> None: + """Reject default scope when no default key is configured.""" + resolver = ScopedCredentialResolver( + default_api_key="", + partner_affiliate_api_key=PARTNER_API_KEY, + ) + + with pytest.raises( + ValueError, + match=NO_DEFAULT_SCOPE_ERROR, + ): + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) diff --git a/tests/multisafepay/unit/test_unit_sdk.py b/tests/multisafepay/unit/test_unit_sdk.py index 038338e..01ec604 100644 --- a/tests/multisafepay/unit/test_unit_sdk.py +++ b/tests/multisafepay/unit/test_unit_sdk.py @@ -7,10 +7,45 @@ """Unit tests for SDK-level environment/base URL guardrails.""" +from unittest.mock import MagicMock + import pytest from multisafepay import Sdk +from multisafepay.api.paths.events.event_manager import EventManager from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ScopedCredentialResolver + +DEFAULT_API_KEY = "resolver_api_key" + + +class _FakeResponse: + """Small HTTP response stub for SDK transport tests.""" + + status_code = 200 + headers = {} + + @staticmethod + def json() -> dict: + return { + "success": True, + "data": {}, + } + + @staticmethod + def raise_for_status() -> None: + return + + +class _CaptureTransport: + """Transport stub that captures outbound request headers.""" + + def __init__(self: "_CaptureTransport") -> None: + self.headers = {} + + def request(self: "_CaptureTransport", **kwargs: dict) -> _FakeResponse: + self.headers = kwargs.get("headers", {}) + return _FakeResponse() def test_sdk_uses_test_url_by_default(monkeypatch: pytest.MonkeyPatch): @@ -64,3 +99,49 @@ def test_sdk_blocks_custom_base_url_in_release( is_production=False, base_url="https://dev-api.multisafepay.test/v1", ) + + +def test_sdk_allows_resolver_only_initialization() -> None: + """Allow constructing SDK without api_key when resolver is provided.""" + resolver = ScopedCredentialResolver(default_api_key="resolver_api_key") + + sdk = Sdk( + is_production=False, + credential_resolver=resolver, + ) + + assert sdk.get_client().url == Client.TEST_URL + + +def test_sdk_requires_api_key_or_resolver() -> None: + """Reject SDK initialization when both api_key and resolver are missing.""" + with pytest.raises(ValueError, match="api_key is required"): + Sdk(is_production=False) + + +def test_sdk_returns_event_manager() -> None: + """Expose EventManager through SDK convenience getter.""" + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + transport=MagicMock(), + ) + + assert isinstance(sdk.get_event_manager(), EventManager) + + +def test_sdk_uses_credential_resolver_with_custom_transport() -> None: + """Wire resolver + transport together and use resolved auth header.""" + transport = _CaptureTransport() + resolver = ScopedCredentialResolver(default_api_key=DEFAULT_API_KEY) + + sdk = Sdk( + is_production=False, + transport=transport, + credential_resolver=resolver, + ) + + sdk.get_client().create_get_request("json/orders") + + assert sdk.get_client().transport is transport + assert transport.headers["Authorization"] == f"Bearer {DEFAULT_API_KEY}" From e3c412126bfe66532044b3421c43b9990cfa7c09 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Mon, 13 Apr 2026 14:46:54 +0200 Subject: [PATCH 2/3] PTHMINT-108: Apply baseline formatting and lint cleanup --- .../api/paths/events/event_manager.py | 4 +--- .../api/paths/events/stream/__init__.py | 5 ++++- .../api/paths/events/stream/event_stream.py | 3 +++ .../api/paths/orders/order_manager.py | 14 ++++++++------ src/multisafepay/client/credential_resolver.py | 4 ++-- .../orders/manager/test_unit_order_manager.py | 16 ++++++++++++---- .../response/test_unit_terminal_response.py | 4 +--- 7 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/multisafepay/api/paths/events/event_manager.py b/src/multisafepay/api/paths/events/event_manager.py index 6c98547..e4000af 100644 --- a/src/multisafepay/api/paths/events/event_manager.py +++ b/src/multisafepay/api/paths/events/event_manager.py @@ -72,9 +72,7 @@ def subscribe_order_events( """ events_token = order.events_token or order.event_token - events_stream_url = ( - order.events_stream_url or order.event_stream_url - ) + events_stream_url = order.events_stream_url or order.event_stream_url if not events_token or not events_stream_url: raise ValueError( diff --git a/src/multisafepay/api/paths/events/stream/__init__.py b/src/multisafepay/api/paths/events/stream/__init__.py index 21fa7fd..202ddf2 100644 --- a/src/multisafepay/api/paths/events/stream/__init__.py +++ b/src/multisafepay/api/paths/events/stream/__init__.py @@ -1,6 +1,9 @@ """Event stream API helpers and manager.""" -from multisafepay.api.paths.events.stream.event_stream import Event, EventStream +from multisafepay.api.paths.events.stream.event_stream import ( + Event, + EventStream, +) __all__ = [ "Event", diff --git a/src/multisafepay/api/paths/events/stream/event_stream.py b/src/multisafepay/api/paths/events/stream/event_stream.py index a47e1a6..380e211 100644 --- a/src/multisafepay/api/paths/events/stream/event_stream.py +++ b/src/multisafepay/api/paths/events/stream/event_stream.py @@ -124,7 +124,10 @@ def open( headers=headers, method="GET", ) + # Keep the response open; EventStream.close manages the lifecycle. + # pylint: disable=consider-using-with response = urlopen(request, timeout=timeout) # noqa: S310 + # pylint: enable=consider-using-with return cls(response=response) diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index 98d870e..5113e62 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -135,12 +135,14 @@ def create( response: ApiResponse = self.client.create_post_request( "json/orders", request_body=json_data, - auth_scope=AuthScope( - scope=Client.AUTH_SCOPE_TERMINAL_GROUP, - group_id=terminal_group_id, - ) - if terminal_group_id - else None, + auth_scope=( + AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None + ), ) return OrderManager.__custom_api_response(response) diff --git a/src/multisafepay/client/credential_resolver.py b/src/multisafepay/client/credential_resolver.py index a9119a2..860bcaa 100644 --- a/src/multisafepay/client/credential_resolver.py +++ b/src/multisafepay/client/credential_resolver.py @@ -56,8 +56,8 @@ def __init__( """ self.default_api_key = (default_api_key or "").strip() self.partner_affiliate_api_key = ( - (partner_affiliate_api_key or "").strip() or None - ) + partner_affiliate_api_key or "" + ).strip() or None self.terminal_group_api_keys = { group_id: api_key.strip() for group_id, api_key in (terminal_group_api_keys or {}).items() diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py index 3427c3b..50eba9f 100644 --- a/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.py @@ -10,7 +10,9 @@ from unittest.mock import MagicMock from multisafepay.api.base.response.api_response import ApiResponse -from multisafepay.api.base.response.custom_api_response import CustomApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) from multisafepay.api.paths.orders.order_manager import OrderManager from multisafepay.api.paths.orders.request.order_request import OrderRequest from multisafepay.api.paths.orders.response.order_response import Order @@ -63,7 +65,9 @@ def test_create_uses_terminal_group_auth_scope_when_provided() -> None: ) called_endpoint = client.create_post_request.call_args.args[0] - called_auth_scope = client.create_post_request.call_args.kwargs["auth_scope"] + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] assert isinstance(response, CustomApiResponse) assert isinstance(response.get_data(), Order) @@ -74,7 +78,9 @@ def test_create_uses_terminal_group_auth_scope_when_provided() -> None: ) -def test_create_omits_auth_scope_when_terminal_group_id_is_not_passed() -> None: +def test_create_omits_auth_scope_when_terminal_group_id_is_not_passed() -> ( + None +): """Do not set auth_scope when create request has no terminal group id.""" client = MagicMock() client.create_post_request.return_value = _build_api_response( @@ -86,7 +92,9 @@ def test_create_omits_auth_scope_when_terminal_group_id_is_not_passed() -> None: response = manager.create(request_order=request_order) called_endpoint = client.create_post_request.call_args.args[0] - called_auth_scope = client.create_post_request.call_args.kwargs["auth_scope"] + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] assert isinstance(response, CustomApiResponse) assert isinstance(response.get_data(), Order) diff --git a/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py b/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py index a866b04..536164a 100644 --- a/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py +++ b/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py @@ -23,9 +23,7 @@ "country": "NL", } -EMPTY_TERMINAL_DATA = { - field: None for field in TERMINAL_DATA -} +EMPTY_TERMINAL_DATA = {field: None for field in TERMINAL_DATA} def _assert_terminal_data(terminal: Terminal, expected: dict) -> None: From 285d51699510abc4b9650957acff1e0d35903cfe Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Mon, 13 Apr 2026 14:55:51 +0200 Subject: [PATCH 3/3] PTHMINT-108: Make E2E base URL dynamic and stabilize URL tests --- tests/multisafepay/unit/test_unit_sdk.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/multisafepay/unit/test_unit_sdk.py b/tests/multisafepay/unit/test_unit_sdk.py index 01ec604..a7fa84c 100644 --- a/tests/multisafepay/unit/test_unit_sdk.py +++ b/tests/multisafepay/unit/test_unit_sdk.py @@ -101,8 +101,14 @@ def test_sdk_blocks_custom_base_url_in_release( ) -def test_sdk_allows_resolver_only_initialization() -> None: +def test_sdk_allows_resolver_only_initialization( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Allow constructing SDK without api_key when resolver is provided.""" + monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False) + monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False) + resolver = ScopedCredentialResolver(default_api_key="resolver_api_key") sdk = Sdk(