From 07ea797fcc2bd7651c1e32683b40ac7206d317eb Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Sun, 17 Jun 2018 21:59:26 +0900 Subject: [PATCH] Adapt recent spec --- docs/conf.py | 2 +- itunesiap/receipt.py | 112 +++++++++++++++++++++++++++++++++--------- setup.py | 3 +- tests/conftest.py | 68 +++++++++++++++++++++++-- tests/receipt_test.py | 44 ++++++++++++----- 5 files changed, 189 insertions(+), 40 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0b15ec8..f21f02a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,7 +79,7 @@ def get_version(): # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' diff --git a/itunesiap/receipt.py b/itunesiap/receipt.py index 3368ee3..85088f9 100644 --- a/itunesiap/receipt.py +++ b/itunesiap/receipt.py @@ -3,9 +3,10 @@ A successful response returns a JSON object including receipts. To manipulate them in convinient way, `itunes-iap` wrapped it with :class:`ObjectMapper`. """ +import datetime +import warnings import pytz import dateutil.parser -import warnings import json from collections import defaultdict from prettyexc import PrettyException @@ -21,12 +22,21 @@ _warned_undocumented_fields = defaultdict(bool) _warned_unlisted_field = defaultdict(bool) +''' +class ExpirationIntent(Enum): + CustomerCanceledTheirSubscription = 1 + BillingError = 2 + CustumerDidNotAgreeToARecentPriceIncrease = 3 + ProductWasNotAvailableForPurchaseAtTheTimeOfRenewal = 4 + UnknownError = 5 +''' + class MissingFieldError(PrettyException, AttributeError, KeyError): """A Backward compatibility error.""" -def _to_datetime(value): +def _rfc3339_to_datetime(value): """Try to parse Apple iTunes receipt date format. By reference, they insists it is rfc3339: @@ -50,6 +60,12 @@ def _to_datetime(value): return d +def _ms_to_datetime(value): + nd = datetime.datetime.utcfromtimestamp(int(value) / 1000) + ad = nd.replace(tzinfo=pytz.UTC) + return ad + + def _to_bool(data): assert data in ('true', 'false'), \ ("Cannot convert {0}, " @@ -184,43 +200,85 @@ class Receipt(ObjectMapper): This object encapsulate it to list of :class:`InApp` object in `in_app` property. - See also: ``_ + :see: ``_ """ __OPAQUE_FIELDS__ = frozenset([ + # app receipt fields 'bundle_id', 'application_version', 'original_application_version', + # in-app purchase receipt fields + 'product_id', + 'transaction_id', + 'original_transaction_id', + 'expires_date_formatted', 'app_item_id', 'version_external_identifier', + 'web_order_line_item_id', + 'auto_renew_product_id', ]) __FIELD_ADAPTERS__ = { - 'receipt_creation_date': _to_datetime, + # app receipt fields + 'receipt_creation_date': _rfc3339_to_datetime, 'receipt_creation_date_ms': int, - 'receipt_expiration_date': _to_datetime, - 'receipt_expiration_date_ms': int, - 'original_purchase_date': _to_datetime, + 'expiration_date': _rfc3339_to_datetime, + 'expiration_date_ms': int, + # in-app purchase receipt fields + 'quantity': int, + 'purchase_date': _rfc3339_to_datetime, + 'purchase_date_ms': int, + 'original_purchase_date': _rfc3339_to_datetime, 'original_purchase_date_ms': int, - 'request_date': _to_datetime, + 'expires_date': _ms_to_datetime, + 'expiration_intent': int, + 'is_in_billing_retry_period': _to_bool, + 'is_in_intro_offer_period': _to_bool, + 'cancellation_date': _rfc3339_to_datetime, + 'cancellation_reason': int, + 'auto_renew_status': int, + 'price_consent_status': int, + 'request_date': _rfc3339_to_datetime, 'request_date_ms': int, } __DOCUMENTED_FIELDS__ = frozenset([ + # app receipt fields 'bundle_id', 'in_app', 'application_version', 'original_application_version', 'receipt_creation_date', - 'receipt_creation_date_ms', - 'receipt_expiration_date', - 'receipt_expiration_date_ms', + 'expiration_date', + # in-app purchase receipt fields + 'quantity', + 'product_id', + 'transaction_id', + 'original_transaction_id', + 'purchase_date', # _formatted value + 'original_purchase_date', + 'expires_date', # _ms value + 'is_in_billing_retry_period', + 'is_in_intro_offer_period', + 'cancellation_date', + 'cancellation_reason', 'app_item_id', 'version_external_identifier', + 'web_order_line_item_id', + 'auto_renew_status', + 'auto_renew_product_id', + 'price_consent_status', ]) __UNDOCUMENTED_FIELDS__ = frozenset([ + # app receipt fields 'request_date', 'request_date_ms', 'version_external_identifier', - 'original_purchase_date', + 'receipt_creation_date_ms', + 'expiration_date_ms', + # in-app purchase receipt fields + 'purchase_date_ms', 'original_purchase_date_ms', + 'expires_date_formatted', + 'unique_identifier', ]) @lazy_property @@ -244,6 +302,10 @@ def last_in_app(self): return sorted( self.in_app, key=lambda x: x['original_purchase_date_ms'])[-1] + @property + def expires_date_ms(self): + return self._['expires_date'] + class Purchase(ObjectMapper): """The individual purchases. @@ -266,18 +328,18 @@ class Purchase(ObjectMapper): 'original_transaction_id', 'web_order_line_item_id', 'unique_identifier', + 'expires_date_formatted', ]) __FIELD_ADAPTERS__ = { 'quantity': int, - 'purchase_date': _to_datetime, + 'purchase_date': _rfc3339_to_datetime, 'purchase_date_ms': int, - 'original_purchase_date': _to_datetime, + 'original_purchase_date': _rfc3339_to_datetime, 'original_purchase_date_ms': int, - 'expires_date': _to_datetime, - 'expires_date_formatted': _to_datetime, + 'expires_date': _rfc3339_to_datetime, 'expires_date_ms': int, 'is_trial_period': _to_bool, - 'cancellation_date': _to_datetime, + 'cancellation_date': _rfc3339_to_datetime, 'cancellation_date_ms': int, 'cancellation_reason': int, } @@ -287,19 +349,18 @@ class Purchase(ObjectMapper): 'transaction_id', 'original_transaction_id', 'purchase_date', - 'purchase_date_ms', # de facto documented 'original_purchase_date', - 'original_purchase_date_ms', # de facto documented 'expires_date', - 'expires_date_ms', # de facto documented 'is_trial_period', 'cancellation_date', - 'cancellation_date_ms', # de facto documented 'cancellation_reason', 'web_order_line_item_id', ]) __UNDOCUMENTED_FIELDS__ = frozenset([ 'unique_identifier', + 'purchase_date_ms', + 'original_purchase_date_ms', + 'cancellation_date_ms', 'expires_date_formatted', # legacy receipts has this field as actual "expires_date" ]) @@ -311,12 +372,17 @@ def __eq__(self, other): @lazy_property def expires_date(self): if 'expires_date_formatted' in self: - return _to_datetime(self['expires_date_formatted']) + return _rfc3339_to_datetime(self['expires_date_formatted']) try: value = self['expires_date'] except KeyError: raise MissingFieldError('expires_date') - return _to_datetime(value) + try: + int(value) + except ValueError: + return _rfc3339_to_datetime(value) + else: + return _ms_to_datetime(value) class InApp(Purchase): diff --git a/setup.py b/setup.py index 79e096d..a2ea746 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,8 @@ def get_readme(): 'pytz', ] tests_require = [ - 'pytest>=3.0.0', 'pytest-cov', 'tox', 'mock', 'patch', + 'pytest>=3.0.0', 'pytest-cov', 'pytest-lazy-fixture', 'tox', + 'mock', 'patch', ] if sys.version_info[:2] >= (3, 5): diff --git a/tests/conftest.py b/tests/conftest.py index efe8461..644e8f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ - +# coding: utf-8 import json import itunesiap import pytest @@ -128,9 +128,9 @@ def itunes_autorenew_response1(): @pytest.fixture(scope='session') -def itunes_autorenew_response(): +def itunes_autorenew_response2(): """Contributed by Jonas Petersen @jox""" - return json.loads('''{ + return json.loads(r'''{ "status": 0, "environment": "Sandbox", "receipt": { @@ -694,3 +694,65 @@ def itunes_autorenew_response(): } ] }''') + + +@pytest.fixture(scope='session') +def itunes_autorenew_response3(): + """Contributed by François Dupayrat @FrancoisDupayrat""" + return json.loads(r'''{ + "auto_renew_status": 1, + "status": 0, + "auto_renew_product_id": "******************************", + "receipt":{ + "original_purchase_date_pst":"2017-06-28 07:31:51 America/Los_Angeles", + "unique_identifier":"******************************", + "original_transaction_id":"******************************", + "expires_date":"1506524970000", + "transaction_id":"******************************", + "quantity":"1", + "product_id":"******************************", + "item_id":"******************************", + "bid":"******************************", + "unique_vendor_identifier":"******************************", + "web_order_line_item_id":"******************************", + "bvrs":"1.1.6", + "expires_date_formatted":"2017-09-27 15:09:30 Etc/GMT", + "purchase_date":"2017-09-27 15:04:30 Etc/GMT", + "purchase_date_ms":"1506524670000", + "expires_date_formatted_pst":"2017-09-27 08:09:30 America/Los_Angeles", + "purchase_date_pst":"2017-09-27 08:04:30 America/Los_Angeles", + "original_purchase_date":"2017-06-28 14:31:51 Etc/GMT", + "original_purchase_date_ms":"1498660311000" + }, + "latest_receipt_info":{ + "original_purchase_date_pst":"2017-06-28 07:31:51 America/Los_Angeles", + "unique_identifier":"******************************", + "original_transaction_id":"******************************", + "expires_date":"******************************", + "transaction_id":"******************************", + "quantity":"1", + "product_id":"******************************", + "item_id":"******************************", + "bid":"******************************", + "unique_vendor_identifier":"******************************", + "web_order_line_item_id":"******************************", + "bvrs":"1.1.6", + "expires_date_formatted":"2017-09-27 15:09:30 Etc/GMT", + "purchase_date":"2017-09-27 15:04:30 Etc/GMT", + "purchase_date_ms":"1506524670000", + "expires_date_formatted_pst":"2017-09-27 08:09:30 America/Los_Angeles", + "purchase_date_pst":"2017-09-27 08:04:30 America/Los_Angeles", + "original_purchase_date":"2017-06-28 14:31:51 Etc/GMT", + "original_purchase_date_ms":"1498660311000" + }, + "latest_receipt":"******************************" + }''') + + +@pytest.fixture(params=[ + pytest.lazy_fixture('itunes_autorenew_response1'), + pytest.lazy_fixture('itunes_autorenew_response2'), + pytest.lazy_fixture('itunes_autorenew_response3'), +]) +def itunes_autorenew_response(request): + return request.param diff --git a/tests/receipt_test.py b/tests/receipt_test.py index 797eb3c..286b136 100644 --- a/tests/receipt_test.py +++ b/tests/receipt_test.py @@ -7,33 +7,53 @@ import pytest -to_datetime = itunesiap.receipt._to_datetime +rfc3339_to_datetime = itunesiap.receipt._rfc3339_to_datetime +ms_to_datetime = itunesiap.receipt._ms_to_datetime -def test_receipt_date(): - """Test to parse string dates to python dates""" - import pytz - import datetime +def test_to_datetime(): + d1 = rfc3339_to_datetime(u'1970-01-01 00:00:00 Etc/GMT') + d2 = ms_to_datetime(0) + assert d1 == d2 + + d1 = rfc3339_to_datetime(u'2017-09-27 15:04:30 Etc/GMT') + d2 = ms_to_datetime(1506524670000) + assert d1 == d2 + - d = to_datetime(u'2013-01-01T00:00:00+09:00') +def test_rfc3339_to_datetime(): + """Test to parse string dates to python dates""" + d = rfc3339_to_datetime(u'2013-01-01T00:00:00+09:00') assert (d.year, d.month, d.day) == (2013, 1, 1) assert d.tzinfo._offset == datetime.timedelta(0, 9 * 3600) - d = to_datetime(u'2013-01-01 00:00:00 Etc/GMT') + d = rfc3339_to_datetime(u'2013-01-01 00:00:00 Etc/GMT') assert (d.year, d.month, d.day) == (2013, 1, 1) assert d.tzinfo._utcoffset == datetime.timedelta(0) - d = to_datetime(u'2013-01-01 00:00:00 America/Los_Angeles') + d = rfc3339_to_datetime(u'2013-01-01 00:00:00 America/Los_Angeles') assert (d.year, d.month, d.day) == (2013, 1, 1) assert d.tzinfo == pytz.timezone('America/Los_Angeles') with pytest.raises(ValueError): - assert to_datetime(u'wrong date') + assert rfc3339_to_datetime(u'wrong date') -def test_autorenew_latest(itunes_autorenew_response): +def test_autorenew_general(itunes_autorenew_response): response = itunesiap.Response(itunes_autorenew_response) assert response.status == 0 + assert response.receipt # definitely, no common sense through versions + + +def test_autorenew_latest(itunes_autorenew_response3): + response = itunesiap.Response(itunes_autorenew_response3) + assert response.status == 0 + receipt = response.receipt + assert receipt.expires_date.date() == datetime.date(2017, 9, 27) + + +def test_autorenew_middleage(itunes_autorenew_response2): + response = itunesiap.Response(itunes_autorenew_response2) assert isinstance(response.latest_receipt, six.string_types) receipt = response.receipt @@ -98,9 +118,9 @@ def test_autorenew_legacy(itunes_autorenew_response_legacy): assert response.receipt.single_purchase == response.latest_receipt_info purchase = response.receipt.single_purchase with pytest.raises((OverflowError, ValueError)): - to_datetime(purchase._expires_date) + rfc3339_to_datetime(purchase._expires_date) assert purchase.expires_date.date() == datetime.date(2012, 12, 2) - assert purchase.expires_date == to_datetime(purchase._expires_date_formatted) + assert purchase.expires_date == rfc3339_to_datetime(purchase._expires_date_formatted) assert isinstance(purchase.original_purchase_date, datetime.datetime) assert isinstance(purchase.transaction_id, six.string_types)