From 446bd2cab6c1e8c64efbf73aa69a232a42b83e64 Mon Sep 17 00:00:00 2001 From: Marco Gil Date: Thu, 17 Jul 2025 13:23:29 +0200 Subject: [PATCH 1/2] Pthmint 74 (#23) --- src/multisafepay/api/base/abstract_manager.py | 17 ++ .../api/paths/capture/capture_manager.py | 3 +- .../api/paths/gateways/gateway_manager.py | 3 +- .../api/paths/issuers/issuer_manager.py | 3 +- .../api/paths/orders/order_manager.py | 13 +- .../payment_methods/payment_method_manager.py | 3 +- .../api/paths/recurring/recurring_manager.py | 11 +- .../unit/api/base/test_abstract_manager.py | 163 ++++++++++++++++++ 8 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 tests/multisafepay/unit/api/base/test_abstract_manager.py diff --git a/src/multisafepay/api/base/abstract_manager.py b/src/multisafepay/api/base/abstract_manager.py index 52981d9..a5241b5 100644 --- a/src/multisafepay/api/base/abstract_manager.py +++ b/src/multisafepay/api/base/abstract_manager.py @@ -5,6 +5,7 @@ # See the DISCLAIMER.md file for disclaimer details. +import urllib.parse from multisafepay.client.client import Client @@ -29,3 +30,19 @@ def __init__(self: "AbstractManager", client: Client) -> None: """ self.client = client + + @staticmethod + def encode_path_segment(segment: str) -> str: + """ + URL encode a path segment to be safely included in a URL. + + Parameters + ---------- + segment (str): The path segment to encode + + Returns + ------- + str: The URL encoded path segment + + """ + return urllib.parse.quote(str(segment), safe="") diff --git a/src/multisafepay/api/paths/capture/capture_manager.py b/src/multisafepay/api/paths/capture/capture_manager.py index 1837f27..833bf61 100644 --- a/src/multisafepay/api/paths/capture/capture_manager.py +++ b/src/multisafepay/api/paths/capture/capture_manager.py @@ -59,8 +59,9 @@ def capture_reservation_cancel( """ json_data = json.dumps(capture_request.dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_patch_request( - f"json/capture/{order_id}", + f"json/capture/{encoded_order_id}", request_body=json_data, ) args: dict = { diff --git a/src/multisafepay/api/paths/gateways/gateway_manager.py b/src/multisafepay/api/paths/gateways/gateway_manager.py index fe427f2..bd12064 100644 --- a/src/multisafepay/api/paths/gateways/gateway_manager.py +++ b/src/multisafepay/api/paths/gateways/gateway_manager.py @@ -102,8 +102,9 @@ def get_by_code( options = {} options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS} + encoded_gateway_code = self.encode_path_segment(gateway_code) response = self.client.create_get_request( - f"json/gateways/{gateway_code}", + f"json/gateways/{encoded_gateway_code}", options, ) args: dict = { diff --git a/src/multisafepay/api/paths/issuers/issuer_manager.py b/src/multisafepay/api/paths/issuers/issuer_manager.py index cf630f6..2e21698 100644 --- a/src/multisafepay/api/paths/issuers/issuer_manager.py +++ b/src/multisafepay/api/paths/issuers/issuer_manager.py @@ -59,8 +59,9 @@ def get_issuers_by_gateway_code( if gateway_code not in ALLOWED_GATEWAY_CODES: raise InvalidArgumentException("Gateway code is not allowed") + encoded_gateway_code = self.encode_path_segment(gateway_code) response = self.client.create_get_request( - f"json/issuers/{gateway_code}", + f"json/issuers/{encoded_gateway_code}", ) args: dict = { **response.dict(), diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index eeefcfd..8fcd46c 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -102,7 +102,8 @@ def get(self: "OrderManager", order_id: str) -> CustomApiResponse: CustomApiResponse: The custom API response containing the order data. """ - endpoint = f"json/orders/{order_id}" + encoded_order_id = self.encode_path_segment(order_id) + endpoint = f"json/orders/{encoded_order_id}" context = {"order_id": order_id} response: ApiResponse = self.client.create_get_request( endpoint, @@ -152,8 +153,9 @@ def update( """ json_data = json.dumps(update_request.to_dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_patch_request( - f"json/orders/{order_id}", + f"json/orders/{encoded_order_id}", request_body=json_data, ) args: dict = { @@ -181,9 +183,10 @@ def capture( """ json_data = json.dumps(capture_request.to_dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_post_request( - f"json/orders/{order_id}/capture", + f"json/orders/{encoded_order_id}/capture", request_body=json_data, ) args: dict = { @@ -221,8 +224,9 @@ def refund( """ json_data = json.dumps(request_refund.to_dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_post_request( - f"json/orders/{order_id}/refunds", + f"json/orders/{encoded_order_id}/refunds", request_body=json_data, ) args: dict = { @@ -267,6 +271,7 @@ def refund_by_item( quantity, ) + # Encode the order_id before calling refund return self.refund(order.order_id, request_refund) @staticmethod diff --git a/src/multisafepay/api/paths/payment_methods/payment_method_manager.py b/src/multisafepay/api/paths/payment_methods/payment_method_manager.py index 31bc140..df43f43 100644 --- a/src/multisafepay/api/paths/payment_methods/payment_method_manager.py +++ b/src/multisafepay/api/paths/payment_methods/payment_method_manager.py @@ -126,8 +126,9 @@ def get_by_gateway_code( if options is None: options = {} options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS} + encoded_gateway_code = self.encode_path_segment(gateway_code) response = self.client.create_get_request( - f"json/payment-methods/{gateway_code}", + f"json/payment-methods/{encoded_gateway_code}", options, ) args: dict = { diff --git a/src/multisafepay/api/paths/recurring/recurring_manager.py b/src/multisafepay/api/paths/recurring/recurring_manager.py index ad6e707..1b22a60 100644 --- a/src/multisafepay/api/paths/recurring/recurring_manager.py +++ b/src/multisafepay/api/paths/recurring/recurring_manager.py @@ -64,8 +64,9 @@ def get_list( CustomApiResponse: The response containing the list of tokens. """ + encoded_reference = self.encode_path_segment(reference) response: ApiResponse = self.client.create_get_request( - f"json/recurring/{reference}", + f"json/recurring/{encoded_reference}", ) args: dict = { **response.dict(), @@ -109,8 +110,10 @@ def get( CustomApiResponse: The response containing the token data. """ + encoded_reference = self.encode_path_segment(reference) + encoded_token = self.encode_path_segment(token) response = self.client.create_get_request( - f"json/recurring/{reference}/token/{token}", + f"json/recurring/{encoded_reference}/token/{encoded_token}", ) args: dict = { **response.dict(), @@ -144,8 +147,10 @@ def delete( CustomApiResponse: The response after deleting the token. """ + encoded_reference = self.encode_path_segment(reference) + encoded_token = self.encode_path_segment(token) response = self.client.create_delete_request( - f"json/recurring/{reference}/remove/{token}", + f"json/recurring/{encoded_reference}/remove/{encoded_token}", ) args: dict = { **response.dict(), diff --git a/tests/multisafepay/unit/api/base/test_abstract_manager.py b/tests/multisafepay/unit/api/base/test_abstract_manager.py new file mode 100644 index 0000000..7de1891 --- /dev/null +++ b/tests/multisafepay/unit/api/base/test_abstract_manager.py @@ -0,0 +1,163 @@ +# 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. + + +from multisafepay.api.base.abstract_manager import AbstractManager + + +def test_encode_path_segment_with_normal_string(): + """Test encoding a normal string without special characters.""" + result = AbstractManager.encode_path_segment("normal_string") + assert result == "normal_string" + + +def test_encode_path_segment_with_spaces(): + """Test encoding a string with spaces.""" + result = AbstractManager.encode_path_segment("hello world") + assert result == "hello%20world" + + +def test_encode_path_segment_with_special_characters(): + """Test encoding a string with various special characters.""" + result = AbstractManager.encode_path_segment("hello@world#test") + assert result == "hello%40world%23test" + + +def test_encode_path_segment_with_forward_slash(): + """Test encoding a string with forward slashes.""" + result = AbstractManager.encode_path_segment("path/to/resource") + assert result == "path%2Fto%2Fresource" + + +def test_encode_path_segment_with_question_mark(): + """Test encoding a string with question marks.""" + result = AbstractManager.encode_path_segment("query?param=value") + assert result == "query%3Fparam%3Dvalue" + + +def test_encode_path_segment_with_ampersand(): + """Test encoding a string with ampersands.""" + result = AbstractManager.encode_path_segment("param1¶m2") + assert result == "param1%26param2" + + +def test_encode_path_segment_with_equals_sign(): + """Test encoding a string with equals signs.""" + result = AbstractManager.encode_path_segment("key=value") + assert result == "key%3Dvalue" + + +def test_encode_path_segment_with_percentage_sign(): + """Test encoding a string with percentage signs.""" + result = AbstractManager.encode_path_segment("discount%off") + assert result == "discount%25off" + + +def test_encode_path_segment_with_plus_sign(): + """Test encoding a string with plus signs.""" + result = AbstractManager.encode_path_segment("one+two") + assert result == "one%2Btwo" + + +def test_encode_path_segment_with_unicode_characters(): + """Test encoding a string with Unicode characters.""" + result = AbstractManager.encode_path_segment("café") + assert result == "caf%C3%A9" + + +def test_encode_path_segment_with_emoji(): + """Test encoding a string with emoji characters.""" + result = AbstractManager.encode_path_segment("hello😊world") + assert result == "hello%F0%9F%98%8Aworld" + + +def test_encode_path_segment_with_empty_string(): + """Test encoding an empty string.""" + result = AbstractManager.encode_path_segment("") + assert result == "" + + +def test_encode_path_segment_with_only_special_characters(): + """Test encoding a string with only special characters.""" + result = AbstractManager.encode_path_segment("!@#$%^&*()") + assert result == "%21%40%23%24%25%5E%26%2A%28%29" + + +def test_encode_path_segment_with_numbers(): + """Test encoding a string with numbers.""" + result = AbstractManager.encode_path_segment("123456") + assert result == "123456" + + +def test_encode_path_segment_with_mixed_alphanumeric(): + """Test encoding a string with mixed alphanumeric characters.""" + result = AbstractManager.encode_path_segment("abc123XYZ") + assert result == "abc123XYZ" + + +def test_encode_path_segment_with_hyphen_and_underscore(): + """Test encoding a string with hyphens and underscores (safe characters).""" + result = AbstractManager.encode_path_segment("test-value_123") + assert result == "test-value_123" + + +def test_encode_path_segment_with_period_and_tilde(): + """Test encoding a string with periods and tildes (safe characters).""" + result = AbstractManager.encode_path_segment("file.txt~backup") + assert result == "file.txt~backup" + + +def test_encode_path_segment_with_integer_input(): + """Test encoding an integer input (should be converted to string).""" + result = AbstractManager.encode_path_segment(12345) + assert result == "12345" + + +def test_encode_path_segment_with_float_input(): + """Test encoding a float input (should be converted to string).""" + result = AbstractManager.encode_path_segment(123.45) + assert result == "123.45" + + +def test_encode_path_segment_with_none_input(): + """Test encoding None input (should be converted to string).""" + result = AbstractManager.encode_path_segment(None) + assert result == "None" + + +def test_encode_path_segment_preserves_unreserved_characters(): + """Test that unreserved characters are not encoded.""" + # RFC 3986 unreserved characters: ALPHA / DIGIT / "-" / "." / "_" / "~" + unreserved = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + result = AbstractManager.encode_path_segment(unreserved) + assert result == unreserved + + +def test_encode_path_segment_encodes_reserved_characters(): + """Test that reserved characters are properly encoded.""" + # Some RFC 3986 reserved characters + reserved = ":/?#[]@!$&'()*+,;=" + result = AbstractManager.encode_path_segment(reserved) + # All characters should be encoded since safe="" is used + assert ":" not in result + assert "/" not in result + assert "?" not in result + assert "#" not in result + assert "@" not in result + assert "!" not in result + assert "$" not in result + assert "&" not in result + assert "'" not in result + assert "(" not in result + assert ")" not in result + assert "*" not in result + assert "+" not in result + assert "," not in result + assert ";" not in result + assert "=" not in result From d323a0eefc979521e9fe2127fbf028e9a433c50b Mon Sep 17 00:00:00 2001 From: Marco Gil Date: Thu, 17 Jul 2025 14:25:42 +0200 Subject: [PATCH 2/2] PTHMINT-75: Remove unsupported attribute in docs (delivery) --- src/multisafepay/api/shared/delivery.py | 83 ------------------- .../unit/api/shared/test_unit_delivery.py | 50 ----------- 2 files changed, 133 deletions(-) diff --git a/src/multisafepay/api/shared/delivery.py b/src/multisafepay/api/shared/delivery.py index 9ec58ee..dcb8d14 100644 --- a/src/multisafepay/api/shared/delivery.py +++ b/src/multisafepay/api/shared/delivery.py @@ -31,9 +31,6 @@ class Delivery(ApiModel): country (Optional[str]): The country code. phone (Optional[str]): The phone number. email (Optional[str]): The email address. - street_name (Optional[str]): The street name. - street_name_additional (Optional[str]): Additional street name information. - house_number_suffix (Optional[str]): The house number suffix. """ @@ -48,10 +45,6 @@ class Delivery(ApiModel): country: Optional[str] phone: Optional[str] email: Optional[str] - street_name: Optional[str] - street_name_additional: Optional[str] - house_number_suffix: Optional[str] - country_name: Optional[str] def add_first_name( self: "Delivery", @@ -253,82 +246,6 @@ def add_email( self.email = email.get() return self - def add_street_name( - self: "Delivery", - street_name: Optional[str], - ) -> "Delivery": - """ - Add the street name to the delivery information. - - Parameters - ---------- - street_name (Optional[str]): The street name to add. - - Returns - ------- - Delivery: The updated Delivery instance. - - """ - self.street_name = street_name - return self - - def add_street_name_additional( - self: "Delivery", - street_name_additional: Optional[str], - ) -> "Delivery": - """ - Add additional street name information to the delivery information. - - Parameters - ---------- - street_name_additional (Optional[str]): The additional street name information to add. - - Returns - ------- - Delivery: The updated Delivery instance. - - """ - self.street_name_additional = street_name_additional - return self - - def add_house_number_suffix( - self: "Delivery", - house_number_suffix: Optional[str], - ) -> "Delivery": - """ - Add the house number suffix to the delivery information. - - Parameters - ---------- - house_number_suffix (Optional[str]): The house number suffix to add. - - Returns - ------- - Delivery: The updated Delivery instance. - - """ - self.house_number_suffix = house_number_suffix - return self - - def add_country_name( - self: "Delivery", - country_name: Optional[str], - ) -> "Delivery": - """ - Add the country name to the delivery information. - - Parameters - ---------- - country_name (Optional[str]): The country name to add. - - Returns - ------- - Delivery: The updated Delivery instance. - - """ - self.country_name = country_name - return self - @staticmethod def from_dict(d: Optional[dict]) -> Optional["Delivery"]: """ diff --git a/tests/multisafepay/unit/api/shared/test_unit_delivery.py b/tests/multisafepay/unit/api/shared/test_unit_delivery.py index 98f82b4..f05b1ed 100644 --- a/tests/multisafepay/unit/api/shared/test_unit_delivery.py +++ b/tests/multisafepay/unit/api/shared/test_unit_delivery.py @@ -25,9 +25,6 @@ def test_initializes_with_valid_values(): country="USA", phone="555-1234", email="example@multisafepay.com", - street_name="Main St", - street_name_additional="Near the park", - house_number_suffix="A", ) assert delivery.first_name == "John" assert delivery.last_name == "Doe" @@ -40,9 +37,6 @@ def test_initializes_with_valid_values(): assert delivery.country == "USA" assert delivery.phone == "555-1234" assert delivery.email == "example@multisafepay.com" - assert delivery.street_name == "Main St" - assert delivery.street_name_additional == "Near the park" - assert delivery.house_number_suffix == "A" def test_initializes_with_default_values(): @@ -61,9 +55,6 @@ def test_initializes_with_default_values(): assert delivery.country is None assert delivery.phone is None assert delivery.email is None - assert delivery.street_name is None - assert delivery.street_name_additional is None - assert delivery.house_number_suffix is None def test_adds_first_name(): @@ -130,38 +121,6 @@ def test_adds_state(): assert delivery.state == "CA" -def test_adds_street_name(): - """ - Test that a street name is added to the Delivery instance. - """ - delivery = Delivery().add_street_name("Main St") - assert delivery.street_name == "Main St" - - -def test_adds_street_name_as_string(): - """ - Test that a street name as a string is added to the Delivery instance. - """ - delivery = Delivery().add_street_name("Main St") - assert delivery.street_name == "Main St" - - -def test_adds_street_name_additional(): - """ - Test that additional street name information is added to the Delivery instance. - """ - delivery = Delivery().add_street_name_additional("Near the park") - assert delivery.street_name_additional == "Near the park" - - -def test_adds_house_number_suffix(): - """ - Test that a house number suffix is added to the Delivery instance. - """ - delivery = Delivery().add_house_number_suffix("A") - assert delivery.house_number_suffix == "A" - - def test_creates_from_dict_with_all_fields(): """ Test that a Delivery instance is created from a dictionary with all fields. @@ -178,9 +137,6 @@ def test_creates_from_dict_with_all_fields(): "country": "USA", "phone": "555-1234", "email": "john.doe@example.com", - "street_name": "Main St", - "street_name_additional": "Near the park", - "house_number_suffix": "A", } delivery = Delivery.from_dict(data) assert delivery.first_name == "John" @@ -194,9 +150,6 @@ def test_creates_from_dict_with_all_fields(): assert delivery.country == "USA" assert delivery.phone == "555-1234" assert delivery.email == "john.doe@example.com" - assert delivery.street_name == "Main St" - assert delivery.street_name_additional == "Near the park" - assert delivery.house_number_suffix == "A" def test_creates_from_empty_dict(): @@ -216,9 +169,6 @@ def test_creates_from_empty_dict(): assert delivery.country is None assert delivery.phone is None assert delivery.email is None - assert delivery.street_name is None - assert delivery.street_name_additional is None - assert delivery.house_number_suffix is None def test_creates_from_none():