diff --git a/etsy_python/v3/models/Listing.py b/etsy_python/v3/models/Listing.py index f178ee5..cf08f49 100644 --- a/etsy_python/v3/models/Listing.py +++ b/etsy_python/v3/models/Listing.py @@ -14,6 +14,42 @@ from etsy_python.v3.models.FileRequest import FileRequest +_PERSONALIZATION_DEPRECATION_MSG = ( + "is_personalizable, personalization_is_required, " + "personalization_char_count_max, and personalization_instructions " + "are deprecated by the Etsy API and scheduled for removal. " + "Use the personalization endpoint (update_listing_personalization) " + "instead. See " + "https://developers.etsy.com/documentation/tutorials/personalization-migration " + "for migration details." +) + + +def _warn_if_personalization_used( + is_personalizable: Optional[bool], + personalization_is_required: Optional[bool], + personalization_char_count_max: Optional[int], + personalization_instructions: Optional[str], +) -> None: + """Emit DeprecationWarning when a deprecated personalization field is actively set. + + Falsy values (False, 0, empty string, None) match the API's documented + defaults and remain no-ops after the fields are removed, so they do not + trigger the warning. + """ + if any([ + is_personalizable, + personalization_is_required, + personalization_char_count_max, + personalization_instructions, + ]): + warnings.warn( + _PERSONALIZATION_DEPRECATION_MSG, + DeprecationWarning, + stacklevel=3, + ) + + class CreateDraftListingRequest(Request): nullable = [ "shipping_profile_id", @@ -105,20 +141,12 @@ def __init__( self.personalization_is_required = personalization_is_required self.personalization_char_count_max = personalization_char_count_max self.personalization_instructions = personalization_instructions - if any(v is not None for v in [ - personalization_is_required, personalization_char_count_max, + _warn_if_personalization_used( + is_personalizable, + personalization_is_required, + personalization_char_count_max, personalization_instructions, - ]): - warnings.warn( - "personalization_is_required, personalization_char_count_max, and " - "personalization_instructions are deprecated by the Etsy API and " - "scheduled for removal. Use the personalization endpoint " - "(update_listing_personalization) instead. See " - "https://developers.etsy.com/documentation/tutorials/personalization-migration " - "for migration details.", - DeprecationWarning, - stacklevel=2, - ) + ) self.production_partner_ids = production_partner_ids self.image_ids = image_ids self.is_supply = is_supply @@ -208,20 +236,12 @@ def __init__( self.personalization_is_required = personalization_is_required self.personalization_char_count_max = personalization_char_count_max self.personalization_instructions = personalization_instructions - if any(v is not None for v in [ - personalization_is_required, personalization_char_count_max, + _warn_if_personalization_used( + is_personalizable, + personalization_is_required, + personalization_char_count_max, personalization_instructions, - ]): - warnings.warn( - "personalization_is_required, personalization_char_count_max, and " - "personalization_instructions are deprecated by the Etsy API and " - "scheduled for removal. Use the personalization endpoint " - "(update_listing_personalization) instead. See " - "https://developers.etsy.com/documentation/tutorials/personalization-migration " - "for migration details.", - DeprecationWarning, - stacklevel=2, - ) + ) self.state = state self.is_supply = is_supply self.production_partner_ids = production_partner_ids diff --git a/specs/baseline.json b/specs/baseline.json index b5d9280..eef3c8c 100644 --- a/specs/baseline.json +++ b/specs/baseline.json @@ -434,7 +434,7 @@ }, "styles": { "type": "array", - "description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Default value is null.", + "description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Each style string is limited to 45 characters. Default value is null.", "nullable": true, "items": { "type": "string" @@ -2423,7 +2423,7 @@ }, "put": { "operationId": "updateListingInventory", - "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nUpdates the inventory for a listing identified by a listing ID. The update fails if the supplied values for product sku, offering quantity, and/or price are incompatible with values in `*_on_property` fields. When setting a price, assign a float equal to amount divided by divisor as specified in the Money resource.", + "description": "
General ReleaseReport bug

This endpoint is ready for production use.

\n\nUpdates the inventory for a listing identified by a listing ID. The update fails if the supplied values for product sku, offering quantity, price, and/or processing profile are incompatible with values in `*_on_property` fields. When setting a price, assign a float equal to amount divided by divisor as specified in the Money resource.", "tags": [ "ShopListing Inventory" ], @@ -13063,7 +13063,7 @@ }, "style": { "type": "array", - "description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Default value is null.", + "description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Each style string is limited to 45 characters. Default value is null.", "items": { "type": "string" } @@ -13505,7 +13505,7 @@ }, "style": { "type": "array", - "description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Default value is null.", + "description": "An array of style strings for this listing, each of which is free-form text string such as \"Formal\", or \"Steampunk\". When creating or updating a listing, the listing may have up to two styles. Valid style strings contain only letters, numbers, and whitespace characters. (regex: /[^\\p{L}\\p{Nd}\\p{Zs}]/u) Each style string is limited to 45 characters. Default value is null.", "items": { "type": "string" } diff --git a/tests/test_exceptions_and_response.py b/tests/test_exceptions_and_response.py new file mode 100644 index 0000000..0626cd6 --- /dev/null +++ b/tests/test_exceptions_and_response.py @@ -0,0 +1,38 @@ +from etsy_python.v3.exceptions.BaseAPIException import BaseAPIException +from etsy_python.v3.exceptions.RequestException import RequestException +from etsy_python.v3.resources.Response import Response + + +class TestBaseAPIExceptionStr: + def test_formats_all_fields(self): + exc = BaseAPIException( + code=400, error="bad_request", error_description="missing field", type="ERROR" + ) + s = str(exc) + assert "code = 400" in s + assert "error = bad_request" in s + assert "error_description = missing field" in s + assert "type = ERROR" in s + + def test_handles_none_defaults(self): + exc = BaseAPIException(code=500) + s = str(exc) + assert "code = 500" in s + assert "error = None" in s + assert "type = ERROR" in s + + +class TestRequestExceptionStr: + def test_prefixes_etsy_marker(self): + exc = RequestException(code=429, error="rate_limit_exceeded", rate_limits={"X-Limit-Per-Day": 5000}) + s = str(exc) + assert s.startswith("[EtsyRequestException]") + assert "code = 429" in s + assert "error = rate_limit_exceeded" in s + + +class TestResponseStr: + def test_includes_code_and_message(self): + resp = Response(200, "ok") + s = str(resp) + assert s == "[EtsyResponse] [code = 200] [message = ok]" diff --git a/tests/test_language_enum.py b/tests/test_language_enum.py new file mode 100644 index 0000000..4ba317b --- /dev/null +++ b/tests/test_language_enum.py @@ -0,0 +1,14 @@ +from etsy_python.v3.enums.Language import Language + + +class TestLanguage: + def test_supported_language_codes_match_iso_639_1(self): + # Every member's value is its ISO 639-1 two-letter code and matches + # its name in lowercase. This catches accidental rename/value drift. + for member in Language: + assert member.value == member.name.lower() + assert len(member.value) == 2 + + def test_expected_languages_are_present(self): + expected = {"en", "de", "es", "fr", "it", "ja", "nl", "pl", "pt", "ru"} + assert {m.value for m in Language} == expected diff --git a/tests/test_listing_models.py b/tests/test_listing_models.py index 9e51f60..b635a14 100644 --- a/tests/test_listing_models.py +++ b/tests/test_listing_models.py @@ -8,7 +8,10 @@ CreateListingTranslationRequest, UpdateListingRequest, UpdateListingInventoryRequest, + UpdateListingPersonalizationRequest, UpdateListingPropertyRequest, + UpdateListingTranslationRequest, + UpdateListingVideoRequest, UpdateVariationImagesRequest, UploadListingImageRequest, UploadListingFileRequest, @@ -210,3 +213,105 @@ def test_update_warns_with_personalization_instructions(self): deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] assert len(deprecation_warnings) == 1 assert "update_listing_personalization" in str(deprecation_warnings[0].message) + + def test_create_warns_with_is_personalizable(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + CreateDraftListingRequest( + **self._make_create_kwargs(), + is_personalizable=True, + ) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + assert "is_personalizable" in str(deprecation_warnings[0].message) + assert "personalization-migration" in str(deprecation_warnings[0].message) + + def test_update_warns_with_is_personalizable(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + UpdateListingRequest(is_personalizable=True) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + assert "is_personalizable" in str(deprecation_warnings[0].message) + + def test_create_no_warning_with_is_personalizable_false(self): + # False matches the API's documented default and is a no-op once + # the field is removed, so explicit opt-out should not warn. + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + CreateDraftListingRequest( + **self._make_create_kwargs(), + is_personalizable=False, + personalization_is_required=False, + ) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0 + + def test_update_no_warning_with_falsy_personalization_values(self): + # 0 char count and empty instructions are equivalent to "not used". + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + UpdateListingRequest( + is_personalizable=False, + personalization_char_count_max=0, + personalization_instructions="", + ) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 0 + + def test_create_warns_once_with_multiple_personalization_fields(self): + # Setting several deprecated fields together must produce exactly one + # DeprecationWarning, not one per field. + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + CreateDraftListingRequest( + **self._make_create_kwargs(), + is_personalizable=True, + personalization_is_required=True, + personalization_char_count_max=256, + personalization_instructions="Enter name", + ) + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + assert "is_personalizable" in str(deprecation_warnings[0].message) + + +class TestUpdateListingTranslationRequest: + def test_stores_fields(self): + req = UpdateListingTranslationRequest( + title="New title", + description="New description", + tags=["a", "b"], + ) + assert req.title == "New title" + assert req.description == "New description" + assert req.tags == ["a", "b"] + + def test_missing_mandatory_raises(self): + with pytest.raises(Exception): + UpdateListingTranslationRequest() + + +class TestUpdateListingPersonalizationRequest: + def test_stores_personalization_questions(self): + questions = [{"personalization_question": "Name?"}] + req = UpdateListingPersonalizationRequest(personalization_questions=questions) + assert req.personalization_questions == questions + + def test_missing_mandatory_raises(self): + with pytest.raises(Exception): + UpdateListingPersonalizationRequest() + + +class TestUpdateListingVideoRequest: + def test_sets_file_and_data(self): + req = UpdateListingVideoRequest( + video_id=42, video_bytes=b"fake-mp4-bytes", name="clip.mp4" + ) + assert req.file == {"video": b"fake-mp4-bytes"} + assert req.data == {"video_id": 42, "name": "clip.mp4"} + + def test_defaults_are_none(self): + req = UpdateListingVideoRequest() + assert req.file == {"video": None} + assert req.data == {"video_id": None, "name": None} diff --git a/tests/test_listing_resource.py b/tests/test_listing_resource.py index d363a7b..527520d 100644 --- a/tests/test_listing_resource.py +++ b/tests/test_listing_resource.py @@ -11,6 +11,7 @@ from etsy_python.v3.exceptions.RequestException import RequestException from etsy_python.v3.models.Listing import ( CreateDraftListingRequest, + UpdateListingPersonalizationRequest, UpdateListingPropertyRequest, UpdateListingRequest, ) @@ -507,3 +508,55 @@ def test_none_optional_params_excluded_from_query_params(self, mock_session): qp = mock_session.make_request.call_args[1]["query_params"] assert qp["keywords"] is None assert qp["min_price"] is None + + +class TestListingPersonalizationEndpoints: + def test_get_listing_personalization(self, mock_session): + mock_session.make_request.return_value = Response(200, {"questions": []}) + resource = ListingResource(session=mock_session) + + resource.get_listing_personalization(MOCK_LISTING_ID) + + mock_session.make_request.assert_called_once_with( + f"/listings/{MOCK_LISTING_ID}/personalization" + ) + + def test_update_listing_personalization(self, mock_session): + mock_session.make_request.return_value = Response(200, {"questions": []}) + resource = ListingResource(session=mock_session) + payload = MagicMock(spec=UpdateListingPersonalizationRequest) + + resource.update_listing_personalization( + MOCK_SHOP_ID, + MOCK_LISTING_ID, + payload, + supports_multiple_personalization_questions=True, + ) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}/personalization", + method=Method.POST, + payload=payload, + query_params={"supports_multiple_personalization_questions": True}, + ) + + def test_update_listing_personalization_defaults_query_param_to_none(self, mock_session): + mock_session.make_request.return_value = Response(200, {"questions": []}) + resource = ListingResource(session=mock_session) + payload = MagicMock(spec=UpdateListingPersonalizationRequest) + + resource.update_listing_personalization(MOCK_SHOP_ID, MOCK_LISTING_ID, payload) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp == {"supports_multiple_personalization_questions": None} + + def test_delete_listing_personalization(self, mock_session): + mock_session.make_request.return_value = Response(204, "") + resource = ListingResource(session=mock_session) + + resource.delete_listing_personalization(MOCK_SHOP_ID, MOCK_LISTING_ID) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/listings/{MOCK_LISTING_ID}/personalization", + method=Method.DELETE, + ) diff --git a/tests/test_misc_and_holiday_models.py b/tests/test_misc_and_holiday_models.py new file mode 100644 index 0000000..1c5ab27 --- /dev/null +++ b/tests/test_misc_and_holiday_models.py @@ -0,0 +1,28 @@ +import pytest + +from etsy_python.v3.models.HolidayPreferences import UpdateHolidayPreferencesRequest +from etsy_python.v3.models.Miscellaneous import GetTokenScopes + + +class TestGetTokenScopes: + def test_stores_token(self): + req = GetTokenScopes(token="abc123") + assert req.token == "abc123" + + def test_missing_mandatory_raises(self): + with pytest.raises(Exception): + GetTokenScopes() + + +class TestUpdateHolidayPreferencesRequest: + def test_stores_is_working_true(self): + req = UpdateHolidayPreferencesRequest(is_working=True) + assert req.is_working is True + + def test_stores_is_working_false(self): + # False is the documented default; constructor must still accept it + # without raising and preserve the literal False. + req = UpdateHolidayPreferencesRequest(is_working=False) + assert req.is_working is False + result = req.get_dict() + assert result["is_working"] is False diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..d80e91d --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,134 @@ +from base64 import urlsafe_b64encode +from hashlib import sha256 +from unittest.mock import MagicMock, patch + +import pytest + +from etsy_python.v3.auth import EtsyOAuth +from etsy_python.v3.common.Env import environment + +MOCK_KEYSTRING = "test-keystring" +MOCK_REDIRECT_URI = "https://example.com/callback" +MOCK_SCOPES = ["shops_r", "listings_r"] + + +@pytest.fixture +def patched_session(): + """Patches OAuth2Session in the OAuth module so no real HTTP happens.""" + with patch("etsy_python.v3.auth.OAuth.OAuth2Session") as mock_cls: + mock_session = MagicMock() + mock_cls.return_value = mock_session + yield mock_cls, mock_session + + +class TestInit: + def test_stores_keystring_and_redirect(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, MOCK_SCOPES) + assert oauth.keystring == MOCK_KEYSTRING + assert oauth.redirect_url == MOCK_REDIRECT_URI + assert oauth.scopes == MOCK_SCOPES + + def test_auto_generates_code_verifier_when_not_supplied(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI) + # token_urlsafe(32) returns a non-empty url-safe string + assert oauth.code_verifier + assert isinstance(oauth.code_verifier, str) + + def test_supplied_code_verifier_blanks_internal_verifier(self, patched_session): + # Existing behavior: supplying a code_verifier sets the attribute to "" + # (the SDK only auto-generates a verifier when none is supplied). + oauth = EtsyOAuth( + MOCK_KEYSTRING, MOCK_REDIRECT_URI, code_verifier="caller-supplied" + ) + assert oauth.code_verifier == "" + + def test_auto_generates_state_when_not_supplied(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI) + assert oauth.state + assert isinstance(oauth.state, str) + + def test_supplied_state_is_used(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, state="caller-state") + assert oauth.state == "caller-state" + + def test_constructs_oauth2_session_with_correct_args(self, patched_session): + mock_cls, _ = patched_session + EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, MOCK_SCOPES) + mock_cls.assert_called_once_with( + MOCK_KEYSTRING, redirect_uri=MOCK_REDIRECT_URI, scope=MOCK_SCOPES + ) + + def test_auth_code_and_token_start_none(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI) + assert oauth.auth_code is None + assert oauth.token is None + + +class TestGenerateChallenge: + def test_matches_known_sha256_b64_no_padding(self): + verifier = "abc123" + expected = ( + urlsafe_b64encode(sha256(verifier.encode("utf-8")).digest()) + .decode("utf-8") + .split("=")[0] + ) + assert EtsyOAuth._generate_challenge(verifier) == expected + + def test_no_padding_in_output(self): + # PKCE requires base64url without padding + challenge = EtsyOAuth._generate_challenge("some-verifier") + assert "=" not in challenge + + +class TestGetAuthCode: + def test_calls_oauth_authorization_url_with_pkce_params(self, patched_session): + _, mock_session = patched_session + mock_session.authorization_url.return_value = ("https://auth/url", "state-x") + + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, state="state-x") + url, state = oauth.get_auth_code() + + assert url == "https://auth/url" + assert state == "state-x" + mock_session.authorization_url.assert_called_once_with( + environment.authorization_url, + state="state-x", + code_challenge=oauth.code_challenge, + code_challenge_method="S256", + ) + + +class TestSetAuthorisationCode: + def test_sets_auth_code_when_state_matches(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, state="state-x") + oauth.set_authorisation_code("the-code", "state-x") + assert oauth.auth_code == "the-code" + + def test_raises_permission_error_when_state_mismatch(self, patched_session): + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, state="state-x") + with pytest.raises(PermissionError): + oauth.set_authorisation_code("the-code", "state-y") + assert oauth.auth_code is None + + +class TestGetAccessToken: + def test_calls_fetch_token_with_pkce_params(self, patched_session): + _, mock_session = patched_session + mock_session.fetch_token.return_value = {"access_token": "the-token"} + + oauth = EtsyOAuth(MOCK_KEYSTRING, MOCK_REDIRECT_URI, state="state-x") + oauth.set_authorisation_code("the-code", "state-x") + token = oauth.get_access_token() + + assert token == {"access_token": "the-token"} + mock_session.fetch_token.assert_called_once_with( + environment.token_url, + code="the-code", + code_verifier=oauth.code_verifier, + include_client_id=True, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "x-api-key": MOCK_KEYSTRING, + }, + ) diff --git a/tests/test_processing_profile_models.py b/tests/test_processing_profile_models.py new file mode 100644 index 0000000..ac8df01 --- /dev/null +++ b/tests/test_processing_profile_models.py @@ -0,0 +1,54 @@ +import pytest + +from etsy_python.v3.enums.ProcessingProfile import ProcessingTimeUnit, ReadinessState +from etsy_python.v3.models.ProcessingProfile import ( + CreateShopReadinessStateDefinitionRequest, + UpdateShopReadinessStateDefinitionRequest, +) + + +class TestCreateShopReadinessStateDefinitionRequest: + def test_stores_required_fields(self): + req = CreateShopReadinessStateDefinitionRequest( + readiness_state=ReadinessState.MADE_TO_ORDER, + min_processing_time=1, + max_processing_time=3, + processing_time_unit=ProcessingTimeUnit.DAYS, + ) + # Enum values get unwrapped to their string in __init__ + assert req.readiness_state == ReadinessState.MADE_TO_ORDER.value + assert req.min_processing_time == 1 + assert req.max_processing_time == 3 + assert req.processing_time_unit == ProcessingTimeUnit.DAYS.value + + def test_optional_processing_time_unit_stays_none(self): + req = CreateShopReadinessStateDefinitionRequest( + readiness_state=ReadinessState.MADE_TO_ORDER, + min_processing_time=2, + max_processing_time=5, + ) + assert req.processing_time_unit is None + + def test_missing_mandatory_raises(self): + with pytest.raises(Exception): + CreateShopReadinessStateDefinitionRequest() + + +class TestUpdateShopReadinessStateDefinitionRequest: + def test_no_mandatory_fields(self): + # All fields are optional on update. + req = UpdateShopReadinessStateDefinitionRequest() + assert req.readiness_state is None + assert req.min_processing_time is None + assert req.max_processing_time is None + assert req.processing_time_unit is None + + def test_partial_update_unwraps_enums(self): + req = UpdateShopReadinessStateDefinitionRequest( + readiness_state=ReadinessState.MADE_TO_ORDER, + processing_time_unit=ProcessingTimeUnit.WEEKS, + ) + assert req.readiness_state == ReadinessState.MADE_TO_ORDER.value + assert req.processing_time_unit == ProcessingTimeUnit.WEEKS.value + assert req.min_processing_time is None + assert req.max_processing_time is None diff --git a/tests/test_processing_profile_resource.py b/tests/test_processing_profile_resource.py new file mode 100644 index 0000000..72fcab8 --- /dev/null +++ b/tests/test_processing_profile_resource.py @@ -0,0 +1,92 @@ +from unittest.mock import MagicMock + +from etsy_python.v3.models.ProcessingProfile import ( + CreateShopReadinessStateDefinitionRequest, + UpdateShopReadinessStateDefinitionRequest, +) +from etsy_python.v3.resources.ProcessingProfile import ProcessingProfileResource +from etsy_python.v3.resources.Response import Response +from etsy_python.v3.resources.enums.Request import Method + +from tests.conftest import MOCK_SHOP_ID + +MOCK_DEFINITION_ID = 313131 + + +class TestProcessingProfileResource: + def test_create_shop_readiness_state_definition(self, mock_session): + mock_session.make_request.return_value = Response(201, {}) + resource = ProcessingProfileResource(session=mock_session) + payload = MagicMock(spec=CreateShopReadinessStateDefinitionRequest) + + resource.create_shop_readiness_state_definition(MOCK_SHOP_ID, payload) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/readiness-state-definitions", + method=Method.POST, + payload=payload, + ) + + def test_get_shop_readiness_state_definitions_uses_default_pagination( + self, mock_session + ): + mock_session.make_request.return_value = Response(200, {"results": []}) + resource = ProcessingProfileResource(session=mock_session) + + resource.get_shop_readiness_state_definitions(MOCK_SHOP_ID) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/readiness-state-definitions", + query_params={"limit": 25, "offset": 0}, + ) + + def test_get_shop_readiness_state_definitions_custom_pagination( + self, mock_session + ): + mock_session.make_request.return_value = Response(200, {"results": []}) + resource = ProcessingProfileResource(session=mock_session) + + resource.get_shop_readiness_state_definitions( + MOCK_SHOP_ID, limit=50, offset=100 + ) + + qp = mock_session.make_request.call_args[1]["query_params"] + assert qp == {"limit": 50, "offset": 100} + + def test_get_shop_readiness_state_definition(self, mock_session): + mock_session.make_request.return_value = Response(200, {}) + resource = ProcessingProfileResource(session=mock_session) + + resource.get_shop_readiness_state_definition(MOCK_SHOP_ID, MOCK_DEFINITION_ID) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/readiness-state-definitions/{MOCK_DEFINITION_ID}" + ) + + def test_update_shop_readiness_state_definition(self, mock_session): + mock_session.make_request.return_value = Response(200, {}) + resource = ProcessingProfileResource(session=mock_session) + payload = MagicMock(spec=UpdateShopReadinessStateDefinitionRequest) + + resource.update_shop_readiness_state_definition( + MOCK_SHOP_ID, MOCK_DEFINITION_ID, payload + ) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/readiness-state-definitions/{MOCK_DEFINITION_ID}", + method=Method.PUT, + payload=payload, + ) + + def test_delete_shop_readiness_state_definition(self, mock_session): + mock_session.make_request.return_value = Response(204, "") + resource = ProcessingProfileResource(session=mock_session) + + resource.delete_shop_readiness_state_definition( + MOCK_SHOP_ID, MOCK_DEFINITION_ID + ) + + mock_session.make_request.assert_called_once_with( + f"/shops/{MOCK_SHOP_ID}/readiness-state-definitions/{MOCK_DEFINITION_ID}", + method=Method.DELETE, + ) diff --git a/tests/test_request_model.py b/tests/test_request_model.py index b587ee2..15fb41a 100644 --- a/tests/test_request_model.py +++ b/tests/test_request_model.py @@ -42,6 +42,19 @@ def test_no_mandatory_fields(self): req = NoMandatoryRequest() assert req.check_mandatory() is True + def test_mandatory_key_with_no_attribute_raises_value_error(self): + """If a mandatory key isn't set as an instance attribute, + check_mandatory must hit the except branch and return False, + causing Request.__init__ to raise ValueError.""" + + class MissingAttrRequest(Request): + def __init__(self): + # Deliberately do NOT set self.required_attr + super().__init__(mandatory=["required_attr"]) + + with pytest.raises(ValueError): + MissingAttrRequest() + class TestGetNulled: def test_nullable_field_empty_string(self): diff --git a/tests/test_session.py b/tests/test_session.py index 1881d59..57e3303 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -204,6 +204,15 @@ def test_patch_without_payload_raises_value_error(self, real_etsy_client): "/shops/123/listings/456", method=Method.PATCH ) + def test_invalid_method_payload_combo_raises_value_error(self, real_etsy_client): + # PUT with a non-Request payload passes the "Improper payload" guard + # (payload is not None) but falls through every isinstance branch and + # hits the final "Invalid method or payload" else. + with pytest.raises(ValueError, match="Invalid method or payload"): + real_etsy_client.make_request( + "/shops/123", method=Method.PUT, payload="not-a-request" + ) + class TestRateLimits: def test_rate_limits_extracted(self, real_etsy_client): diff --git a/tests/test_shop_return_policy_models.py b/tests/test_shop_return_policy_models.py new file mode 100644 index 0000000..5e20dda --- /dev/null +++ b/tests/test_shop_return_policy_models.py @@ -0,0 +1,58 @@ +import pytest + +from etsy_python.v3.models.ShopReturnPolicy import ( + ConsolidateShopReturnPoliciesRequest, + CreateShopReturnPolicyRequest, + UpdateShopReturnPolicyRequest, +) + + +class TestConsolidateShopReturnPoliciesRequest: + def test_stores_required_ids(self): + req = ConsolidateShopReturnPoliciesRequest( + source_return_policy_id=111, destination_return_policy_id=222 + ) + assert req.source_return_policy_id == 111 + assert req.destination_return_policy_id == 222 + + +class TestCreateShopReturnPolicyRequest: + def test_stores_required_fields(self): + req = CreateShopReturnPolicyRequest( + accepts_returns=True, accepts_exchanges=False, return_deadline=30 + ) + assert req.accepts_returns is True + assert req.accepts_exchanges is False + assert req.return_deadline == 30 + + def test_return_deadline_optional(self): + req = CreateShopReturnPolicyRequest( + accepts_returns=True, accepts_exchanges=True + ) + assert req.return_deadline is None + + def test_bool_false_preserved_not_nulled(self): + # Booleans must serialize even when False — guard against the + # nullable/None coercion described in CLAUDE.md. + req = CreateShopReturnPolicyRequest( + accepts_returns=False, accepts_exchanges=False + ) + result = req.get_dict() + assert result["accepts_returns"] is False + assert result["accepts_exchanges"] is False + + +class TestUpdateShopReturnPolicyRequest: + def test_stores_required_fields(self): + req = UpdateShopReturnPolicyRequest( + accepts_returns=True, accepts_exchanges=True, return_deadline=14 + ) + assert req.accepts_returns is True + assert req.accepts_exchanges is True + assert req.return_deadline == 14 + + def test_return_deadline_optional(self): + req = UpdateShopReturnPolicyRequest( + accepts_returns=False, accepts_exchanges=True + ) + assert req.return_deadline is None diff --git a/tests/test_utils.py b/tests/test_utils.py index 2b96d85..4ab4bf7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,10 @@ from enum import Enum -from etsy_python.v3.common.Utils import generate_get_uri, todict +from etsy_python.v3.common.Utils import ( + generate_bytes_from_file, + generate_get_uri, + todict, +) class TestGenerateGetUri: @@ -165,3 +169,25 @@ def __init__(self): result = todict(ObjWithNullableBool(), nullable=[]) assert result["was_shipped"] is False assert result["name"] == "test" + + def test_ast_attribute_is_expanded(self): + """Objects exposing an _ast() method get serialized via that method.""" + + class AstNode: + def _ast(self): + return {"kind": "literal", "value": 7} + + result = todict(AstNode()) + assert result == {"kind": "literal", "value": 7} + + +class TestGenerateBytesFromFile: + def test_reads_file_contents(self, tmp_path): + path = tmp_path / "blob.bin" + path.write_bytes(b"hello-bytes") + assert generate_bytes_from_file(str(path)) == b"hello-bytes" + + def test_reads_empty_file(self, tmp_path): + path = tmp_path / "empty.bin" + path.write_bytes(b"") + assert generate_bytes_from_file(str(path)) == b""