From f197d99bdba6322527a31e1e2f9c0c4ef33d4821 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Fri, 17 Apr 2026 12:51:59 +0200 Subject: [PATCH 1/3] PTHMINT-108: Add POS APIs and strict feature E2E support Add scoped auth API surface and add strict Cloud POS/terminal E2E setup with dedicated docs/env configuration. --- .env.example | 14 ++ README.md | 36 +++ 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 | 12 + src/multisafepay/api/paths/events/__init__.py | 7 + .../api/paths/events/event_manager.py | 87 +++++++ .../api/paths/events/stream/__init__.py | 11 + .../api/paths/events/stream/event_stream.py | 218 ++++++++++++++++++ .../paths/orders/order_id/cancel/__init__.py | 1 + .../order_id/cancel/request/__init__.py | 9 + .../request/cancel_transaction_request.py | 42 ++++ .../order_id/cancel/response/__init__.py | 9 + .../cancel/response/cancel_transaction.py | 79 +++++++ .../api/paths/orders/order_manager.py | 83 +++++++ .../paths/orders/response/order_response.py | 27 ++- src/multisafepay/api/paths/pos/__init__.py | 7 + src/multisafepay/api/paths/pos/pos_manager.py | 89 +++++++ .../api/paths/pos/receipt/__init__.py | 1 + .../paths/pos/receipt/response/__init__.py | 7 + .../receipt/response/components/__init__.py | 21 ++ .../receipt/response/components/merchant.py | 26 +++ .../pos/receipt/response/components/order.py | 109 +++++++++ .../receipt/response/components/payment.py | 36 +++ .../components/related_transactions.py | 36 +++ .../api/paths/pos/receipt/response/receipt.py | 78 +++++++ .../api/paths/terminal_groups/__init__.py | 9 + .../terminal_groups/terminal_group_manager.py | 107 +++++++++ .../api/paths/terminals/__init__.py | 9 + .../api/paths/terminals/request/__init__.py | 9 + .../request/create_terminal_request.py | 103 +++++++++ .../api/paths/terminals/response/__init__.py | 7 + .../api/paths/terminals/response/terminal.py | 63 +++++ .../api/paths/terminals/terminal_manager.py | 144 ++++++++++++ src/multisafepay/client/__init__.py | 2 + src/multisafepay/client/client.py | 69 +++++- .../client/credential_resolver.py | 106 +++++++++ src/multisafepay/sdk.py | 76 +++++- tests/multisafepay/e2e/conftest.py | 205 +++++++++++++++- .../e2e/examples/order_manager/test_cancel.py | 86 +++++++ .../order_manager/test_cloud_pos_order.py | 80 +++++++ .../test_get_terminals_by_group.py | 44 ++++ .../terminal_manager/test_get_terminals.py | 38 +++ .../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 | 103 +++++++++ .../response/test_unit_order_response.py | 74 ++++++ .../test_unit_create_terminal_request.py | 63 +++++ .../response/test_unit_terminal_response.py | 94 ++++++++ .../unit/client/test_unit_client.py | 51 ++++ .../client/test_unit_credential_resolver.py | 96 ++++++++ tests/multisafepay/unit/test_unit_sdk.py | 87 +++++++ 58 files changed, 3606 insertions(+), 20 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/orders/order_id/cancel/__init__.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py create mode 100644 src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py create mode 100644 src/multisafepay/api/paths/pos/__init__.py create mode 100644 src/multisafepay/api/paths/pos/pos_manager.py create mode 100644 src/multisafepay/api/paths/pos/receipt/__init__.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/__init__.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/__init__.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/merchant.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/order.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/payment.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py create mode 100644 src/multisafepay/api/paths/pos/receipt/response/receipt.py create mode 100644 src/multisafepay/api/paths/terminal_groups/__init__.py create mode 100644 src/multisafepay/api/paths/terminal_groups/terminal_group_manager.py create mode 100644 src/multisafepay/api/paths/terminals/__init__.py create mode 100644 src/multisafepay/api/paths/terminals/request/__init__.py create mode 100644 src/multisafepay/api/paths/terminals/request/create_terminal_request.py create mode 100644 src/multisafepay/api/paths/terminals/response/__init__.py create mode 100644 src/multisafepay/api/paths/terminals/response/terminal.py create mode 100644 src/multisafepay/api/paths/terminals/terminal_manager.py create mode 100644 src/multisafepay/client/credential_resolver.py create mode 100644 tests/multisafepay/e2e/examples/order_manager/test_cancel.py create mode 100644 tests/multisafepay/e2e/examples/order_manager/test_cloud_pos_order.py create mode 100644 tests/multisafepay/e2e/examples/terminal_group_manager/test_get_terminals_by_group.py create mode 100644 tests/multisafepay/e2e/examples/terminal_manager/test_get_terminals.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..b6420bb 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,17 @@ API_KEY= E2E_API_KEY= E2E_BASE_URL=https://testapi.multisafepay.com/v1/ +# Temporary override for strict feature examples not yet supported on testapi +E2E_NO_SANDBOX_BASE_URL= +PARTNER_API_KEY= +CLOUD_POS_TERMINAL_GROUP_ID= + +# Dedicated env vars for strict feature-specific E2E tests (temporary split) +E2E_PARTNER_API_KEY= +E2E_CLOUD_POS_TERMINAL_ID= +E2E_TERMINAL_GROUP_API_KEY_GROUP_DEFAULT= + +# 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/README.md b/README.md index e30a317..67ebbcf 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,45 @@ make test-e2e `E2E_BASE_URL` is optional and can point to any HTTPS base URL used for E2E. When omitted, E2E defaults to `testapi.multisafepay.com`. +For strict feature-specific examples (Cloud POS and terminal endpoints), you can +set a separate URL with `E2E_NO_SANDBOX_BASE_URL`. If omitted, those tests reuse +`E2E_BASE_URL`. + +This split is a temporary workaround: some feature-specific flows are not yet +supported on the default `testapi` environment. + The e2e suite does not use the shared `API_KEY` variable or the shared `MSP_SDK_*` custom base URL settings. +### Strict feature-specific E2E credentials + +Cloud POS and terminal example E2E tests use a strict, isolated credential set. If +any required variable is missing, those tests fail fast during setup. + +These tests are intentionally isolated because they currently depend on +features that may not be available in `testapi` yet. + +`terminal_group_id` is resolved automatically from `/json/terminals` using +`E2E_CLOUD_POS_TERMINAL_ID`. + +```bash +export E2E_API_KEY="m_" +export E2E_NO_SANDBOX_BASE_URL="https://api.dev.multisafepay.com/v1/" +export E2E_PARTNER_API_KEY="" +export E2E_CLOUD_POS_TERMINAL_ID="" +export E2E_TERMINAL_GROUP_API_KEY_GROUP_DEFAULT="" +``` + +You can run only the strict feature-specific examples with: + +```bash +poetry run pytest \ + tests/multisafepay/e2e/examples/terminal_manager/test_get_terminals.py \ + tests/multisafepay/e2e/examples/terminal_group_manager/test_get_terminals_by_group.py \ + tests/multisafepay/e2e/examples/order_manager/test_cloud_pos_order.py \ + tests/multisafepay/e2e/examples/order_manager/test_cancel.py -q +``` + ## Support Create an issue on this repository or email 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..951201c 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 @@ -12,9 +13,16 @@ from multisafepay.api.paths.payment_methods.payment_method_manager import ( PaymentMethodManager, ) +from multisafepay.api.paths.pos.pos_manager import PosManager from multisafepay.api.paths.recurring.recurring_manager import ( RecurringManager, ) +from multisafepay.api.paths.terminal_groups.terminal_group_manager import ( + TerminalGroupManager, +) +from multisafepay.api.paths.terminals.terminal_manager import ( + TerminalManager, +) from multisafepay.api.paths.transactions.transaction_manager import ( TransactionManager, ) @@ -23,11 +31,15 @@ "AuthManager", "CaptureManager", "CategoryManager", + "EventManager", "GatewayManager", "IssuerManager", "MeManager", "OrderManager", "PaymentMethodManager", + "PosManager", "RecurringManager", + "TerminalGroupManager", + "TerminalManager", "TransactionManager", ] 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..e4000af --- /dev/null +++ b/src/multisafepay/api/paths/events/event_manager.py @@ -0,0 +1,87 @@ +# 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..202ddf2 --- /dev/null +++ b/src/multisafepay/api/paths/events/stream/__init__.py @@ -0,0 +1,11 @@ +"""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..380e211 --- /dev/null +++ b/src/multisafepay/api/paths/events/stream/event_stream.py @@ -0,0 +1,218 @@ +# 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", + ) + # 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) + + @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_id/cancel/__init__.py b/src/multisafepay/api/paths/orders/order_id/cancel/__init__.py new file mode 100644 index 0000000..9d4f4ae --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/__init__.py @@ -0,0 +1 @@ +"""Cancel operations and endpoints for specific orders.""" diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py b/src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py new file mode 100644 index 0000000..c3ffb89 --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/request/__init__.py @@ -0,0 +1,9 @@ +"""Request models for order cancellation operations.""" + +from multisafepay.api.paths.orders.order_id.cancel.request.cancel_transaction_request import ( + CancelTransactionRequest, +) + +__all__ = [ + "CancelTransactionRequest", +] diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py b/src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py new file mode 100644 index 0000000..d477b69 --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/request/cancel_transaction_request.py @@ -0,0 +1,42 @@ +# 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 cancel transaction endpoint.""" + +from multisafepay.model.request_model import RequestModel + + +class CancelTransactionRequest(RequestModel): + """ + Represents a request to cancel a POS transaction. + + Attributes + ---------- + order_id (str): The order identifier used in the endpoint path. + + """ + + order_id: str + + def add_order_id( + self: "CancelTransactionRequest", + order_id: str, + ) -> "CancelTransactionRequest": + """ + Adds order id to the cancellation request. + + Parameters + ---------- + order_id (str): The order identifier. + + Returns + ------- + CancelTransactionRequest: The updated request instance. + + """ + self.order_id = order_id + return self diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py b/src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py new file mode 100644 index 0000000..bf2da9e --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/response/__init__.py @@ -0,0 +1,9 @@ +"""Response models for order cancellation outcomes.""" + +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) + +__all__ = [ + "CancelTransaction", +] diff --git a/src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py b/src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py new file mode 100644 index 0000000..ff77591 --- /dev/null +++ b/src/multisafepay/api/paths/orders/order_id/cancel/response/cancel_transaction.py @@ -0,0 +1,79 @@ +# 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. + +"""Response model for order cancellation endpoint payload.""" + +from typing import Optional + +from multisafepay.api.base.decorator import Decorator +from multisafepay.api.paths.orders.response.components.payment_details import ( + PaymentDetails, +) +from multisafepay.api.shared.costs import Costs +from multisafepay.api.shared.custom_info import CustomInfo +from multisafepay.api.shared.customer import Customer +from multisafepay.api.shared.payment_method import PaymentMethod +from multisafepay.model.response_model import ResponseModel + + +class CancelTransaction(ResponseModel): + """ + Represents the `data` payload returned by cancel order transaction. + + Attributes + ---------- + costs (Optional[list[Costs]]): The costs of the order. + created (Optional[str]): Creation timestamp. + modified (Optional[str]): Last modification timestamp. + custom_info (Optional[CustomInfo]): Additional custom info. + customer (Optional[Customer]): The customer data. + fastcheckout (Optional[str]): Fastcheckout flag/status. + financial_status (Optional[str]): Financial status. + items (Optional[str]): Rendered items payload. + payment_details (Optional[PaymentDetails]): Payment details. + payment_methods (Optional[list[PaymentMethod]]): Payment methods. + status (Optional[str]): Order status. + + """ + + costs: Optional[list[Costs]] + created: Optional[str] + modified: Optional[str] + custom_info: Optional[CustomInfo] + customer: Optional[Customer] + fastcheckout: Optional[str] + financial_status: Optional[str] + items: Optional[str] + payment_details: Optional[PaymentDetails] + payment_methods: Optional[list[PaymentMethod]] + status: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["CancelTransaction"]: + """ + Create a CancelTransaction from dictionary data. + + Parameters + ---------- + d (dict): The cancellation response data. + + Returns + ------- + Optional[CancelTransaction]: A cancellation response instance or None. + + """ + if d is None: + return None + cancel_dependency_adapter = Decorator(dependencies=d) + dependencies = ( + cancel_dependency_adapter.adapt_costs(d.get("costs")) + .adapt_custom_info(d.get("custom_info")) + .adapt_payment_details(d.get("payment_details")) + .adapt_payment_methods(d.get("payment_methods")) + .get_dependencies() + ) + return CancelTransaction(**dependencies) diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index 9ae9092..e9d9fbe 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -15,6 +15,12 @@ from multisafepay.api.base.response.custom_api_response import ( CustomApiResponse, ) +from multisafepay.api.paths.orders.order_id.cancel.request.cancel_transaction_request import ( + CancelTransactionRequest, +) +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) from multisafepay.api.paths.orders.order_id.capture.request.capture_request import ( CaptureOrderRequest, ) @@ -38,6 +44,7 @@ from multisafepay.api.shared.cart.shopping_cart import ShoppingCart from multisafepay.api.shared.description import Description from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope from multisafepay.util.dict_utils import dict_empty from multisafepay.util.json_encoder import DecimalEncoder from multisafepay.util.message import MessageList, gen_could_not_created_msg @@ -90,6 +97,26 @@ def __custom_api_response(response: ApiResponse) -> CustomApiResponse: return CustomApiResponse(**args) + @staticmethod + def __custom_cancel_transaction_response( + response: ApiResponse, + ) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + if not dict_empty(response.get_body_data()): + try: + args["data"] = CancelTransaction.from_dict( + d=response.get_body_data().copy(), + ) + except ValidationError: + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("CancelTransaction"), + ) + + return CustomApiResponse(**args) + def get(self: "OrderManager", order_id: str) -> CustomApiResponse: """ Retrieve an order by its ID. @@ -115,6 +142,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 +150,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 +162,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 + ), ) return OrderManager.__custom_api_response(response) @@ -246,6 +284,51 @@ def refund( return CustomApiResponse(**args) + def cancel_transaction( + self: "OrderManager", + cancel_transaction_request: Union[ + CancelTransactionRequest, + str, + ], + terminal_group_id: str = None, + ) -> CustomApiResponse: + """ + Cancel a POS transaction by order id. + + Parameters + ---------- + cancel_transaction_request (CancelTransactionRequest | str): + Request object or direct order identifier. + terminal_group_id (str): Optional terminal group identifier for + scoped auth resolution. + + Returns + ------- + CustomApiResponse: The cancellation response. + + """ + order_id = ( + cancel_transaction_request + if isinstance(cancel_transaction_request, str) + else cancel_transaction_request.order_id + ) + encoded_order_id = self.encode_path_segment(order_id) + endpoint = f"json/orders/{encoded_order_id}/cancel" + context = {"order_id": order_id} + response = self.client.create_post_request( + endpoint, + context=context, + auth_scope=( + AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None + ), + ) + return OrderManager.__custom_cancel_transaction_response(response) + def refund_by_item( self: "OrderManager", order: Order, 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/pos/__init__.py b/src/multisafepay/api/paths/pos/__init__.py new file mode 100644 index 0000000..1619e50 --- /dev/null +++ b/src/multisafepay/api/paths/pos/__init__.py @@ -0,0 +1,7 @@ +"""Point of Sale API endpoints.""" + +from multisafepay.api.paths.pos.pos_manager import PosManager + +__all__ = [ + "PosManager", +] diff --git a/src/multisafepay/api/paths/pos/pos_manager.py b/src/multisafepay/api/paths/pos/pos_manager.py new file mode 100644 index 0000000..7bf3dce --- /dev/null +++ b/src/multisafepay/api/paths/pos/pos_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. + +"""POS manager for `/json/pos/...` endpoints.""" + +from multisafepay.api.base.abstract_manager import AbstractManager +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.pos.receipt.response.receipt import Receipt +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope +from multisafepay.util.dict_utils import dict_empty +from multisafepay.util.message import MessageList, gen_could_not_created_msg +from pydantic import ValidationError + + +class PosManager(AbstractManager): + """A class representing the PosManager.""" + + def __init__(self: "PosManager", client: Client) -> None: + """ + Initialize the PosManager with a client. + + Parameters + ---------- + client (Client): The client used to make API requests. + + """ + super().__init__(client) + + @staticmethod + def __custom_receipt_response(response: ApiResponse) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + if not dict_empty(response.get_body_data()): + try: + args["data"] = Receipt.from_dict( + d=response.get_body_data().copy(), + ) + except ValidationError: + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("Receipt"), + ) + + return CustomApiResponse(**args) + + def get_receipt( + self: "PosManager", + order_id: str, + terminal_group_id: str = None, + ) -> CustomApiResponse: + """ + Retrieve receipt data for a POS transaction. + + Parameters + ---------- + order_id (str): Order identifier. + terminal_group_id (str): Optional terminal group identifier for + scoped auth. + + Returns + ------- + CustomApiResponse: The response containing receipt data. + + """ + encoded_order_id = self.encode_path_segment(order_id) + endpoint = f"json/pos/receipt/{encoded_order_id}" + context = {"order_id": order_id} + response = self.client.create_get_request( + endpoint, + context=context, + auth_scope=( + AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=terminal_group_id, + ) + if terminal_group_id + else None + ), + ) + return PosManager.__custom_receipt_response(response) diff --git a/src/multisafepay/api/paths/pos/receipt/__init__.py b/src/multisafepay/api/paths/pos/receipt/__init__.py new file mode 100644 index 0000000..5912702 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/__init__.py @@ -0,0 +1 @@ +"""POS receipt endpoint models.""" diff --git a/src/multisafepay/api/paths/pos/receipt/response/__init__.py b/src/multisafepay/api/paths/pos/receipt/response/__init__.py new file mode 100644 index 0000000..47fbf9a --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/__init__.py @@ -0,0 +1,7 @@ +"""Response models for POS receipt endpoint.""" + +from multisafepay.api.paths.pos.receipt.response.receipt import Receipt + +__all__ = [ + "Receipt", +] diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/__init__.py b/src/multisafepay/api/paths/pos/receipt/response/components/__init__.py new file mode 100644 index 0000000..a32722b --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/__init__.py @@ -0,0 +1,21 @@ +"""Component models for POS receipt response payload.""" + +from multisafepay.api.paths.pos.receipt.response.components.merchant import ( + ReceiptMerchant, +) +from multisafepay.api.paths.pos.receipt.response.components.order import ( + ReceiptOrder, +) +from multisafepay.api.paths.pos.receipt.response.components.payment import ( + ReceiptPayment, +) +from multisafepay.api.paths.pos.receipt.response.components.related_transactions import ( + ReceiptRelatedTransactions, +) + +__all__ = [ + "ReceiptMerchant", + "ReceiptOrder", + "ReceiptPayment", + "ReceiptRelatedTransactions", +] diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/merchant.py b/src/multisafepay/api/paths/pos/receipt/response/components/merchant.py new file mode 100644 index 0000000..5ee703c --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/merchant.py @@ -0,0 +1,26 @@ +# 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. + +"""Merchant section for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptMerchant(ResponseModel): + """Merchant information included in receipt data.""" + + name: Optional[str] + address: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptMerchant"]: + """Create a ReceiptMerchant model from dictionary data.""" + if d is None: + return None + return ReceiptMerchant(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/order.py b/src/multisafepay/api/paths/pos/receipt/response/components/order.py new file mode 100644 index 0000000..0fd81e8 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/order.py @@ -0,0 +1,109 @@ +# 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. + +"""Order section models for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptOrderItem(ResponseModel): + """Represents one printed order item on the receipt.""" + + currency: Optional[str] + item_price: Optional[int] + name: Optional[str] + quantity: Optional[int] + unit_price: Optional[int] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrderItem"]: + """Create a ReceiptOrderItem from dictionary data.""" + if d is None: + return None + return ReceiptOrderItem(**d) + + +class ReceiptOrderTipEmployee(ResponseModel): + """Employee info for receipt tip section.""" + + id: Optional[str] + name: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrderTipEmployee"]: + """Create a ReceiptOrderTipEmployee from dictionary data.""" + if d is None: + return None + return ReceiptOrderTipEmployee(**d) + + +class ReceiptOrderTip(ResponseModel): + """Tip information on the printed receipt.""" + + amount: Optional[int] + employee: Optional[list[ReceiptOrderTipEmployee]] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrderTip"]: + """Create a ReceiptOrderTip from dictionary data.""" + if d is None: + return None + + args = d.copy() + employee_data = d.get("employee") + if isinstance(employee_data, list): + args["employee"] = [ + ReceiptOrderTipEmployee.from_dict(employee) + for employee in employee_data + if isinstance(employee, dict) + ] + + return ReceiptOrderTip(**args) + + +class ReceiptOrder(ResponseModel): + """Order information included in receipt data.""" + + amount: Optional[int] + amount_refunded: Optional[int] + completed: Optional[str] + created: Optional[str] + currency: Optional[str] + financial_status: Optional[str] + modified: Optional[str] + order_id: Optional[str] + status: Optional[str] + transaction_id: Optional[int] + items: Optional[list[ReceiptOrderItem]] + tip: Optional[list[ReceiptOrderTip]] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptOrder"]: + """Create a ReceiptOrder from dictionary data.""" + if d is None: + return None + + args = d.copy() + items_data = d.get("items") + if isinstance(items_data, list): + args["items"] = [ + ReceiptOrderItem.from_dict(item) + for item in items_data + if isinstance(item, dict) + ] + + tip_data = d.get("tip") + if isinstance(tip_data, list): + args["tip"] = [ + ReceiptOrderTip.from_dict(tip) + for tip in tip_data + if isinstance(tip, dict) + ] + + return ReceiptOrder(**args) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/payment.py b/src/multisafepay/api/paths/pos/receipt/response/components/payment.py new file mode 100644 index 0000000..9a0c753 --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/payment.py @@ -0,0 +1,36 @@ +# 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. + +"""Payment section model for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptPayment(ResponseModel): + """Payment information included in receipt data.""" + + application_id: Optional[str] + authorization_code: Optional[int] + card_acceptor_location: Optional[str] + card_entry_mode: Optional[str] + card_expiry_date: Optional[str] + cardholder_verification_method: Optional[str] + issuer_bin: Optional[str] + issuer_country_code: Optional[str] + last4: Optional[str] + payment_method: Optional[str] + response_code: Optional[str] + terminal_id: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptPayment"]: + """Create a ReceiptPayment model from dictionary data.""" + if d is None: + return None + return ReceiptPayment(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py b/src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py new file mode 100644 index 0000000..f96d40b --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/components/related_transactions.py @@ -0,0 +1,36 @@ +# 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. + +"""Related transactions section model for POS receipt response.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class ReceiptRelatedTransactions(ResponseModel): + """Represents related transaction data returned for a receipt.""" + + amount: Optional[int] + created: Optional[str] + currency: Optional[str] + description: Optional[str] + items: Optional[str] + modified: Optional[str] + order_id: Optional[str] + reference_transaction_id: Optional[int] + status: Optional[str] + transaction_id: Optional[int] + type: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["ReceiptRelatedTransactions"]: + """Create a ReceiptRelatedTransactions model from dictionary data.""" + if d is None: + return None + + return ReceiptRelatedTransactions(**d) diff --git a/src/multisafepay/api/paths/pos/receipt/response/receipt.py b/src/multisafepay/api/paths/pos/receipt/response/receipt.py new file mode 100644 index 0000000..5a2e7bd --- /dev/null +++ b/src/multisafepay/api/paths/pos/receipt/response/receipt.py @@ -0,0 +1,78 @@ +# 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. + +"""Response model for POS receipt data.""" + +from typing import Optional + +from multisafepay.api.paths.pos.receipt.response.components.merchant import ( + ReceiptMerchant, +) +from multisafepay.api.paths.pos.receipt.response.components.order import ( + ReceiptOrder, +) +from multisafepay.api.paths.pos.receipt.response.components.payment import ( + ReceiptPayment, +) +from multisafepay.api.paths.pos.receipt.response.components.related_transactions import ( + ReceiptRelatedTransactions, +) +from multisafepay.model.response_model import ResponseModel + + +class Receipt(ResponseModel): + """ + Represents receipt payload data returned by the POS receipt endpoint. + + Attributes + ---------- + merchant (Optional[ReceiptMerchant]): Information about the merchant. + order (Optional[ReceiptOrder]): Information about the order. + payment (Optional[ReceiptPayment]): Information about the payment. + printed_on (Optional[str]): Timestamp when the receipt was printed. + related_transactions (Optional[ReceiptRelatedTransactions]): Linked transaction information. + + """ + + merchant: Optional[ReceiptMerchant] + order: Optional[ReceiptOrder] + payment: Optional[ReceiptPayment] + printed_on: Optional[str] + related_transactions: Optional[ReceiptRelatedTransactions] + + @staticmethod + def from_dict(d: dict) -> Optional["Receipt"]: + """ + Create a Receipt from dictionary data. + + Parameters + ---------- + d (dict): The receipt data. + + Returns + ------- + Optional[Receipt]: A receipt instance or None. + + """ + if d is None: + return None + + args = d.copy() + if isinstance(d.get("merchant"), dict): + args["merchant"] = ReceiptMerchant.from_dict(d.get("merchant")) + if isinstance(d.get("order"), dict): + args["order"] = ReceiptOrder.from_dict(d.get("order")) + if isinstance(d.get("payment"), dict): + args["payment"] = ReceiptPayment.from_dict(d.get("payment")) + if isinstance(d.get("related_transactions"), dict): + args["related_transactions"] = ( + ReceiptRelatedTransactions.from_dict( + d.get("related_transactions"), + ) + ) + + return Receipt(**args) diff --git a/src/multisafepay/api/paths/terminal_groups/__init__.py b/src/multisafepay/api/paths/terminal_groups/__init__.py new file mode 100644 index 0000000..a537fc1 --- /dev/null +++ b/src/multisafepay/api/paths/terminal_groups/__init__.py @@ -0,0 +1,9 @@ +"""Terminal group API endpoints.""" + +from multisafepay.api.paths.terminal_groups.terminal_group_manager import ( + TerminalGroupManager, +) + +__all__ = [ + "TerminalGroupManager", +] diff --git a/src/multisafepay/api/paths/terminal_groups/terminal_group_manager.py b/src/multisafepay/api/paths/terminal_groups/terminal_group_manager.py new file mode 100644 index 0000000..e1ec30e --- /dev/null +++ b/src/multisafepay/api/paths/terminal_groups/terminal_group_manager.py @@ -0,0 +1,107 @@ +# 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. + +"""Terminal group manager for `/json/terminal-groups/{terminal_group_id}/terminals`.""" + +from multisafepay.api.base.abstract_manager import AbstractManager +from multisafepay.api.base.listings.listing_pager import ListingPager +from multisafepay.api.base.listings.pager import Pager +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.terminals.response.terminal import Terminal +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope +from multisafepay.util.message import MessageList, gen_could_not_created_msg +from pydantic import ValidationError + +ALLOWED_OPTIONS = { + "page": "", + "limit": "", +} + + +class TerminalGroupManager(AbstractManager): + """A class representing the TerminalGroupManager.""" + + def __init__(self: "TerminalGroupManager", client: Client) -> None: + """ + Initialize the TerminalGroupManager with a client. + + Parameters + ---------- + client (Client): The client used to make API requests. + + """ + super().__init__(client) + + @staticmethod + def __custom_terminal_listing_response( + response: ApiResponse, + ) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + + pager = None + raw_pager = response.get_pager() + if isinstance(raw_pager, dict): + pager = Pager.from_dict(raw_pager.copy()) + + try: + args["data"] = ListingPager( + data=response.get_body_data().copy(), + pager=pager, + class_type=Terminal, + ) + except (AttributeError, TypeError, ValidationError): + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("Listing Terminal"), + ) + + return CustomApiResponse(**args) + + def get_terminals_by_group( + self: "TerminalGroupManager", + terminal_group_id: str, + options: dict = None, + ) -> CustomApiResponse: + """ + List POS terminals for the given terminal group. + + Parameters + ---------- + terminal_group_id (str): Terminal group identifier. + options (dict): Request options (`page`, `limit`). Defaults to None. + + Returns + ------- + CustomApiResponse: The response containing terminal listing data. + + """ + if options is None: + options = {} + options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS} + + encoded_terminal_group_id = self.encode_path_segment(terminal_group_id) + endpoint = ( + f"json/terminal-groups/{encoded_terminal_group_id}/terminals" + ) + context = {"terminal_group_id": terminal_group_id} + response = self.client.create_get_request( + endpoint=endpoint, + params=options, + context=context, + auth_scope=AuthScope( + scope=Client.AUTH_SCOPE_PARTNER_AFFILIATE, + ), + ) + return TerminalGroupManager.__custom_terminal_listing_response( + response, + ) diff --git a/src/multisafepay/api/paths/terminals/__init__.py b/src/multisafepay/api/paths/terminals/__init__.py new file mode 100644 index 0000000..5d8a053 --- /dev/null +++ b/src/multisafepay/api/paths/terminals/__init__.py @@ -0,0 +1,9 @@ +"""Terminal API endpoints for POS terminal and receipt operations.""" + +from multisafepay.api.paths.terminals.terminal_manager import ( + TerminalManager, +) + +__all__ = [ + "TerminalManager", +] diff --git a/src/multisafepay/api/paths/terminals/request/__init__.py b/src/multisafepay/api/paths/terminals/request/__init__.py new file mode 100644 index 0000000..b517cde --- /dev/null +++ b/src/multisafepay/api/paths/terminals/request/__init__.py @@ -0,0 +1,9 @@ +"""Request models for terminal-related API calls.""" + +from multisafepay.api.paths.terminals.request.create_terminal_request import ( + CreateTerminalRequest, +) + +__all__ = [ + "CreateTerminalRequest", +] 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/api/paths/terminals/response/__init__.py b/src/multisafepay/api/paths/terminals/response/__init__.py new file mode 100644 index 0000000..9cd2f2d --- /dev/null +++ b/src/multisafepay/api/paths/terminals/response/__init__.py @@ -0,0 +1,7 @@ +"""Response models for terminal endpoints.""" + +from multisafepay.api.paths.terminals.response.terminal import Terminal + +__all__ = [ + "Terminal", +] diff --git a/src/multisafepay/api/paths/terminals/response/terminal.py b/src/multisafepay/api/paths/terminals/response/terminal.py new file mode 100644 index 0000000..45e28f3 --- /dev/null +++ b/src/multisafepay/api/paths/terminals/response/terminal.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. + +"""Response model for POS terminal data.""" + +from typing import Optional + +from multisafepay.model.response_model import ResponseModel + + +class Terminal(ResponseModel): + """ + Represents a POS terminal returned by the API. + + Attributes + ---------- + id (Optional[str]): The terminal identifier. + provider (Optional[str]): The terminal provider. + name (Optional[str]): The terminal name. + code (Optional[str]): The terminal code. + created (Optional[str]): Terminal creation timestamp. + last_updated (Optional[str]): Terminal update timestamp. + manufacturer_id (Optional[str]): Terminal manufacturer identifier. + serial_number (Optional[str]): Terminal serial number. + active (Optional[bool]): Whether the terminal is active. + group_id (Optional[int]): The terminal group identifier. + country (Optional[str]): The terminal country code. + + """ + + id: Optional[str] + provider: Optional[str] + name: Optional[str] + code: Optional[str] + created: Optional[str] + last_updated: Optional[str] + manufacturer_id: Optional[str] + serial_number: Optional[str] + active: Optional[bool] + group_id: Optional[int] + country: Optional[str] + + @staticmethod + def from_dict(d: dict) -> Optional["Terminal"]: + """ + Create a Terminal from dictionary data. + + Parameters + ---------- + d (dict): The terminal data. + + Returns + ------- + Optional[Terminal]: A terminal instance or None. + + """ + if d is None: + return None + return Terminal(**d) diff --git a/src/multisafepay/api/paths/terminals/terminal_manager.py b/src/multisafepay/api/paths/terminals/terminal_manager.py new file mode 100644 index 0000000..41a5930 --- /dev/null +++ b/src/multisafepay/api/paths/terminals/terminal_manager.py @@ -0,0 +1,144 @@ +# 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. + +"""Terminal manager for `/json/terminals` operations.""" + +import json + +from multisafepay.api.base.abstract_manager import AbstractManager +from multisafepay.api.base.listings.listing_pager import ListingPager +from multisafepay.api.base.listings.pager import Pager +from multisafepay.api.base.response.api_response import ApiResponse +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.terminals.request.create_terminal_request import ( + CreateTerminalRequest, +) +from multisafepay.api.paths.terminals.response.terminal import Terminal +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope +from multisafepay.util.dict_utils import dict_empty +from multisafepay.util.message import MessageList, gen_could_not_created_msg +from pydantic import ValidationError + +ALLOWED_OPTIONS = { + "page": "", + "limit": "", +} + + +class TerminalManager(AbstractManager): + """A class representing the TerminalManager.""" + + def __init__(self: "TerminalManager", client: Client) -> None: + """ + Initialize the TerminalManager with a client. + + Parameters + ---------- + client (Client): The client used to make API requests. + + """ + super().__init__(client) + + @staticmethod + def __custom_terminal_response(response: ApiResponse) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + if not dict_empty(response.get_body_data()): + try: + args["data"] = Terminal.from_dict( + d=response.get_body_data().copy(), + ) + except ValidationError: + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("Terminal"), + ) + + return CustomApiResponse(**args) + + @staticmethod + def __custom_terminal_listing_response( + response: ApiResponse, + ) -> CustomApiResponse: + args: dict = { + **response.dict(), + "data": None, + } + + pager = None + raw_pager = response.get_pager() + if isinstance(raw_pager, dict): + pager = Pager.from_dict(raw_pager.copy()) + + try: + args["data"] = ListingPager( + data=response.get_body_data().copy(), + pager=pager, + class_type=Terminal, + ) + except (AttributeError, TypeError, ValidationError): + args["warnings"] = MessageList().add_message( + gen_could_not_created_msg("Listing Terminal"), + ) + + return CustomApiResponse(**args) + + def create_terminal( + self: "TerminalManager", + create_terminal_request: CreateTerminalRequest, + ) -> CustomApiResponse: + """ + Create a new POS terminal. + + Parameters + ---------- + create_terminal_request (CreateTerminalRequest): Request payload. + + Returns + ------- + CustomApiResponse: The response containing created terminal data. + + """ + json_data = json.dumps(create_terminal_request.to_dict()) + response = self.client.create_post_request( + "json/terminals", + request_body=json_data, + ) + return TerminalManager.__custom_terminal_response(response) + + def get_terminals( + self: "TerminalManager", + options: dict = None, + ) -> CustomApiResponse: + """ + List POS terminals for the account. + + Parameters + ---------- + options (dict): Request options (`page`, `limit`). Defaults to None. + + Returns + ------- + CustomApiResponse: The response containing terminal listing data. + + """ + if options is None: + options = {} + options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS} + + response = self.client.create_get_request( + "json/terminals", + options, + auth_scope=AuthScope( + scope=Client.AUTH_SCOPE_PARTNER_AFFILIATE, + ), + ) + return TerminalManager.__custom_terminal_listing_response(response) diff --git a/src/multisafepay/client/__init__.py b/src/multisafepay/client/__init__.py index a14961a..922e1d2 100644 --- a/src/multisafepay/client/__init__.py +++ b/src/multisafepay/client/__init__.py @@ -2,8 +2,10 @@ from multisafepay.client.api_key import ApiKey from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ScopedCredentialResolver __all__ = [ "ApiKey", "Client", + "ScopedCredentialResolver", ] diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index d6d5e00..2c9a3dc 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -16,6 +16,11 @@ from ..exception.api import ApiException from .api_key import ApiKey +from .credential_resolver import ( + AuthScope, + CredentialResolver, + ScopedCredentialResolver, +) class Client: @@ -39,6 +44,14 @@ class Client: CUSTOM_BASE_URL_ENV = "MSP_SDK_CUSTOM_BASE_URL" ALLOW_CUSTOM_BASE_URL_ENV = "MSP_SDK_ALLOW_CUSTOM_BASE_URL" + AUTH_SCOPE_DEFAULT = ScopedCredentialResolver.AUTH_SCOPE_DEFAULT + AUTH_SCOPE_PARTNER_AFFILIATE = ( + ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE + ) + AUTH_SCOPE_TERMINAL_GROUP = ( + ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP + ) + METHOD_POST = "POST" METHOD_GET = "GET" METHOD_PATCH = "PATCH" @@ -46,18 +59,20 @@ 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, + credential_resolver: Optional[CredentialResolver] = None, ) -> None: """ Initialize the Client. 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. @@ -65,9 +80,17 @@ def __init__( base_url (Optional[str], optional): Custom API base URL. Only allowed when running with `MSP_SDK_BUILD_PROFILE=dev` and `MSP_SDK_ALLOW_CUSTOM_BASE_URL=1`. + credential_resolver (Optional[CredentialResolver], optional): + Resolver used to derive API keys by auth scope. """ - self.api_key = ApiKey(api_key=api_key) + if api_key is None and credential_resolver is None: + raise ValueError( + "api_key is required when credential_resolver is not provided.", + ) + + self.api_key = ApiKey(api_key=api_key) if api_key is not None else None + self.credential_resolver = credential_resolver self.url = self._resolve_base_url( is_production=is_production, explicit_base_url=base_url, @@ -123,6 +146,7 @@ def create_get_request( endpoint: str, params: dict[str, Any] = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a GET request. @@ -143,6 +167,7 @@ def create_get_request( self.METHOD_GET, url, context=context, + auth_scope=auth_scope, ) def create_post_request( @@ -151,6 +176,7 @@ def create_post_request( params: dict[str, Any] = None, request_body: str = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a POST request. @@ -173,6 +199,7 @@ def create_post_request( url, request_body=request_body, context=context, + auth_scope=auth_scope, ) def create_patch_request( @@ -181,6 +208,7 @@ def create_patch_request( params: dict[str, Any] = None, request_body: str = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a PATCH request. @@ -203,6 +231,7 @@ def create_patch_request( url, request_body=request_body, context=context, + auth_scope=auth_scope, ) def create_delete_request( @@ -210,6 +239,7 @@ def create_delete_request( endpoint: str, params: dict[str, Any] = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create a DELETE request. @@ -226,7 +256,12 @@ def create_delete_request( """ url = self._build_url(endpoint, params) - return self._create_request(self.METHOD_DELETE, url, context=context) + return self._create_request( + self.METHOD_DELETE, + url, + context=context, + auth_scope=auth_scope, + ) def _build_url( self: "Client", @@ -255,12 +290,33 @@ def _build_url( ) return f"{self.url}{endpoint}?{query_string}" + def _resolve_api_key( + self: "Client", + auth_scope: Optional[AuthScope], + ) -> str: + if self.credential_resolver is not None: + resolved_scope = auth_scope or AuthScope( + scope=self.AUTH_SCOPE_DEFAULT, + ) + return self.credential_resolver.resolve( + auth_scope=resolved_scope.scope, + group_id=resolved_scope.group_id, + ) + + if self.api_key is None: + raise ValueError( + "api_key is required when credential_resolver is not provided.", + ) + + return self.api_key.get() + def _create_request( self: "Client", method: str, url: str, request_body: Optional[dict[str, Any]] = None, context: Optional[dict[str, Any]] = None, + auth_scope: Optional[AuthScope] = None, ) -> ApiResponse: """ Create and send an HTTP request. @@ -277,8 +333,9 @@ def _create_request( ApiResponse: The API response. """ + api_key = self._resolve_api_key(auth_scope) headers = { - "Authorization": "Bearer " + self.api_key.get(), + "Authorization": "Bearer " + api_key, "accept-encoding": "application/json", "Content-Type": "application/json", } diff --git a/src/multisafepay/client/credential_resolver.py b/src/multisafepay/client/credential_resolver.py new file mode 100644 index 0000000..860bcaa --- /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..23c35e2 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -11,12 +11,18 @@ 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 from multisafepay.api.paths.payment_methods.payment_method_manager import ( PaymentMethodManager, ) +from multisafepay.api.paths.pos.pos_manager import PosManager +from multisafepay.api.paths.terminal_groups.terminal_group_manager import ( + TerminalGroupManager, +) +from multisafepay.api.paths.terminals.terminal_manager import TerminalManager from multisafepay.api.paths.transactions.transaction_manager import ( TransactionManager, ) @@ -26,6 +32,7 @@ from .api.paths.me.me_manager import MeManager from .api.paths.recurring.recurring_manager import RecurringManager from .client.client import Client +from .client.credential_resolver import CredentialResolver class Sdk: @@ -38,19 +45,21 @@ 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, + credential_resolver: Optional[CredentialResolver] = None, ) -> None: """ Initialize the SDK with the provided configuration. 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 @@ -60,14 +69,17 @@ def __init__( The locale to use for requests, by default "en_US". base_url : Optional[str], optional Custom API base URL (dev-only guardrails apply), by default None. + credential_resolver : Optional[CredentialResolver], optional + Strategy for resolving API keys per auth scope, by default None. """ self.client = Client( - api_key.strip(), - is_production, - transport, - locale, - base_url, + api_key=api_key, + is_production=is_production, + transport=transport, + locale=locale, + base_url=base_url, + credential_resolver=credential_resolver, ) self.recurring_manager = RecurringManager(self.client) @@ -167,6 +179,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. @@ -191,6 +215,42 @@ def get_capture_manager(self: "Sdk") -> CaptureManager: """ return CaptureManager(self.client) + def get_terminal_manager(self: "Sdk") -> TerminalManager: + """ + Get the terminal manager. + + Returns + ------- + TerminalManager + The terminal manager instance. + + """ + return TerminalManager(self.client) + + def get_terminal_group_manager(self: "Sdk") -> TerminalGroupManager: + """ + Get the terminal group manager. + + Returns + ------- + TerminalGroupManager + The terminal group manager instance. + + """ + return TerminalGroupManager(self.client) + + def get_pos_manager(self: "Sdk") -> PosManager: + """ + Get the POS manager. + + Returns + ------- + PosManager + The POS manager instance. + + """ + return PosManager(self.client) + def get_client(self: "Sdk") -> Client: """ Get the client instance. diff --git a/tests/multisafepay/e2e/conftest.py b/tests/multisafepay/e2e/conftest.py index 66d1a96..ca0384b 100644 --- a/tests/multisafepay/e2e/conftest.py +++ b/tests/multisafepay/e2e/conftest.py @@ -8,12 +8,20 @@ import pytest from dotenv import load_dotenv -from multisafepay.client import Client +from multisafepay.client import Client, ScopedCredentialResolver from multisafepay.sdk import Sdk from multisafepay.transport import HTTPTransport E2E_API_KEY_ENV = "E2E_API_KEY" E2E_BASE_URL_ENV = "E2E_BASE_URL" +# Temporary override for strict feature examples while testapi lacks +# support for specific Cloud POS / terminal flows. +E2E_NO_SANDBOX_BASE_URL_ENV = "E2E_NO_SANDBOX_BASE_URL" +E2E_PARTNER_API_KEY_ENV = "E2E_PARTNER_API_KEY" +E2E_TERMINAL_GROUP_DEFAULT_API_KEY_ENV = ( + "E2E_TERMINAL_GROUP_API_KEY_GROUP_DEFAULT" +) +E2E_CLOUD_POS_TERMINAL_ID_ENV = "E2E_CLOUD_POS_TERMINAL_ID" # Load .env file from the project root load_dotenv() @@ -28,10 +36,78 @@ def _get_e2e_base_url() -> str: return base_url or Client.TEST_URL -def _validate_e2e_base_url(base_url: str) -> str: +def _get_e2e_no_sandbox_base_url() -> str: + base_url = os.getenv(E2E_NO_SANDBOX_BASE_URL_ENV, "").strip() + if base_url: + return base_url + + return _get_e2e_base_url() + + +def _require_strict_env(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + msg = f"Feature-specific E2E tests require {name} (not set)" + raise pytest.UsageError(msg) + return value + + +def _resolve_cloud_pos_terminal_group_id( + partner_sdk: Sdk, + cloud_pos_terminal_id: str, +) -> str: + terminal_manager = partner_sdk.get_terminal_manager() + limit = 100 + max_pages = 10 + + for page in range(1, max_pages + 1): + response = terminal_manager.get_terminals( + options={ + "limit": limit, + "page": page, + }, + ) + if ( + response.get_status_code() != 200 + or not response.get_body_success() + ): + raise pytest.UsageError( + "Unable to resolve Cloud POS terminal group id: " + "GET /json/terminals did not return a successful response", + ) + + listing = response.get_data() + if listing is None: + break + + terminals = listing.get_data() + for terminal in terminals: + terminal_id = getattr(terminal, "id", None) + terminal_code = getattr(terminal, "code", None) + if cloud_pos_terminal_id not in {terminal_id, terminal_code}: + continue + + group_id = getattr(terminal, "group_id", None) + if group_id is None: + raise pytest.UsageError( + "Unable to resolve Cloud POS terminal group id: " + f"terminal {cloud_pos_terminal_id} has no group_id", + ) + return str(group_id) + + if len(terminals) < limit: + break + + raise pytest.UsageError( + "Unable to resolve Cloud POS terminal group id from /json/terminals " + f"for terminal {cloud_pos_terminal_id}", + ) + + +def _validate_e2e_base_url(base_url: str, env_name: str) -> str: parsed = urlparse(base_url) if parsed.scheme != "https" or not parsed.netloc: - msg = f"{E2E_BASE_URL_ENV} must be a valid https URL" + msg = f"{env_name} must be a valid https URL" raise pytest.UsageError(msg) parsed = urlparse(base_url) @@ -53,7 +129,16 @@ def e2e_api_key() -> str: @pytest.fixture(scope="session") def e2e_base_url() -> str: """Return the dedicated base URL used by E2E tests.""" - return _validate_e2e_base_url(_get_e2e_base_url()) + return _validate_e2e_base_url(_get_e2e_base_url(), E2E_BASE_URL_ENV) + + +@pytest.fixture(scope="session") +def e2e_no_sandbox_base_url() -> str: + """Return non-sandbox base URL used by strict example E2E tests.""" + return _validate_e2e_base_url( + _get_e2e_no_sandbox_base_url(), + E2E_NO_SANDBOX_BASE_URL_ENV, + ) @pytest.fixture(scope="session") @@ -82,6 +167,118 @@ def e2e_sdk(e2e_sdk_factory: Callable[..., Sdk]) -> Sdk: return e2e_sdk_factory() +@pytest.fixture(scope="session") +def merchant_e2e_api_key() -> str: + """Return merchant key for strict feature tests.""" + return _require_strict_env(E2E_API_KEY_ENV) + + +@pytest.fixture(scope="session") +def partner_affiliate_api_key() -> str: + """Return partner affiliate API key used by strict feature E2E tests.""" + return _require_strict_env(E2E_PARTNER_API_KEY_ENV) + + +@pytest.fixture(scope="session") +def cloud_pos_terminal_group_id( + partner_sdk: Sdk, + cloud_pos_terminal_id: str, +) -> str: + """Return terminal group id resolved from terminal listing data.""" + return _resolve_cloud_pos_terminal_group_id( + partner_sdk=partner_sdk, + cloud_pos_terminal_id=cloud_pos_terminal_id, + ) + + +@pytest.fixture(scope="session") +def cloud_pos_terminal_id() -> str: + """Return terminal id used by Cloud POS E2E tests.""" + return _require_strict_env(E2E_CLOUD_POS_TERMINAL_ID_ENV) + + +@pytest.fixture(scope="session") +def cloud_pos_numeric_terminal_group_id( + cloud_pos_terminal_group_id: str, +) -> str: + """Return terminal group id and ensure it matches numeric example constraints.""" + if not cloud_pos_terminal_group_id.isdigit(): + raise pytest.UsageError( + "Resolved Cloud POS terminal group id must be numeric for this " + "example", + ) + return cloud_pos_terminal_group_id + + +@pytest.fixture(scope="session") +def e2e_terminal_group_default_api_key() -> str: + """Return terminal-group API key used by strict Cloud POS E2E tests.""" + return _require_strict_env(E2E_TERMINAL_GROUP_DEFAULT_API_KEY_ENV) + + +@pytest.fixture(scope="session") +def merchant_sdk( + merchant_e2e_api_key: str, + e2e_base_url: str, +) -> Sdk: + """Return SDK configured with merchant credentials only.""" + sdk = Sdk( + api_key=merchant_e2e_api_key, + is_production=False, + ) + sdk.get_client().url = e2e_base_url + return sdk + + +@pytest.fixture(scope="session") +def partner_sdk( + merchant_e2e_api_key: str, + e2e_no_sandbox_base_url: str, + partner_affiliate_api_key: str, +) -> Sdk: + """Return partner-scoped SDK isolated for strict feature examples.""" + credential_resolver = ScopedCredentialResolver( + default_api_key=merchant_e2e_api_key, + partner_affiliate_api_key=partner_affiliate_api_key, + ) + sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + sdk.get_client().url = e2e_no_sandbox_base_url + return sdk + + +@pytest.fixture(scope="session") +def cloud_pos_sdk( + merchant_e2e_api_key: str, + e2e_no_sandbox_base_url: str, + cloud_pos_terminal_group_id: str, + e2e_terminal_group_default_api_key: str, +) -> Sdk: + """Return Cloud POS SDK isolated for strict feature examples.""" + partner_affiliate_api_key = os.getenv(E2E_PARTNER_API_KEY_ENV, "").strip() + + resolver_kwargs: dict = { + "default_api_key": merchant_e2e_api_key, + "terminal_group_api_keys": { + cloud_pos_terminal_group_id: e2e_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) + sdk = Sdk( + is_production=False, + credential_resolver=credential_resolver, + ) + sdk.get_client().url = e2e_no_sandbox_base_url + return sdk + + def pytest_collection_modifyitems( config: pytest.Config, # noqa: ARG001 items: list[pytest.Item], diff --git a/tests/multisafepay/e2e/examples/order_manager/test_cancel.py b/tests/multisafepay/e2e/examples/order_manager/test_cancel.py new file mode 100644 index 0000000..5a9510a --- /dev/null +++ b/tests/multisafepay/e2e/examples/order_manager/test_cancel.py @@ -0,0 +1,86 @@ +# 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. + +"""E2E coverage for examples/order_manager/cancel.py.""" + +import time + +import pytest + +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) +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.sdk import Sdk + + +@pytest.fixture(scope="module") +def order_manager(cloud_pos_sdk: Sdk) -> OrderManager: + """Fixture that provides an OrderManager instance for Cloud POS tests.""" + return cloud_pos_sdk.get_order_manager() + + +def _build_cancel_order_request( + order_id: str, + terminal_id: str, +) -> OrderRequest: + """Create a Cloud POS order request that can be canceled.""" + return ( + OrderRequest() + .add_type("redirect") + .add_order_id(order_id) + .add_description("Cloud POS cancel order") + .add_amount(100) + .add_currency("EUR") + .add_gateway_info( + { + "terminal_id": terminal_id, + }, + ) + ) + + +def test_cancel_cloud_pos_order( + order_manager: OrderManager, + cloud_pos_terminal_group_id: str, + cloud_pos_terminal_id: str, +) -> None: + """Create and cancel a Cloud POS order following the example flow.""" + order_id = f"cloud-pos-cancel-e2e-{int(time.time())}" + + create_response = order_manager.create( + request_order=_build_cancel_order_request( + order_id=order_id, + terminal_id=cloud_pos_terminal_id, + ), + terminal_group_id=cloud_pos_terminal_group_id, + ) + + assert isinstance(create_response, CustomApiResponse) + assert create_response.get_status_code() == 200 + assert create_response.get_body_success() is True + + order = create_response.get_data() + assert isinstance(order, Order) + assert order.order_id == order_id + + time.sleep(5) + + cancel_response = order_manager.cancel_transaction( + order_id, + terminal_group_id=cloud_pos_terminal_group_id, + ) + + assert isinstance(cancel_response, CustomApiResponse) + assert cancel_response.get_status_code() == 200 + assert cancel_response.get_body_success() is True + assert isinstance(cancel_response.get_data(), CancelTransaction) diff --git a/tests/multisafepay/e2e/examples/order_manager/test_cloud_pos_order.py b/tests/multisafepay/e2e/examples/order_manager/test_cloud_pos_order.py new file mode 100644 index 0000000..4b9f19d --- /dev/null +++ b/tests/multisafepay/e2e/examples/order_manager/test_cloud_pos_order.py @@ -0,0 +1,80 @@ +# 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. + +"""E2E coverage for examples/order_manager/cloud_pos_order.py.""" + +import time + +import pytest + +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.sdk import Sdk + + +@pytest.fixture(scope="module") +def order_manager(cloud_pos_sdk: Sdk) -> OrderManager: + """Fixture that provides an OrderManager instance for Cloud POS tests.""" + return cloud_pos_sdk.get_order_manager() + + +def _build_cloud_pos_order_request( + order_id: str, + terminal_id: str, +) -> OrderRequest: + """Create the Cloud POS order request as used in the example template.""" + return ( + 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, + }, + ) + ) + + +def test_cloud_pos_order( + order_manager: OrderManager, + cloud_pos_terminal_group_id: str, + cloud_pos_terminal_id: str, +) -> None: + """Create a Cloud POS order with terminal-group scoped authentication.""" + order_id = f"cloud-pos-e2e-{int(time.time())}" + + create_response = order_manager.create( + request_order=_build_cloud_pos_order_request( + order_id=order_id, + terminal_id=cloud_pos_terminal_id, + ), + terminal_group_id=cloud_pos_terminal_group_id, + ) + + assert isinstance(create_response, CustomApiResponse) + assert create_response.get_status_code() == 200 + assert create_response.get_body_success() is True + + order = create_response.get_data() + assert isinstance(order, Order) + assert order.order_id == 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 is not None: + assert isinstance(events_token, str) + + if events_stream_url is not None: + assert isinstance(events_stream_url, str) diff --git a/tests/multisafepay/e2e/examples/terminal_group_manager/test_get_terminals_by_group.py b/tests/multisafepay/e2e/examples/terminal_group_manager/test_get_terminals_by_group.py new file mode 100644 index 0000000..7d2b8f4 --- /dev/null +++ b/tests/multisafepay/e2e/examples/terminal_group_manager/test_get_terminals_by_group.py @@ -0,0 +1,44 @@ +# 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. + +"""E2E coverage for examples/terminal_group_manager/get_terminals_by_group.py.""" + +import pytest + +from multisafepay.api.base.listings.listing_pager import ListingPager +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.terminal_groups.terminal_group_manager import ( + TerminalGroupManager, +) +from multisafepay.sdk import Sdk + + +@pytest.fixture(scope="module") +def terminal_group_manager(partner_sdk: Sdk) -> TerminalGroupManager: + """Fixture that provides a TerminalGroupManager instance for testing.""" + return partner_sdk.get_terminal_group_manager() + + +def test_get_terminals_by_group( + terminal_group_manager: TerminalGroupManager, + cloud_pos_numeric_terminal_group_id: str, +) -> None: + """List terminals for a specific group using the example template flow.""" + response = terminal_group_manager.get_terminals_by_group( + terminal_group_id=cloud_pos_numeric_terminal_group_id, + options={ + "limit": 10, + "page": 1, + }, + ) + + assert isinstance(response, CustomApiResponse) + assert response.get_status_code() == 200 + assert response.get_body_success() is True + assert isinstance(response.get_data(), ListingPager) diff --git a/tests/multisafepay/e2e/examples/terminal_manager/test_get_terminals.py b/tests/multisafepay/e2e/examples/terminal_manager/test_get_terminals.py new file mode 100644 index 0000000..56e4b06 --- /dev/null +++ b/tests/multisafepay/e2e/examples/terminal_manager/test_get_terminals.py @@ -0,0 +1,38 @@ +# 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. + +"""E2E coverage for examples/terminal_manager/get_terminals.py.""" + +import pytest + +from multisafepay.api.base.listings.listing_pager import ListingPager +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.terminals.terminal_manager import TerminalManager +from multisafepay.sdk import Sdk + + +@pytest.fixture(scope="module") +def terminal_manager(partner_sdk: Sdk) -> TerminalManager: + """Fixture that provides a TerminalManager instance for testing.""" + return partner_sdk.get_terminal_manager() + + +def test_get_terminals(terminal_manager: TerminalManager) -> None: + """List terminals using the same flow as the terminal manager example.""" + response = terminal_manager.get_terminals( + options={ + "limit": 10, + "page": 1, + }, + ) + + assert isinstance(response, CustomApiResponse) + assert response.get_status_code() == 200 + assert response.get_body_success() is True + assert isinstance(response.get_data(), ListingPager) 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..50eba9f --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager.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. + +"""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..536164a --- /dev/null +++ b/tests/multisafepay/unit/api/path/terminals/response/test_unit_terminal_response.py @@ -0,0 +1,94 @@ +# 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..80e30e6 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -11,10 +11,61 @@ import pytest from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import ( + 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..a7fa84c 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,55 @@ 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( + 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( + 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 f922dea7348e288deaffc9f7cca2d6e864654ba6 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Fri, 17 Apr 2026 16:02:25 +0200 Subject: [PATCH 2/3] PTHMINT-108: Remove redundant credential checks Delegate all API key validation to ScopedCredentialResolver. Fix resolver_bootstrap_api_key fallback bug in Cloud POS examples. Add scope comments documenting which key each manager method resolves. --- README.md | 11 +++++ examples/event_manager/subscribe_events.py | 31 +++++-------- examples/order_manager/cancel.py | 28 +++++------- examples/order_manager/cloud_pos_order.py | 26 ++++------- examples/pos_manager/get_receipt.py | 45 ++++++------------- .../get_terminals_by_group.py | 21 +++------ examples/terminal_manager/create.py | 22 +++------ examples/terminal_manager/get_terminals.py | 13 +++--- 8 files changed, 76 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 67ebbcf..ef3e2c9 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,17 @@ In any non-dev profile (including default `release`), custom base URLs are block Go to the folder `examples` to see how to use the SDK. +### Scoped credential behavior in examples + +Some examples support multiple credential scopes through `ScopedCredentialResolver`. + +- `ScopedCredentialResolver` supports any key combination as long as at least one credential is configured. +- Cloud POS order lifecycle examples execute manager calls with terminal-group scope and therefore require `TERMINAL_GROUP_API_KEY_GROUP_DEFAULT`. +- Those examples still read `API_KEY` and `PARTNER_API_KEY` and pass them to the resolver when available, but they are not validated as required for those specific flows. +- `terminal_manager/create.py` calls `create_terminal` with default scope and therefore requires `API_KEY`. +- `terminal_manager/get_terminals.py` and `terminal_group_manager/get_terminals_by_group.py` call partner-affiliate scope and require either `PARTNER_API_KEY` or `API_KEY`. +- `PARTNER_API_KEY` is optional in those examples and must be passed via `partner_affiliate_api_key`; it does not replace `default_api_key`. + ## Code quality checks ### Linting diff --git a/examples/event_manager/subscribe_events.py b/examples/event_manager/subscribe_events.py index 6eec22b..bb04dd0 100644 --- a/examples/event_manager/subscribe_events.py +++ b/examples/event_manager/subscribe_events.py @@ -22,24 +22,16 @@ ) 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 - ) + # This example executes Cloud POS calls with terminal-group scope. + scoped_terminal_group_id = CLOUD_POS_TERMINAL_GROUP_ID resolver_kwargs = { - "default_api_key": resolver_bootstrap_api_key, - "terminal_group_api_keys": { - CLOUD_POS_TERMINAL_GROUP_ID: TERMINAL_GROUP_DEFAULT_API_KEY, - }, + "default_api_key": DEFAULT_ACCOUNT_API_KEY, + "partner_affiliate_api_key": PARTNER_AFFILIATE_API_KEY, } - if PARTNER_AFFILIATE_API_KEY: - resolver_kwargs["partner_affiliate_api_key"] = ( - PARTNER_AFFILIATE_API_KEY - ) + if scoped_terminal_group_id: + resolver_kwargs["terminal_group_api_keys"] = { + scoped_terminal_group_id: TERMINAL_GROUP_DEFAULT_API_KEY, + } credential_resolver = ScopedCredentialResolver(**resolver_kwargs) @@ -50,8 +42,9 @@ 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", "") + terminal_id = "" + # Temporary override for local runs via .env. + terminal_id = os.getenv("CLOUD_POS_TERMINAL_ID", terminal_id) order_id = f"cloud-pos-{int(time.time())}" @@ -71,7 +64,7 @@ create_response = order_manager.create( order_request, - terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + terminal_group_id=scoped_terminal_group_id, ) order = create_response.get_data() diff --git a/examples/order_manager/cancel.py b/examples/order_manager/cancel.py index 9cfa74d..240d1c2 100644 --- a/examples/order_manager/cancel.py +++ b/examples/order_manager/cancel.py @@ -23,24 +23,16 @@ ) 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 - ) + # This example executes Cloud POS calls with terminal-group scope. + scoped_terminal_group_id = CLOUD_POS_TERMINAL_GROUP_ID resolver_kwargs = { - "default_api_key": resolver_bootstrap_api_key, - "terminal_group_api_keys": { - CLOUD_POS_TERMINAL_GROUP_ID: TERMINAL_GROUP_DEFAULT_API_KEY, - }, + "default_api_key": DEFAULT_ACCOUNT_API_KEY, + "partner_affiliate_api_key": PARTNER_AFFILIATE_API_KEY, } - if PARTNER_AFFILIATE_API_KEY: - resolver_kwargs["partner_affiliate_api_key"] = ( - PARTNER_AFFILIATE_API_KEY - ) + if scoped_terminal_group_id: + resolver_kwargs["terminal_group_api_keys"] = { + scoped_terminal_group_id: TERMINAL_GROUP_DEFAULT_API_KEY, + } credential_resolver = ScopedCredentialResolver(**resolver_kwargs) @@ -69,7 +61,7 @@ create_response = order_manager.create( order_request, - terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + terminal_group_id=scoped_terminal_group_id, ) order = create_response.get_data() @@ -83,7 +75,7 @@ cancel_response = order_manager.cancel_transaction( order_id, - terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + terminal_group_id=scoped_terminal_group_id, ) print(f"Canceled Cloud POS order: {order_id}") diff --git a/examples/order_manager/cloud_pos_order.py b/examples/order_manager/cloud_pos_order.py index 791e652..b3abc06 100644 --- a/examples/order_manager/cloud_pos_order.py +++ b/examples/order_manager/cloud_pos_order.py @@ -22,26 +22,18 @@ ) if __name__ == "__main__": - if not TERMINAL_GROUP_DEFAULT_API_KEY: - raise RuntimeError( - "TERMINAL_GROUP_API_KEY_GROUP_DEFAULT is required", - ) - + # This example executes Cloud POS calls with terminal-group scope. + scoped_terminal_group_id = CLOUD_POS_TERMINAL_GROUP_ID # 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, - }, + "default_api_key": DEFAULT_ACCOUNT_API_KEY, + "partner_affiliate_api_key": PARTNER_AFFILIATE_API_KEY, } - if PARTNER_AFFILIATE_API_KEY: - resolver_kwargs["partner_affiliate_api_key"] = ( - PARTNER_AFFILIATE_API_KEY - ) + if scoped_terminal_group_id: + resolver_kwargs["terminal_group_api_keys"] = { + scoped_terminal_group_id: TERMINAL_GROUP_DEFAULT_API_KEY, + } credential_resolver = ScopedCredentialResolver(**resolver_kwargs) @@ -73,7 +65,7 @@ create_response = order_manager.create( order_request, - terminal_group_id=CLOUD_POS_TERMINAL_GROUP_ID, + terminal_group_id=scoped_terminal_group_id, ) order = create_response.get_data() diff --git a/examples/pos_manager/get_receipt.py b/examples/pos_manager/get_receipt.py index 8096d9b..b584e93 100644 --- a/examples/pos_manager/get_receipt.py +++ b/examples/pos_manager/get_receipt.py @@ -11,18 +11,10 @@ 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() +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) +terminal_group_id = os.getenv("CLOUD_POS_TERMINAL_GROUP_ID", "Default") +terminal_id = os.getenv("CLOUD_POS_TERMINAL_ID", "") def _is_completed_event(event: object) -> bool: @@ -45,27 +37,16 @@ def _is_completed_event(event: object) -> bool: 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 - ) + # This example executes Cloud POS calls with terminal-group scope. + scoped_terminal_group_id = terminal_group_id resolver_kwargs = { - "default_api_key": resolver_bootstrap_api_key, - "terminal_group_api_keys": { - terminal_group_id: terminal_group_default_api_key, - }, + "default_api_key": default_account_api_key, + "partner_affiliate_api_key": partner_affiliate_api_key, } - if partner_affiliate_api_key: - resolver_kwargs["partner_affiliate_api_key"] = ( - partner_affiliate_api_key - ) + if scoped_terminal_group_id: + resolver_kwargs["terminal_group_api_keys"] = { + scoped_terminal_group_id: terminal_group_default_api_key, + } credential_resolver = ScopedCredentialResolver(**resolver_kwargs) @@ -95,7 +76,7 @@ def _is_completed_event(event: object) -> bool: create_response = order_manager.create( order_request, - terminal_group_id=terminal_group_id, + terminal_group_id=scoped_terminal_group_id, ) order = create_response.get_data() @@ -115,7 +96,7 @@ def _is_completed_event(event: object) -> bool: print("Completed event detected. Fetching receipt...") receipt_response = pos_manager.get_receipt( order_id=order.order_id, - terminal_group_id=terminal_group_id, + terminal_group_id=scoped_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 index 18e7009..75ab404 100644 --- a/examples/terminal_group_manager/get_terminals_by_group.py +++ b/examples/terminal_group_manager/get_terminals_by_group.py @@ -17,21 +17,14 @@ ).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", - ) + # get_terminals_by_group → partner_affiliate scope → resolver returns + # partner_affiliate_api_key, falls back to default_api_key + resolver_kwargs = { + "default_api_key": default_account_api_key, + "partner_affiliate_api_key": partner_affiliate_api_key, + } - credential_resolver = ScopedCredentialResolver( - default_api_key=default_account_api_key, - partner_affiliate_api_key=partner_affiliate_api_key, - ) + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) multisafepay_sdk = Sdk( is_production=False, diff --git a/examples/terminal_manager/create.py b/examples/terminal_manager/create.py index d6520ad..414a136 100644 --- a/examples/terminal_manager/create.py +++ b/examples/terminal_manager/create.py @@ -20,23 +20,15 @@ ).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", - ) - + # create_terminal → default scope → resolver returns default_api_key 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, - ) + resolver_kwargs = { + "default_api_key": default_account_api_key, + "partner_affiliate_api_key": partner_affiliate_api_key, + } + + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) multisafepay_sdk = Sdk( is_production=False, diff --git a/examples/terminal_manager/get_terminals.py b/examples/terminal_manager/get_terminals.py index 2c1eb54..5708d2d 100644 --- a/examples/terminal_manager/get_terminals.py +++ b/examples/terminal_manager/get_terminals.py @@ -12,13 +12,14 @@ 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") + # get_terminals → partner_affiliate scope → resolver returns + # partner_affiliate_api_key, falls back to default_api_key + resolver_kwargs = { + "default_api_key": default_account_api_key, + "partner_affiliate_api_key": partner_affiliate_api_key, + } - credential_resolver = ScopedCredentialResolver( - default_api_key=default_account_api_key, - partner_affiliate_api_key=partner_affiliate_api_key, - ) + credential_resolver = ScopedCredentialResolver(**resolver_kwargs) multisafepay_sdk = Sdk( is_production=False, From 4b78ae4ecda865758f6a527eec7907ee2a224017 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Fri, 17 Apr 2026 16:51:01 +0200 Subject: [PATCH 3/3] PTHMINT-108: Add coverage for scoped auth changes --- .coveragerc | 1 + .../unit/api/base/test_unit_decorator.py | 91 ++++++++ .../unit/api/path/capture/__init__.py | 1 + .../path/capture/test_unit_capture_manager.py | 83 ++++++++ .../manager/test_unit_cancel_transaction.py | 119 +++++++++++ .../test_unit_order_manager_operations.py | 170 +++++++++++++++ .../refund/request/test_unit_checkout_data.py | 140 +++++++++++++ .../unit/api/path/pos/__init__.py | 1 + .../api/path/pos/test_unit_pos_manager.py | 194 ++++++++++++++++++ .../unit/api/path/recurring/__init__.py | 1 + .../recurring/test_unit_recurring_manager.py | 129 ++++++++++++ .../unit/api/path/terminal_groups/__init__.py | 1 + .../test_unit_terminal_group_manager.py | 107 ++++++++++ .../terminals/test_unit_terminal_manager.py | 150 ++++++++++++++ .../unit/client/test_unit_client.py | 68 ++++++ .../client/test_unit_credential_resolver.py | 38 ++++ tests/multisafepay/unit/test_unit_sdk.py | 57 +++++ 17 files changed, 1351 insertions(+) create mode 100644 tests/multisafepay/unit/api/path/capture/__init__.py create mode 100644 tests/multisafepay/unit/api/path/capture/test_unit_capture_manager.py create mode 100644 tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py create mode 100644 tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py create mode 100644 tests/multisafepay/unit/api/path/orders/order_id/refund/request/test_unit_checkout_data.py create mode 100644 tests/multisafepay/unit/api/path/pos/__init__.py create mode 100644 tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py create mode 100644 tests/multisafepay/unit/api/path/recurring/__init__.py create mode 100644 tests/multisafepay/unit/api/path/recurring/test_unit_recurring_manager.py create mode 100644 tests/multisafepay/unit/api/path/terminal_groups/__init__.py create mode 100644 tests/multisafepay/unit/api/path/terminal_groups/test_unit_terminal_group_manager.py create mode 100644 tests/multisafepay/unit/api/path/terminals/test_unit_terminal_manager.py diff --git a/.coveragerc b/.coveragerc index 0da7835..b7bfbd4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,3 +4,4 @@ source = tests omit = env/ + examples/ diff --git a/tests/multisafepay/unit/api/base/test_unit_decorator.py b/tests/multisafepay/unit/api/base/test_unit_decorator.py index 5065739..7a0e3d2 100644 --- a/tests/multisafepay/unit/api/base/test_unit_decorator.py +++ b/tests/multisafepay/unit/api/base/test_unit_decorator.py @@ -15,3 +15,94 @@ def test_initialization_with_empty_dependencies(): """Test the initialization of a Decorator object with empty dependencies.""" decorator = Decorator() assert decorator.get_dependencies() == {} + + +def test_adapt_costs_with_valid_data() -> None: + """Adapt costs from a list of cost dictionaries.""" + decorator = Decorator(dependencies={"key": "value"}) + costs = [{"amount": 100, "description": "Shipping"}] + result = decorator.adapt_costs(costs) + assert result is decorator + assert "costs" in decorator.get_dependencies() + + +def test_adapt_costs_with_none() -> None: + """Skip costs adaptation when input is None.""" + decorator = Decorator(dependencies={}) + decorator.adapt_costs(None) + assert "costs" not in decorator.get_dependencies() + + +def test_adapt_custom_info_with_valid_data() -> None: + """Adapt custom info from dictionary.""" + decorator = Decorator(dependencies={}) + decorator.adapt_custom_info({"custom_1": "val1"}) + assert "custom_info" in decorator.get_dependencies() + + +def test_adapt_customer_with_valid_data() -> None: + """Adapt customer from dictionary.""" + decorator = Decorator(dependencies={}) + decorator.adapt_customer({"first_name": "John", "last_name": "Doe"}) + assert "customer" in decorator.get_dependencies() + + +def test_adapt_payment_details_with_valid_data() -> None: + """Adapt payment details from dictionary.""" + decorator = Decorator(dependencies={}) + decorator.adapt_payment_details({"type": "VISA"}) + assert "payment_details" in decorator.get_dependencies() + + +def test_adapt_payment_methods_with_valid_data() -> None: + """Adapt payment methods from list of dictionaries.""" + decorator = Decorator(dependencies={}) + decorator.adapt_payment_methods([{"type": "VISA"}]) + assert "payment_methods" in decorator.get_dependencies() + + +def test_adapt_shopping_cart_with_valid_data() -> None: + """Adapt shopping cart from dictionary.""" + decorator = Decorator(dependencies={}) + decorator.adapt_shopping_cart({"items": []}) + assert "shopping_cart" in decorator.get_dependencies() + + +def test_adapt_related_transactions_with_valid_data() -> None: + """Adapt related transactions from list of dictionaries.""" + decorator = Decorator(dependencies={}) + decorator.adapt_related_transactions( + [{"transaction_id": 123, "order_id": "order-1"}], + ) + assert "related_transactions" in decorator.get_dependencies() + + +def test_adapt_checkout_options_with_valid_data() -> None: + """Adapt checkout options from dictionary.""" + decorator = Decorator(dependencies={}) + decorator.adapt_checkout_options({}) + assert "checkout_options" in decorator.get_dependencies() + + +def test_adapt_order_adjustment_with_valid_data() -> None: + """Adapt order adjustment from dictionary.""" + decorator = Decorator(dependencies={}) + decorator.adapt_order_adjustment({"total_adjustment": 0}) + assert "order_adjustment" in decorator.get_dependencies() + + +def test_chaining_multiple_adapters() -> None: + """Chain multiple adapt calls and get all dependencies.""" + deps = ( + Decorator(dependencies={"status": "completed"}) + .adapt_costs([{"amount": 10}]) + .adapt_custom_info({"custom_1": "a"}) + .adapt_payment_details({"type": "VISA"}) + .adapt_payment_methods([{"type": "VISA"}]) + .get_dependencies() + ) + assert "costs" in deps + assert "custom_info" in deps + assert "payment_details" in deps + assert "payment_methods" in deps + assert deps["status"] == "completed" diff --git a/tests/multisafepay/unit/api/path/capture/__init__.py b/tests/multisafepay/unit/api/path/capture/__init__.py new file mode 100644 index 0000000..761a9a9 --- /dev/null +++ b/tests/multisafepay/unit/api/path/capture/__init__.py @@ -0,0 +1 @@ +"""Unit tests for capture path package.""" diff --git a/tests/multisafepay/unit/api/path/capture/test_unit_capture_manager.py b/tests/multisafepay/unit/api/path/capture/test_unit_capture_manager.py new file mode 100644 index 0000000..18e424c --- /dev/null +++ b/tests/multisafepay/unit/api/path/capture/test_unit_capture_manager.py @@ -0,0 +1,83 @@ +# 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 CaptureManager.capture_reservation_cancel 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.capture.capture_manager import CaptureManager +from multisafepay.api.paths.capture.request.capture_request import ( + CaptureRequest, +) +from multisafepay.api.paths.capture.response.capture import CancelReservation + +ORDER_ID = "capture-order-1" + + +def _build_capture_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": ORDER_ID, + "success": True, + "transaction_id": "txn-001", + }, + }, + ) + + +def _build_empty_capture_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={"success": True, "data": {}}, + ) + + +def test_capture_reservation_cancel_sends_patch_and_parses() -> None: + """Send PATCH to json/capture/{order_id} and parse CancelReservation.""" + client = MagicMock() + client.create_patch_request.return_value = _build_capture_api_response() + + request = CaptureRequest(status="cancelled", reason="test") + + manager = CaptureManager(client) + response = manager.capture_reservation_cancel( + order_id=ORDER_ID, + capture_request=request, + ) + + called_endpoint = client.create_patch_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), CancelReservation) + assert response.get_data().order_id == ORDER_ID + assert f"json/capture/{ORDER_ID}" == called_endpoint + + +def test_capture_reservation_cancel_empty_data() -> None: + """Return None data when body data is empty.""" + client = MagicMock() + client.create_patch_request.return_value = _build_empty_capture_response() + + request = CaptureRequest(status="cancelled", reason="test") + + manager = CaptureManager(client) + response = manager.capture_reservation_cancel( + order_id=ORDER_ID, + capture_request=request, + ) + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py new file mode 100644 index 0000000..ff39274 --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_cancel_transaction.py @@ -0,0 +1,119 @@ +# 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 OrderManager.cancel_transaction 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_id.cancel.request.cancel_transaction_request import ( + CancelTransactionRequest, +) +from multisafepay.api.paths.orders.order_id.cancel.response.cancel_transaction import ( + CancelTransaction, +) +from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope + +ORDER_ID = "cloud-pos-cancel-1" +TERMINAL_GROUP_ID = "Default" + + +def _build_cancel_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "status": "void", + "financial_status": "void", + "created": "2026-01-01T00:00:00", + "modified": "2026-01-01T00:00:01", + }, + }, + ) + + +def test_cancel_transaction_with_terminal_group_scope() -> None: + """Use terminal-group auth scope when terminal_group_id is provided.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + manager = OrderManager(client) + response = manager.cancel_transaction( + cancel_transaction_request=ORDER_ID, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), CancelTransaction) + assert response.get_data().status == "void" + assert called_auth_scope == AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + + +def test_cancel_transaction_without_terminal_group_scope() -> None: + """Omit auth scope when terminal_group_id is not provided.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + manager = OrderManager(client) + response = manager.cancel_transaction( + cancel_transaction_request=ORDER_ID, + ) + + called_auth_scope = client.create_post_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert called_auth_scope is None + + +def test_cancel_transaction_accepts_request_object() -> None: + """Accept CancelTransactionRequest as input instead of raw string.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + request = CancelTransactionRequest(order_id=ORDER_ID) + + manager = OrderManager(client) + response = manager.cancel_transaction( + cancel_transaction_request=request, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert ORDER_ID in called_endpoint + assert called_endpoint.endswith("/cancel") + + +def test_cancel_transaction_encodes_order_id() -> None: + """Verify order ID with special chars is encoded in the endpoint.""" + client = MagicMock() + client.create_post_request.return_value = _build_cancel_api_response() + + manager = OrderManager(client) + manager.cancel_transaction( + cancel_transaction_request="order/special&chars", + ) + + called_endpoint = client.create_post_request.call_args.args[0] + assert "order%2Fspecial%26chars" in called_endpoint diff --git a/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py new file mode 100644 index 0000000..c6135d4 --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/manager/test_unit_order_manager_operations.py @@ -0,0 +1,170 @@ +# 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 OrderManager get, update, capture, and refund methods.""" + +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.response.order_response import Order + +ORDER_ID = "test-order-1" + + +def _build_order_api_response(order_id: str) -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": order_id, + "status": "completed", + }, + }, + ) + + +def _build_empty_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={"success": True, "data": {}}, + ) + + +def _build_capture_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "order_id": ORDER_ID, + "status": "completed", + }, + }, + ) + + +def _build_refund_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "refund_id": "refund-1", + "order_id": ORDER_ID, + }, + }, + ) + + +def test_get_order_by_id() -> None: + """Retrieve an order by its ID.""" + client = MagicMock() + client.create_get_request.return_value = _build_order_api_response( + ORDER_ID, + ) + + manager = OrderManager(client) + response = manager.get(order_id=ORDER_ID) + + called_endpoint = client.create_get_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Order) + assert response.get_data().order_id == ORDER_ID + assert ORDER_ID in called_endpoint + + +def test_get_order_encodes_special_chars_in_id() -> None: + """Verify order ID with special chars is encoded in the URL.""" + client = MagicMock() + client.create_get_request.return_value = _build_order_api_response( + "order/special", + ) + + manager = OrderManager(client) + manager.get(order_id="order/special") + + called_endpoint = client.create_get_request.call_args.args[0] + assert "order%2Fspecial" in called_endpoint + + +def test_get_order_returns_none_for_empty_data() -> None: + """Return None when body data is empty.""" + client = MagicMock() + client.create_get_request.return_value = _build_empty_api_response() + + manager = OrderManager(client) + response = manager.get(order_id=ORDER_ID) + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None + + +def test_update_order_sends_patch_request() -> None: + """Update sends PATCH to the correct endpoint.""" + client = MagicMock() + client.create_patch_request.return_value = _build_empty_api_response() + + update_request = MagicMock() + update_request.to_dict.return_value = {"description": "updated"} + + manager = OrderManager(client) + response = manager.update(order_id=ORDER_ID, update_request=update_request) + + called_endpoint = client.create_patch_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert ORDER_ID in called_endpoint + + +def test_capture_order_sends_post_and_parses_response() -> None: + """Capture sends POST and parses OrderCapture response.""" + client = MagicMock() + client.create_post_request.return_value = _build_capture_api_response() + + capture_request = MagicMock() + capture_request.to_dict.return_value = {"amount": 100} + + manager = OrderManager(client) + response = manager.capture( + order_id=ORDER_ID, + capture_request=capture_request, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert f"json/orders/{ORDER_ID}/capture" == called_endpoint + + +def test_refund_order_sends_post_and_parses_response() -> None: + """Refund sends POST and parses OrderRefund response.""" + client = MagicMock() + client.create_post_request.return_value = _build_refund_api_response() + + refund_request = MagicMock() + refund_request.to_dict.return_value = {"amount": 50} + + manager = OrderManager(client) + response = manager.refund( + order_id=ORDER_ID, + request_refund=refund_request, + ) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert f"json/orders/{ORDER_ID}/refunds" == called_endpoint diff --git a/tests/multisafepay/unit/api/path/orders/order_id/refund/request/test_unit_checkout_data.py b/tests/multisafepay/unit/api/path/orders/order_id/refund/request/test_unit_checkout_data.py new file mode 100644 index 0000000..7b477fe --- /dev/null +++ b/tests/multisafepay/unit/api/path/orders/order_id/refund/request/test_unit_checkout_data.py @@ -0,0 +1,140 @@ +# 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 CheckoutData refund component.""" + +import pytest + +from multisafepay.api.paths.orders.order_id.refund.request.components.checkout_data import ( + CheckoutData, +) +from multisafepay.api.shared.cart.cart_item import CartItem +from multisafepay.api.shared.cart.shopping_cart import ShoppingCart +from multisafepay.exception.invalid_argument import InvalidArgumentException + + +def _make_item( + merchant_item_id: str = "item-1", + quantity: int = 2, + unit_price: int = 500, +) -> CartItem: + return CartItem( + merchant_item_id=merchant_item_id, + name="Widget", + quantity=quantity, + unit_price=unit_price, + ) + + +def test_add_items_appends_to_list() -> None: + """add_items appends multiple items to checkout data.""" + cd = CheckoutData(items=None) + item1 = _make_item("a") + item2 = _make_item("b") + cd.add_items([item1, item2]) + assert len(cd.get_items()) == 2 + + +def test_add_items_with_none_is_noop() -> None: + """add_items with None leaves items unchanged.""" + cd = CheckoutData(items=None) + cd.add_items(None) + assert cd.get_items() is None + + +def test_add_item_creates_list_when_none() -> None: + """add_item initializes list when items is None.""" + cd = CheckoutData(items=None) + cd.add_item(_make_item()) + assert len(cd.get_items()) == 1 + + +def test_add_item_with_none_is_noop() -> None: + """add_item with None leaves items unchanged.""" + cd = CheckoutData(items=None) + cd.add_item(None) + assert cd.get_items() is None + + +def test_get_item_by_index() -> None: + """get_item retrieves item by index.""" + item = _make_item() + cd = CheckoutData(items=[item]) + assert cd.get_item(0).merchant_item_id == item.merchant_item_id + + +def test_get_item_returns_none_when_no_items() -> None: + """get_item returns None when items is None.""" + cd = CheckoutData(items=None) + assert cd.get_item(0) is None + + +def test_generate_from_shopping_cart() -> None: + """generate_from_shopping_cart populates items from cart.""" + item = _make_item() + cart = ShoppingCart(items=[item]) + cd = CheckoutData(items=None) + cd.generate_from_shopping_cart(cart) + assert len(cd.get_items()) == 1 + + +def test_generate_from_shopping_cart_with_tax_selector() -> None: + """generate_from_shopping_cart sets tax_table_selector on items.""" + item = _make_item() + cart = ShoppingCart(items=[item]) + cd = CheckoutData(items=None) + cd.generate_from_shopping_cart(cart, tax_table_selector="BTW21") + assert cd.get_items()[0].tax_table_selector == "BTW21" + + +def test_generate_from_shopping_cart_with_none_cart() -> None: + """generate_from_shopping_cart with None cart is noop.""" + cd = CheckoutData(items=None) + cd.generate_from_shopping_cart(None) + assert cd.get_items() is None + + +def test_refund_by_merchant_item_id() -> None: + """refund_by_merchant_item_id creates a negative refund item.""" + item = _make_item(merchant_item_id="item-1", quantity=3, unit_price=500) + cd = CheckoutData(items=[item]) + cd.refund_by_merchant_item_id("item-1", quantity=2) + assert len(cd.get_items()) == 2 + refund_item = cd.get_items()[1] + assert refund_item.unit_price == -500 + assert refund_item.quantity == 2 + + +def test_refund_by_merchant_item_id_defaults_to_full_quantity() -> None: + """refund_by_merchant_item_id uses full quantity when quantity=0.""" + item = _make_item(merchant_item_id="item-1", quantity=5, unit_price=100) + cd = CheckoutData(items=[item]) + cd.refund_by_merchant_item_id("item-1", quantity=0) + refund_item = cd.get_items()[1] + assert refund_item.quantity == 5 + + +def test_refund_by_merchant_item_id_raises_without_items() -> None: + """Raise when refunding with no items.""" + cd = CheckoutData(items=None) + with pytest.raises(InvalidArgumentException): + cd.refund_by_merchant_item_id("item-1") + + +def test_get_item_by_merchant_item_id_raises_when_not_found() -> None: + """Raise when merchant_item_id is not found.""" + item = _make_item(merchant_item_id="item-1") + cd = CheckoutData(items=[item]) + with pytest.raises(InvalidArgumentException, match="No item found"): + cd.get_item_by_merchant_item_id("nonexistent") + + +def test_get_item_by_merchant_item_id_raises_without_items() -> None: + """Raise when items is None.""" + cd = CheckoutData(items=None) + with pytest.raises(InvalidArgumentException, match="No items provided"): + cd.get_item_by_merchant_item_id("item-1") diff --git a/tests/multisafepay/unit/api/path/pos/__init__.py b/tests/multisafepay/unit/api/path/pos/__init__.py new file mode 100644 index 0000000..878ede5 --- /dev/null +++ b/tests/multisafepay/unit/api/path/pos/__init__.py @@ -0,0 +1 @@ +"""Unit tests for POS path package.""" diff --git a/tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py b/tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py new file mode 100644 index 0000000..0732780 --- /dev/null +++ b/tests/multisafepay/unit/api/path/pos/test_unit_pos_manager.py @@ -0,0 +1,194 @@ +# 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 PosManager.get_receipt 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.pos.pos_manager import PosManager +from multisafepay.api.paths.pos.receipt.response.receipt import Receipt +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope + +ORDER_ID = "cloud-pos-order-1" +TERMINAL_GROUP_ID = "Default" + + +def _build_receipt_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "merchant": {"name": "Test Merchant", "address": "123 St"}, + "order": { + "order_id": ORDER_ID, + "amount": 100, + "currency": "EUR", + "status": "completed", + "financial_status": "completed", + "created": "2026-01-01T00:00:00", + "modified": "2026-01-01T00:00:01", + "completed": "2026-01-01T00:00:02", + "amount_refunded": 0, + "transaction_id": 12345, + "items": [ + { + "name": "Widget", + "quantity": 1, + "unit_price": 100, + "item_price": 100, + "currency": "EUR", + }, + ], + "tip": [ + { + "amount": 50, + "employee": [ + {"id": "emp-1", "name": "Alice"}, + ], + }, + ], + }, + "payment": { + "payment_method": "VISA", + "last4": "1234", + "terminal_id": "T-001", + "authorization_code": 123456, + "application_id": "A001", + "card_acceptor_location": "NL", + "card_entry_mode": "contactless", + "card_expiry_date": "12/28", + "cardholder_verification_method": "pin", + "issuer_bin": "411111", + "issuer_country_code": "NL", + "response_code": "00", + }, + "printed_on": "2026-01-01T00:00:03", + "related_transactions": { + "amount": 100, + "created": "2026-01-01T00:00:00", + "currency": "EUR", + "description": "Refund", + "items": None, + "modified": "2026-01-01T00:00:01", + "order_id": ORDER_ID, + "reference_transaction_id": 99999, + "status": "completed", + "transaction_id": 12346, + "type": "refund", + }, + }, + }, + ) + + +def _build_empty_receipt_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={"success": True, "data": {}}, + ) + + +def test_get_receipt_with_terminal_group_scope() -> None: + """Use terminal-group auth scope when terminal_group_id is provided.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + response = manager.get_receipt( + order_id=ORDER_ID, + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_auth_scope = client.create_get_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Receipt) + assert response.get_data().printed_on == "2026-01-01T00:00:03" + assert called_auth_scope == AuthScope( + scope=Client.AUTH_SCOPE_TERMINAL_GROUP, + group_id=TERMINAL_GROUP_ID, + ) + + +def test_get_receipt_without_terminal_group_scope() -> None: + """Omit auth scope when terminal_group_id is not provided.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + response = manager.get_receipt(order_id=ORDER_ID) + + called_auth_scope = client.create_get_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Receipt) + assert called_auth_scope is None + + +def test_get_receipt_parses_nested_receipt_components() -> None: + """Verify receipt response parses all nested model components.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + response = manager.get_receipt( + order_id=ORDER_ID, + terminal_group_id=TERMINAL_GROUP_ID, + ) + receipt = response.get_data() + + assert receipt.merchant.name == "Test Merchant" + assert receipt.merchant.address == "123 St" + assert receipt.order.order_id == ORDER_ID + assert receipt.order.amount == 100 + assert len(receipt.order.items) == 1 + assert receipt.order.items[0].name == "Widget" + assert len(receipt.order.tip) == 1 + assert receipt.order.tip[0].amount == 50 + assert receipt.order.tip[0].employee[0].name == "Alice" + assert receipt.payment.payment_method == "VISA" + assert receipt.payment.last4 == "1234" + assert receipt.related_transactions.transaction_id == 12346 + assert receipt.related_transactions.type == "refund" + + +def test_get_receipt_returns_none_data_for_empty_body() -> None: + """Return None data when body data is empty.""" + client = MagicMock() + client.create_get_request.return_value = ( + _build_empty_receipt_api_response() + ) + + manager = PosManager(client) + response = manager.get_receipt(order_id=ORDER_ID) + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None + + +def test_get_receipt_encodes_order_id_in_endpoint() -> None: + """Verify order ID with special chars is encoded in the URL.""" + client = MagicMock() + client.create_get_request.return_value = _build_receipt_api_response() + + manager = PosManager(client) + manager.get_receipt(order_id="order/special&chars") + + called_endpoint = client.create_get_request.call_args.args[0] + assert "order%2Fspecial%26chars" in called_endpoint diff --git a/tests/multisafepay/unit/api/path/recurring/__init__.py b/tests/multisafepay/unit/api/path/recurring/__init__.py new file mode 100644 index 0000000..4c79c4b --- /dev/null +++ b/tests/multisafepay/unit/api/path/recurring/__init__.py @@ -0,0 +1 @@ +"""Unit tests for recurring path package.""" diff --git a/tests/multisafepay/unit/api/path/recurring/test_unit_recurring_manager.py b/tests/multisafepay/unit/api/path/recurring/test_unit_recurring_manager.py new file mode 100644 index 0000000..fe2056e --- /dev/null +++ b/tests/multisafepay/unit/api/path/recurring/test_unit_recurring_manager.py @@ -0,0 +1,129 @@ +# 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 RecurringManager get_list, get, and delete 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.recurring.recurring_manager import RecurringManager + +REFERENCE = "cust-ref-1" +TOKEN_VALUE = "tok-abc-123" + + +def _build_token_list_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "tokens": [ + { + "token": TOKEN_VALUE, + "code": "VISA", + "display": "Visa ***1234", + "bin": "411111", + "name_holder": "John Doe", + "expiry_date": "2512", + "is_expired": False, + "last_four": "1234", + "model": "cardOnFile", + }, + ], + }, + }, + ) + + +def _build_single_token_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "token": TOKEN_VALUE, + "code": "VISA", + "display": "Visa ***1234", + }, + }, + ) + + +def _build_empty_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={"success": True, "data": {}}, + ) + + +def test_get_list_returns_tokens() -> None: + """Get_list parses a list of Token objects.""" + client = MagicMock() + client.create_get_request.return_value = _build_token_list_response() + + manager = RecurringManager(client) + response = manager.get_list(reference=REFERENCE) + + called_endpoint = client.create_get_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), list) + assert len(response.get_data()) == 1 + assert response.get_data()[0].token == TOKEN_VALUE + assert f"json/recurring/{REFERENCE}" == called_endpoint + + +def test_get_returns_single_token() -> None: + """Get parses a single Token object.""" + client = MagicMock() + client.create_get_request.return_value = _build_single_token_response() + + manager = RecurringManager(client) + response = manager.get(token=TOKEN_VALUE, reference=REFERENCE) + + called_endpoint = client.create_get_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert response.get_data().token == TOKEN_VALUE + assert TOKEN_VALUE in called_endpoint + assert REFERENCE in called_endpoint + + +def test_get_returns_none_for_empty_data() -> None: + """Get returns None when body data is empty.""" + client = MagicMock() + client.create_get_request.return_value = _build_empty_response() + + manager = RecurringManager(client) + response = manager.get(token=TOKEN_VALUE, reference=REFERENCE) + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None + + +def test_delete_sends_delete_request() -> None: + """Delete sends DELETE to the correct endpoint.""" + client = MagicMock() + client.create_delete_request.return_value = _build_empty_response() + + manager = RecurringManager(client) + response = manager.delete(reference=REFERENCE, token=TOKEN_VALUE) + + called_endpoint = client.create_delete_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert response.get_data() is None + assert REFERENCE in called_endpoint + assert TOKEN_VALUE in called_endpoint + assert "remove" in called_endpoint diff --git a/tests/multisafepay/unit/api/path/terminal_groups/__init__.py b/tests/multisafepay/unit/api/path/terminal_groups/__init__.py new file mode 100644 index 0000000..1fb5ab7 --- /dev/null +++ b/tests/multisafepay/unit/api/path/terminal_groups/__init__.py @@ -0,0 +1 @@ +"""Unit tests for terminal group path package.""" diff --git a/tests/multisafepay/unit/api/path/terminal_groups/test_unit_terminal_group_manager.py b/tests/multisafepay/unit/api/path/terminal_groups/test_unit_terminal_group_manager.py new file mode 100644 index 0000000..7e935b4 --- /dev/null +++ b/tests/multisafepay/unit/api/path/terminal_groups/test_unit_terminal_group_manager.py @@ -0,0 +1,107 @@ +# 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 TerminalGroupManager.get_terminals_by_group 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.terminal_groups.terminal_group_manager import ( + TerminalGroupManager, +) +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope + +TERMINAL_GROUP_ID = "42" + + +def _build_listing_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": [ + { + "terminal_id": "T-001", + "name": "POS Terminal 1", + "group_id": 42, + "active": True, + "status": "active", + "provider": "CTAP", + }, + ], + "pager": { + "total": 1, + "offset": 0, + "limit": 10, + }, + }, + ) + + +def test_get_terminals_by_group_uses_partner_affiliate_scope() -> None: + """Send partner_affiliate auth scope for terminal group listing.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalGroupManager(client) + response = manager.get_terminals_by_group( + terminal_group_id=TERMINAL_GROUP_ID, + ) + + called_auth_scope = client.create_get_request.call_args.kwargs[ + "auth_scope" + ] + + assert isinstance(response, CustomApiResponse) + assert called_auth_scope == AuthScope( + scope=Client.AUTH_SCOPE_PARTNER_AFFILIATE, + ) + + +def test_get_terminals_by_group_encodes_group_id_in_endpoint() -> None: + """Verify terminal_group_id is included in the URL path.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalGroupManager(client) + manager.get_terminals_by_group(terminal_group_id=TERMINAL_GROUP_ID) + + called_endpoint = client.create_get_request.call_args.kwargs["endpoint"] + assert TERMINAL_GROUP_ID in called_endpoint + assert "json/terminal-groups/" in called_endpoint + + +def test_get_terminals_by_group_filters_options() -> None: + """Only pass allowed options (page, limit) to the API.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalGroupManager(client) + manager.get_terminals_by_group( + terminal_group_id=TERMINAL_GROUP_ID, + options={"page": 2, "limit": 5, "foo": "bar"}, + ) + + called_params = client.create_get_request.call_args.kwargs["params"] + assert called_params == {"page": 2, "limit": 5} + + +def test_get_terminals_by_group_defaults_empty_options() -> None: + """Use empty options dict when no options are provided.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalGroupManager(client) + manager.get_terminals_by_group(terminal_group_id=TERMINAL_GROUP_ID) + + called_params = client.create_get_request.call_args.kwargs["params"] + assert called_params == {} diff --git a/tests/multisafepay/unit/api/path/terminals/test_unit_terminal_manager.py b/tests/multisafepay/unit/api/path/terminals/test_unit_terminal_manager.py new file mode 100644 index 0000000..3656878 --- /dev/null +++ b/tests/multisafepay/unit/api/path/terminals/test_unit_terminal_manager.py @@ -0,0 +1,150 @@ +# 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 TerminalManager.create_terminal and get_terminals 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.terminals.request.create_terminal_request import ( + CreateTerminalRequest, +) +from multisafepay.api.paths.terminals.response.terminal import Terminal +from multisafepay.api.paths.terminals.terminal_manager import TerminalManager +from multisafepay.client.client import Client +from multisafepay.client.credential_resolver import AuthScope + + +def _build_terminal_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": { + "terminal_id": "T-001", + "name": "Demo POS Terminal", + "group_id": 42, + "active": True, + "status": "active", + "provider": "CTAP", + }, + }, + ) + + +def _build_listing_api_response() -> ApiResponse: + return ApiResponse( + headers={}, + status_code=200, + body={ + "success": True, + "data": [ + { + "terminal_id": "T-001", + "name": "Terminal 1", + "group_id": 42, + "active": True, + "status": "active", + "provider": "CTAP", + }, + ], + "pager": { + "total": 1, + "offset": 0, + "limit": 10, + }, + }, + ) + + +def test_create_terminal_sends_post_to_correct_endpoint() -> None: + """create_terminal posts to json/terminals with no auth scope.""" + client = MagicMock() + client.create_post_request.return_value = _build_terminal_api_response() + + request = ( + CreateTerminalRequest() + .add_provider("CTAP") + .add_group_id(42) + .add_name("Demo POS Terminal") + ) + + manager = TerminalManager(client) + response = manager.create_terminal(request) + + called_endpoint = client.create_post_request.call_args.args[0] + + assert isinstance(response, CustomApiResponse) + assert isinstance(response.get_data(), Terminal) + assert response.get_data().terminal_id == "T-001" + assert called_endpoint == "json/terminals" + + +def test_create_terminal_serializes_request_body() -> None: + """Verify the request body is serialized as JSON.""" + client = MagicMock() + client.create_post_request.return_value = _build_terminal_api_response() + + request = ( + CreateTerminalRequest() + .add_provider("CTAP") + .add_group_id(42) + .add_name("Demo POS Terminal") + ) + + manager = TerminalManager(client) + manager.create_terminal(request) + + called_body = client.create_post_request.call_args.kwargs["request_body"] + assert '"provider": "CTAP"' in called_body + assert '"group_id": 42' in called_body + + +def test_get_terminals_uses_partner_affiliate_scope() -> None: + """get_terminals sends partner_affiliate auth scope.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalManager(client) + response = manager.get_terminals(options={"page": 1, "limit": 10}) + + called_auth_scope = client.create_get_request.call_args.kwargs.get( + "auth_scope", + ) + + assert isinstance(response, CustomApiResponse) + assert called_auth_scope == AuthScope( + scope=Client.AUTH_SCOPE_PARTNER_AFFILIATE, + ) + + +def test_get_terminals_filters_options() -> None: + """Only pass allowed options (page, limit) to the API.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalManager(client) + manager.get_terminals(options={"page": 1, "limit": 5, "invalid": "x"}) + + called_params = client.create_get_request.call_args.args[1] + assert called_params == {"page": 1, "limit": 5} + + +def test_get_terminals_defaults_empty_options() -> None: + """Use empty options dict when no options are provided.""" + client = MagicMock() + client.create_get_request.return_value = _build_listing_api_response() + + manager = TerminalManager(client) + manager.get_terminals() + + called_params = client.create_get_request.call_args.args[1] + assert called_params == {} diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 80e30e6..b4bbc90 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -262,3 +262,71 @@ def test_rejects_custom_base_url_without_netloc( is_production=False, base_url="https:///v1", ) + + +def test_create_get_request_sends_authorization_header() -> None: + """GET request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_get_request("json/orders") + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_create_post_request_sends_authorization_header() -> None: + """POST request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_post_request("json/orders", request_body='{"foo":"bar"}') + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_create_patch_request_sends_authorization_header() -> None: + """PATCH request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_patch_request("json/orders/1", request_body='{"foo":"bar"}') + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_create_delete_request_sends_authorization_header() -> None: + """DELETE request includes Bearer authorization header.""" + transport = _CaptureTransport() + client = Client( + api_key="test_key", + is_production=False, + transport=transport, + ) + client.create_delete_request("json/recurring/1") + assert transport.headers["Authorization"] == "Bearer test_key" + + +def test_resolve_api_key_uses_credential_resolver() -> None: + """Prefer credential resolver when both api_key and resolver exist.""" + transport = _CaptureTransport() + resolver = ScopedCredentialResolver(default_api_key="resolver_key") + client = _build_resolver_client(resolver, transport) + client.create_get_request("json/orders") + assert transport.headers["Authorization"] == "Bearer resolver_key" + + +def test_resolve_api_key_raises_without_key_or_resolver() -> None: + """Raise ValueError when no api_key or resolver is configured.""" + with pytest.raises(ValueError, match=API_KEY_REQUIRED_ERROR): + Client( + api_key=None, + is_production=False, + transport=_CaptureTransport(), + credential_resolver=None, + ) diff --git a/tests/multisafepay/unit/client/test_unit_credential_resolver.py b/tests/multisafepay/unit/client/test_unit_credential_resolver.py index 2d9d2b7..52d7f0e 100644 --- a/tests/multisafepay/unit/client/test_unit_credential_resolver.py +++ b/tests/multisafepay/unit/client/test_unit_credential_resolver.py @@ -94,3 +94,41 @@ def test_raises_for_default_scope_without_default_key() -> None: match=NO_DEFAULT_SCOPE_ERROR, ): resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) + + +def test_resolves_partner_scope_falls_back_to_default_key() -> None: + """Fall back to default_api_key for partner scope when no partner key.""" + resolver = ScopedCredentialResolver(default_api_key=DEFAULT_API_KEY) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE) + == DEFAULT_API_KEY + ) + + +def test_raises_for_unknown_terminal_group_id() -> None: + """Reject terminal_group scope when the group_id is not configured.""" + resolver = _resolver_with_terminal_group() + + with pytest.raises(ValueError, match="No API key configured"): + resolver.resolve( + ScopedCredentialResolver.AUTH_SCOPE_TERMINAL_GROUP, + group_id="unknown_group", + ) + + +def test_strips_whitespace_from_api_keys() -> None: + """Strip leading/trailing whitespace from provided API keys.""" + resolver = ScopedCredentialResolver( + default_api_key=" key_with_spaces ", + partner_affiliate_api_key=" partner_spaces ", + ) + + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_DEFAULT) + == "key_with_spaces" + ) + assert ( + resolver.resolve(ScopedCredentialResolver.AUTH_SCOPE_PARTNER_AFFILIATE) + == "partner_spaces" + ) diff --git a/tests/multisafepay/unit/test_unit_sdk.py b/tests/multisafepay/unit/test_unit_sdk.py index a7fa84c..6ffd983 100644 --- a/tests/multisafepay/unit/test_unit_sdk.py +++ b/tests/multisafepay/unit/test_unit_sdk.py @@ -12,7 +12,14 @@ import pytest from multisafepay import Sdk +from multisafepay.api.paths.capture.capture_manager import CaptureManager from multisafepay.api.paths.events.event_manager import EventManager +from multisafepay.api.paths.orders.order_manager import OrderManager +from multisafepay.api.paths.pos.pos_manager import PosManager +from multisafepay.api.paths.terminal_groups.terminal_group_manager import ( + TerminalGroupManager, +) +from multisafepay.api.paths.terminals.terminal_manager import TerminalManager from multisafepay.client.client import Client from multisafepay.client.credential_resolver import ScopedCredentialResolver @@ -151,3 +158,53 @@ def test_sdk_uses_credential_resolver_with_custom_transport() -> None: assert sdk.get_client().transport is transport assert transport.headers["Authorization"] == f"Bearer {DEFAULT_API_KEY}" + + +def test_sdk_returns_order_manager() -> None: + """Expose OrderManager through SDK convenience getter.""" + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + transport=MagicMock(), + ) + assert isinstance(sdk.get_order_manager(), OrderManager) + + +def test_sdk_returns_terminal_manager() -> None: + """Expose TerminalManager through SDK convenience getter.""" + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + transport=MagicMock(), + ) + assert isinstance(sdk.get_terminal_manager(), TerminalManager) + + +def test_sdk_returns_terminal_group_manager() -> None: + """Expose TerminalGroupManager through SDK convenience getter.""" + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + transport=MagicMock(), + ) + assert isinstance(sdk.get_terminal_group_manager(), TerminalGroupManager) + + +def test_sdk_returns_pos_manager() -> None: + """Expose PosManager through SDK convenience getter.""" + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + transport=MagicMock(), + ) + assert isinstance(sdk.get_pos_manager(), PosManager) + + +def test_sdk_returns_capture_manager() -> None: + """Expose CaptureManager through SDK convenience getter.""" + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + transport=MagicMock(), + ) + assert isinstance(sdk.get_capture_manager(), CaptureManager)