diff --git a/payment/bazaar.py b/payment/bazaar.py index b295815..cf1d1cd 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -166,11 +166,21 @@ def complete_authorization(*, code: str, state: str = '') -> None: raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.') cache.delete(ACCESS_TOKEN_CACHE_KEY) if data.get('access_token'): - cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], - timeout=max(int(data.get('expires_in', 3600)) - 60, 60)) + cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], timeout=_access_token_ttl(data)) logger.info('Bazaar API authorized; refresh token stored') +def _access_token_ttl(data: dict) -> int: + # سند ۳۶۰۰ می‌گوید ولی بازارِ واقعی ۲۰۲۶-۰۹-۲۶ `expires_in: 3600000` داد + # (احتمالاً میلی‌ثانیه). کش را به یک ساعت سقف می‌زنیم تا ۴۱ روز توکنِ + # مرده نگه نداریم؛ روی 401/403 هم پاک می‌شود. + try: + seconds = int(data.get('expires_in', 3600)) + except (TypeError, ValueError): + seconds = 3600 + return max(min(seconds, 3600) - 60, 60) + + def _get_access_token() -> str: """ ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک @@ -214,9 +224,7 @@ def _get_access_token() -> str: logger.error('Bazaar access-token response malformed: status=%s', resp.status_code) raise BazaarAPIError() - # طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی - # می‌کنیم تا لبه‌ی زمانی مشکل نسازه. - cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60) + cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=_access_token_ttl(data)) return token @@ -234,7 +242,10 @@ def _auth_headers() -> dict: """ if settings.BAZAAR_PISHKHAN_API_SECRET: return {'CAFEBAZAAR-PISHKHAN-API-SECRET': settings.BAZAAR_PISHKHAN_API_SECRET} - return {'Authorization': f'Bearer {_get_access_token()}'} + # بدونِ «Bearer»: با آن بازار 403 «Access token is not valid» می‌دهد، با + # توکنِ خام قبول می‌کند (آزمایش روی بازارِ واقعی، ۲۰۲۶-۰۹-۲۶)، هرچند + # token_typeِ خودش Bearer است. + return {'Authorization': _get_access_token()} def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult: @@ -247,7 +258,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b تک‌خرید: api/validate//inapp//purchases// اشتراک: api/applications//subscriptions//purchases/ به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس - فقط ۴۰۴ نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند + فقط همان نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود. """ if not is_configured(): @@ -279,13 +290,16 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b except ValueError: data = None - if resp.status_code == 404: - return BazaarPurchaseResult(is_valid=False, raw=data if isinstance(data, dict) else {'status_code': 404}) + error = data.get('error') if isinstance(data, dict) else None + if resp.status_code == 404 and error == 'not_found': + return BazaarPurchaseResult(is_valid=False, raw=data) + # 404 با invalid_value («Package name is invalid» / «Product is not found») + # یعنی تنظیمِ ما غلط است، نه خریدِ کاربر؛ مثلِ بقیه‌ی خطاها ۵۰۳. if resp.status_code != 200 or not isinstance(data, dict): - if resp.status_code == 401: + if resp.status_code in (401, 403): cache.delete(ACCESS_TOKEN_CACHE_KEY) - logger.error('Bazaar verify-purchase failed: status=%s error=%s', resp.status_code, - data.get('error') if isinstance(data, dict) else None) + logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', resp.status_code, + error, data.get('error_description') if isinstance(data, dict) else None) raise BazaarAPIError() if is_subscription: diff --git a/payment/tests.py b/payment/tests.py index 533df53..8523530 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -119,7 +119,7 @@ class BazaarVerifyPurchaseFailClosedTests(TestCase): self.assertTrue(result.is_valid) mock_get.assert_called_once() _, get_kwargs = mock_get.call_args - self.assertEqual(get_kwargs['headers']['Authorization'], 'Bearer real-access-token') + self.assertEqual(get_kwargs['headers']['Authorization'], 'real-access-token') @override_settings( BAZAAR_REFRESH_TOKEN='refresh-token-xyz', diff --git a/payment/tests_bazaar_oauth.py b/payment/tests_bazaar_oauth.py index 33f4812..35ce05e 100644 --- a/payment/tests_bazaar_oauth.py +++ b/payment/tests_bazaar_oauth.py @@ -162,6 +162,18 @@ class BazaarValidateAgainstDocsTests(TestCase): self._resp(mock_get, 404, {'error': 'not_found'}) self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='forged').is_valid) + @patch('payment.bazaar.requests.get') + def test_wrong_package_or_sku_is_503_not_invalid(self, mock_get): + # 404ِ invalid_value یعنی تنظیمِ ما غلط است، نه خریدِ جعلی. + self._resp(mock_get, 404, {'error': 'invalid_value', 'error_description': 'Package name is invalid.'}) + with self.assertRaises(bazaar.BazaarAPIError): + bazaar.verify_purchase(product_sku='s', purchase_token='t') + + def test_access_token_ttl_is_capped_at_an_hour(self): + # بازارِ واقعی expires_in=3600000 داد. + self.assertEqual(bazaar._access_token_ttl({'expires_in': 3600000}), 3540) + self.assertEqual(bazaar._access_token_ttl({'expires_in': 3600}), 3540) + @patch('payment.bazaar.requests.get') def test_our_credential_error_is_503_not_invalid(self, mock_get): # کلیدِ غلط یا سقفِ روزانه نباید خریدِ واقعیِ کاربر را failed کند.