Files
MathApp/payment/tests_bazaar_subscription.py
Zareian 947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:38 +03:30

252 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(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(on_sale=True, duration_days=180).bazaar_sku, '10344')
self.assertEqual(Plan.objects.get(on_sale=True, duration_days=30).bazaar_sku, '10342')
def test_plan_without_sku_is_refused_for_bazaar(self):
Plan.objects.filter(pk=self.three.pk).update(bazaar_sku='')
self.assertEqual(self.buy(self.three).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['paymentUrl']), ('10343', 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')
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_verify_never_uses_the_clients_sku(self):
# سفارشی بی‌SKU (دستی ساخته‌شده): SKUِ فرستاده‌ی کلاینت هرگز سنجیده نمی‌شود.
order = Order.objects.create(user_id=self.user.id, amount=1, provider='bazaar', idempotency_key='no-sku-1')
with patch('payment.bazaar.verify_purchase') as verify:
response = self.client.post('/api/payment/bazaar/verify', {
'orderId': str(order.id), 'productSku': 'cheapest', 'purchaseToken': 't'}, format='json')
self.assertEqual(response.status_code, 400)
verify.assert_not_called()
def test_same_idempotency_key_cannot_switch_payment_method(self):
self.assertEqual(self.buy(self.three, key='k-1').status_code, 200)
response = self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'zarinpal'},
format='json', HTTP_IDEMPOTENCY_KEY='k-1')
self.assertEqual(response.status_code, 409)
def test_unknown_payment_method_is_a_400(self):
response = self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'paypal'},
format='json')
self.assertEqual(response.status_code, 400)
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.called)
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(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'], '10343')
@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(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')