- payment/providers.py: ZarinpalProvider / BazaarProvider with one interface (check_plan, sku_for, payment_url, purchase_extras, discount_blocker, reconcile_user); provider_for / provider_for_method pick one; unknown fails. create_order, purchase view, discount redeem and reconcile_user_orders use it instead of scattered ifs. - subscription.0015: Bazaar SKU 10342 on the one-month plan (owner: "probably the monthly one"). - config/health: the Bazaar check no longer swallows its exception without a log. 982 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
13 KiB
Python
243 lines
13 KiB
Python
"""
|
||
اشتراکِ کافهبازار، «تخفیف پویا» و تمدیدِ خودکار (۲۰۲۶-۱۰-۱۲).
|
||
|
||
بازار با `bazaar.verify_purchase`ِ ساختگی آزموده میشود؛ خریدِ واقعی، تمدیدِ واقعی و پذیرفتنِ JWT
|
||
از سوی بازار هیچکدام آزموده نشدهاند (اپِ اندرویدِ تازه هنوز ساخته نشده).
|
||
"""
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
from unittest.mock import patch
|
||
|
||
from django.core.cache import cache
|
||
from django.test import TestCase, override_settings
|
||
from django.utils import timezone
|
||
from rest_framework.test import APIClient
|
||
|
||
from accounts.authentication import issue_access_token
|
||
from accounts.models import User
|
||
from discount.models import DiscountCode
|
||
from subscription.models import Plan, Subscription
|
||
|
||
from .bazaar import BazaarPurchaseResult
|
||
from .models import Order
|
||
|
||
|
||
def _result(days: float, valid=True):
|
||
return BazaarPurchaseResult(is_valid=valid, valid_until=timezone.now() + timezone.timedelta(days=days),
|
||
auto_renewing=True)
|
||
|
||
|
||
class BazaarSubscriptionTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.user = User.objects.create(phone='09120007301')
|
||
self.client = APIClient()
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||
self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def buy(self, plan, key='k1'):
|
||
return self.client.post('/api/subscription/purchase', {'planId': str(plan.id), 'paymentMethod': 'bazaar'},
|
||
format='json', HTTP_IDEMPOTENCY_KEY=key)
|
||
|
||
def verify(self, order_id, result):
|
||
with patch('payment.bazaar.verify_purchase', return_value=result) as mock:
|
||
response = self.client.post('/api/payment/bazaar/verify',
|
||
{'orderId': order_id, 'purchaseToken': 'sub-token-1'}, format='json')
|
||
return response, mock
|
||
|
||
def test_migration_put_the_panel_skus_on_three_and_six_months(self):
|
||
self.assertEqual(self.three.bazaar_sku, '10343')
|
||
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=180).bazaar_sku, '10344')
|
||
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=30).bazaar_sku, '10342')
|
||
# بستهها هنوز در پیشخان نیستند.
|
||
self.assertFalse(Plan.objects.filter(kind='pack').exclude(bazaar_sku='').exists())
|
||
|
||
def test_plan_without_sku_is_refused_for_bazaar(self):
|
||
pack = Plan.objects.get(kind='pack', question_credits=20)
|
||
self.assertEqual(self.buy(pack).status_code, 400)
|
||
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
|
||
|
||
def test_subscription_bought_in_bazaar_activates_for_the_plan(self):
|
||
response = self.buy(self.three)
|
||
self.assertEqual(response.status_code, 200, response.content)
|
||
body = response.json()
|
||
self.assertEqual((body['bazaarSku'], body['bazaarProductType'], body['paymentUrl']), ('10343', 'subscription', None))
|
||
order = Order.objects.get(pk=body['orderId'])
|
||
self.assertEqual((order.provider, order.plan_id), ('bazaar', self.three.id))
|
||
self.assertIsNotNone(order.subscription_id)
|
||
|
||
response, mock = self.verify(body['orderId'], _result(90))
|
||
self.assertEqual(response.json()['status'], 'paid')
|
||
self.assertTrue(mock.call_args.kwargs['is_subscription'])
|
||
sub = Subscription.objects.get(user_id=self.user.id)
|
||
self.assertEqual(sub.status, 'active')
|
||
self.assertAlmostEqual((sub.expires_at - timezone.now()).days, 89, delta=1)
|
||
self.assertIsNotNone(Order.objects.get(pk=body['orderId']).bazaar_valid_until)
|
||
|
||
def test_pack_verify_asks_for_inapp(self):
|
||
pack = Plan.objects.get(kind='pack', question_credits=20)
|
||
pack.bazaar_sku = 'pack20'
|
||
pack.save()
|
||
body = self.buy(pack).json()
|
||
self.assertEqual(body['bazaarProductType'], 'inapp')
|
||
_, mock = self.verify(body['orderId'], BazaarPurchaseResult(is_valid=True, purchase_state=0))
|
||
self.assertFalse(mock.call_args.kwargs['is_subscription'])
|
||
|
||
def _paid_subscription(self):
|
||
order_id = self.buy(self.three).json()['orderId']
|
||
self.verify(order_id, _result(90))
|
||
return Order.objects.get(pk=order_id)
|
||
|
||
def test_auto_renewal_in_bazaar_extends_the_subscription_once(self):
|
||
order = self._paid_subscription()
|
||
# دورهی پرداختشده تمام شده؛ بازار تمدید کرده و validUntil نود روز جلو رفته.
|
||
order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1)
|
||
order.save(update_fields=['bazaar_valid_until'])
|
||
before = Subscription.objects.get(user_id=self.user.id).expires_at
|
||
with patch('payment.bazaar.verify_purchase', return_value=_result(90)) as mock:
|
||
self.client.get('/api/subscription/status')
|
||
cache.clear() # سقفِ ساعتی را کنار بگذار: ثبتِ دوباره هم نباید دورهی دوم بسازد
|
||
self.client.get('/api/subscription/status')
|
||
self.assertTrue(mock.call_args.kwargs['is_subscription'])
|
||
renewals = Order.objects.filter(user_id=self.user.id, status='paid').exclude(pk=order.pk)
|
||
self.assertEqual(renewals.count(), 1)
|
||
self.assertEqual(renewals.get().amount, self.three.price)
|
||
after = Subscription.objects.get(user_id=self.user.id).expires_at
|
||
self.assertAlmostEqual((after - before).days, 90, delta=1)
|
||
|
||
def test_paywall_picks_up_a_renewal_without_opening_the_store(self):
|
||
from subscription.services import has_active_subscription, has_subscription_access
|
||
order = self._paid_subscription()
|
||
past = timezone.now() - timezone.timedelta(hours=1)
|
||
Order.objects.filter(pk=order.pk).update(bazaar_valid_until=past)
|
||
Subscription.objects.filter(user_id=self.user.id).update(expires_at=past)
|
||
self.assertFalse(has_active_subscription(self.user.id))
|
||
with patch('payment.bazaar.verify_purchase', return_value=_result(90)):
|
||
self.assertTrue(has_subscription_access(self.user.id))
|
||
|
||
def test_no_renewal_when_bazaar_did_not_move_the_period(self):
|
||
order = self._paid_subscription()
|
||
order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1)
|
||
order.save(update_fields=['bazaar_valid_until'])
|
||
with patch('payment.bazaar.verify_purchase', return_value=_result(-1 / 24, valid=False)):
|
||
self.client.get('/api/subscription/status')
|
||
self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), 1)
|
||
|
||
def test_bazaar_is_not_asked_while_the_period_is_far_from_its_end(self):
|
||
self._paid_subscription()
|
||
with patch('payment.bazaar.verify_purchase') as mock:
|
||
self.client.get('/api/subscription/status')
|
||
mock.assert_not_called()
|
||
|
||
|
||
def _decode(token: str, key: str) -> dict:
|
||
head, body, sig = token.split('.')
|
||
expected = base64.urlsafe_b64encode(
|
||
hmac.new(key.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()).rstrip(b'=').decode()
|
||
assert hmac.compare_digest(sig, expected), 'bad signature'
|
||
return json.loads(base64.urlsafe_b64decode(body + '=' * (-len(body) % 4)))
|
||
|
||
|
||
class BazaarDynamicPriceTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.user = User.objects.create(phone='09120007302')
|
||
self.client = APIClient()
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||
self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
|
||
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def order(self):
|
||
return self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'bazaar'},
|
||
format='json', HTTP_IDEMPOTENCY_KEY='dp1').json()['orderId']
|
||
|
||
def start(self, order_id):
|
||
return self.client.post(f'/api/payment/bazaar/orders/{order_id}/start', format='json')
|
||
|
||
def redeem(self, order_id):
|
||
return self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json')
|
||
|
||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='')
|
||
def test_without_the_key_discount_is_refused_and_no_token(self):
|
||
order_id = self.order()
|
||
self.assertEqual(self.redeem(order_id).status_code, 400)
|
||
body = self.start(order_id).json()
|
||
self.assertIsNone(body['dynamicPriceToken'])
|
||
self.assertEqual((body['sku'], body['productType']), ('10343', 'subscription'))
|
||
|
||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key', BAZAAR_PACKAGE_NAME='com.tadrisino.math.app')
|
||
def test_discounted_price_is_signed_for_bazaar(self):
|
||
order_id = self.order()
|
||
self.assertEqual(self.redeem(order_id).status_code, 200)
|
||
body = self.start(order_id).json()
|
||
claims = _decode(body['dynamicPriceToken'], 'panel-key')
|
||
self.assertEqual(claims['price'], self.three.price // 2 * 10) # تومان → ریال
|
||
self.assertEqual((claims['sku'], claims['package_name']), ('10343', 'com.tadrisino.math.app'))
|
||
self.assertGreater(claims['exp'], timezone.now().timestamp())
|
||
self.assertTrue(claims['nonce'])
|
||
self.assertEqual(Order.objects.get(pk=order_id).bazaar_price_rial, claims['price'])
|
||
|
||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key')
|
||
def test_full_price_sends_no_token(self):
|
||
# توکن روی بازارِ قدیمیتر از ۱۳.۳ خطا میدهد؛ بیتخفیف لازمش نداریم.
|
||
self.assertIsNone(self.start(self.order()).json()['dynamicPriceToken'])
|
||
|
||
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key')
|
||
def test_no_discount_after_the_purchase_started(self):
|
||
order_id = self.order()
|
||
self.start(order_id)
|
||
self.assertEqual(self.redeem(order_id).status_code, 400)
|
||
self.assertEqual(Order.objects.get(pk=order_id).amount, self.three.price)
|
||
|
||
def test_start_is_only_for_the_owner_and_pending_orders(self):
|
||
order_id = self.order()
|
||
other = APIClient()
|
||
other.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(User.objects.create(phone="09120007303").id)}')
|
||
self.assertEqual(other.post(f'/api/payment/bazaar/orders/{order_id}/start').status_code, 404)
|
||
Order.objects.filter(pk=order_id).update(status='paid')
|
||
self.assertEqual(self.start(order_id).status_code, 400)
|
||
|
||
|
||
class PaymentProviderFactoryTests(TestCase):
|
||
"""payment/providers.py: هر روش یک کلاس؛ روشِ ناشناخته خطا، نه پیشفرضِ زرینپال."""
|
||
|
||
def test_each_method_and_order_value_maps_to_its_class(self):
|
||
from .providers import BazaarProvider, ZarinpalProvider, provider_for, provider_for_method
|
||
self.assertIsInstance(provider_for_method('zarinpal'), ZarinpalProvider)
|
||
self.assertIsInstance(provider_for_method('bazaar'), BazaarProvider)
|
||
self.assertIsInstance(provider_for('gateway'), ZarinpalProvider)
|
||
self.assertIsInstance(provider_for('bazaar'), BazaarProvider)
|
||
# هر مقدارِ مدل یک کلاس دارد؛ مقدارِ تازه در مدل بیکلاس این تست را میشکند.
|
||
self.assertEqual({k for k, _ in Order.PROVIDER_CHOICES}, {'gateway', 'bazaar'})
|
||
for key, _ in Order.PROVIDER_CHOICES:
|
||
provider_for(key)
|
||
|
||
def test_unknown_method_fails_loudly(self):
|
||
from rest_framework.exceptions import ValidationError
|
||
from .providers import provider_for, provider_for_method
|
||
with self.assertRaises(ValidationError):
|
||
provider_for_method('paypal')
|
||
with self.assertRaises(KeyError):
|
||
provider_for('paypal')
|
||
|
||
def test_zarinpal_order_gets_a_pay_link_and_bazaar_order_none(self):
|
||
user = User.objects.create(phone='09120007304')
|
||
client = APIClient()
|
||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||
three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
|
||
z = client.post('/api/subscription/purchase', {'planId': str(three.id)}, format='json').json()
|
||
self.assertIn('/api/payment/orders/', z['paymentUrl'])
|
||
self.assertNotIn('bazaarSku', z)
|
||
b = client.post('/api/subscription/purchase', {'planId': str(three.id), 'paymentMethod': 'bazaar'},
|
||
format='json', HTTP_IDEMPOTENCY_KEY='fb').json()
|
||
self.assertIsNone(b['paymentUrl'])
|
||
self.assertEqual(b['bazaarSku'], '10343')
|