""" اشتراکِ کافه‌بازار، «تخفیف پویا» و تمدیدِ خودکار (۲۰۲۶-۱۰-۱۲). بازار با `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)