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": "
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": "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""