Files
MathApp/payment/tests_bazaar_subscription.py
Zareian 8077ab4350 Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
  response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
  code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
  and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
  the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:11 +03:30

204 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
اشتراکِ کافه‌بازار، «تخفیف پویا» و تمدیدِ خودکار (۲۰۲۶-۱۰-۱۲).
بازار با `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, '')
def test_plan_without_sku_is_refused_for_bazaar(self):
monthly = Plan.objects.get(kind='subscription', on_sale=True, duration_days=30)
self.assertEqual(self.buy(monthly).status_code, 400)
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)