Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
7.7 KiB
Python
168 lines
7.7 KiB
Python
"""
|
|
پول: ایرادهایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید کرد و حالا بسته شدهاند.
|
|
|
|
هر تست همان سناریوی واقعی را اجرا میکند که پیشتر خراب بود.
|
|
"""
|
|
from unittest.mock import patch
|
|
|
|
from django.core.cache import cache
|
|
from django.test import TestCase
|
|
from django.utils import timezone
|
|
from rest_framework.test import APIClient
|
|
|
|
from accounts.authentication import issue_access_token
|
|
from accounts.models import User
|
|
from affiliate.models import AffiliateAccount, Conversion
|
|
from discount.models import DiscountCode
|
|
from payment.models import Order
|
|
from payment.services import apply_order_payment_result, reconcile_pending_order
|
|
from payment.zarinpal import ZarinpalVerifyResult
|
|
from subscription.models import Plan, Subscription
|
|
|
|
|
|
class _Base(TestCase):
|
|
def setUp(self):
|
|
cache.clear()
|
|
self.user = User.objects.create(phone='09120007001', is_guest=False)
|
|
self.client = APIClient()
|
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
|
self.month = Plan.objects.create(title='ماهانه', price=49000, duration_days=30)
|
|
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
|
|
|
|
def tearDown(self):
|
|
cache.clear()
|
|
|
|
def buy(self, plan):
|
|
response = self.client.post('/api/subscription/purchase', {'planId': str(plan.id)}, format='json')
|
|
self.assertEqual(response.status_code, 200, response.content)
|
|
return Order.objects.get(pk=response.json()['orderId'])
|
|
|
|
def pay(self, order, ref='R'):
|
|
return apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
|
|
new_status='paid', event='test')
|
|
|
|
def subscription(self):
|
|
return Subscription.objects.get(user_id=self.user.id)
|
|
|
|
|
|
class CheckoutTests(_Base):
|
|
def test_paying_the_cheap_order_gives_the_cheap_plan(self):
|
|
cheap = self.buy(self.month)
|
|
self.buy(self.year) # شروعِ خریدِ دوم، بدونِ پرداخت
|
|
self.pay(cheap)
|
|
|
|
sub = self.subscription()
|
|
self.assertEqual(sub.plan, self.month)
|
|
days = (sub.expires_at - timezone.now()).days
|
|
self.assertIn(days, (29, 30))
|
|
|
|
def test_opening_checkout_does_not_take_away_an_active_subscription(self):
|
|
self.pay(self.buy(self.month), ref='R1')
|
|
self.buy(self.year)
|
|
self.assertEqual(self.subscription().status, 'active')
|
|
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
|
|
|
|
def test_renewing_early_keeps_the_remaining_days(self):
|
|
self.pay(self.buy(self.month), ref='R1')
|
|
first_expiry = self.subscription().expires_at
|
|
self.pay(self.buy(self.month), ref='R2')
|
|
self.assertEqual((self.subscription().expires_at - first_expiry).days, 30)
|
|
|
|
def test_an_expired_subscription_is_not_reported_active(self):
|
|
self.pay(self.buy(self.month), ref='R1')
|
|
Subscription.objects.filter(user_id=self.user.id).update(
|
|
expires_at=timezone.now() - timezone.timedelta(days=1))
|
|
self.assertFalse(self.client.get('/api/subscription/status').json()['active'])
|
|
|
|
def test_the_mark_paid_webhook_is_gone(self):
|
|
order = self.buy(self.month)
|
|
response = self.client.post('/api/payment/webhook', {'orderId': order.id, 'status': 'paid'},
|
|
format='json', HTTP_X_WEBHOOK_SECRET='anything')
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
|
|
class DiscountTests(_Base):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.half = DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
|
|
self.tenth = DiscountCode.objects.create(code='TENTH', type='percent', value=10, max_uses=10)
|
|
self.free = DiscountCode.objects.create(code='FREE', type='percent', value=100, max_uses=10)
|
|
|
|
def redeem(self, order, code):
|
|
return self.client.post('/api/discount/redeem', {'orderId': str(order.id), 'code': code}, format='json')
|
|
|
|
def test_one_code_per_order(self):
|
|
order = self.buy(self.month)
|
|
self.assertEqual(self.redeem(order, 'HALF').status_code, 200)
|
|
self.assertEqual(self.redeem(order, 'TENTH').status_code, 400)
|
|
order.refresh_from_db()
|
|
self.assertEqual(order.amount, 24500)
|
|
|
|
def test_no_discount_after_going_to_the_gateway(self):
|
|
order = self.buy(self.month)
|
|
Order.objects.filter(pk=order.pk).update(gateway_authority='A0000000000000000000000000000001')
|
|
self.assertEqual(self.redeem(order, 'HALF').status_code, 400)
|
|
order.refresh_from_db()
|
|
self.assertEqual(order.amount, 49000)
|
|
|
|
def test_a_full_discount_completes_the_purchase_without_a_gateway(self):
|
|
order = self.buy(self.month)
|
|
body = self.redeem(order, 'FREE').json()
|
|
self.assertEqual(body['orderStatus'], 'paid')
|
|
self.assertEqual(body['amount'], 0)
|
|
self.assertEqual(self.subscription().status, 'active')
|
|
|
|
|
|
class ReconcileTests(_Base):
|
|
def test_a_payment_whose_callback_was_lost_is_picked_up(self):
|
|
order = self.buy(self.month)
|
|
Order.objects.filter(pk=order.pk).update(gateway_authority='A1')
|
|
order.refresh_from_db()
|
|
with patch('payment.zarinpal.verify_payment',
|
|
return_value=ZarinpalVerifyResult(is_valid=True, ref_id='REF9')):
|
|
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
|
|
order.refresh_from_db()
|
|
self.assertEqual(order.status, 'paid')
|
|
|
|
def test_an_unpaid_order_is_left_pending_not_failed(self):
|
|
order = self.buy(self.month)
|
|
Order.objects.filter(pk=order.pk).update(gateway_authority='A2')
|
|
order.refresh_from_db()
|
|
with patch('payment.zarinpal.verify_payment', return_value=ZarinpalVerifyResult(is_valid=False)):
|
|
self.assertFalse(reconcile_pending_order(order))
|
|
order.refresh_from_db()
|
|
self.assertEqual(order.status, 'pending')
|
|
|
|
|
|
class AffiliateTests(_Base):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.referrer = User.objects.create(phone='09120007002', is_guest=False)
|
|
self.account = AffiliateAccount.objects.create(user_id=self.referrer.id, referral_code='SARA10',
|
|
commission_rate=0.1)
|
|
|
|
def test_commission_is_recorded_on_the_first_real_payment(self):
|
|
self.assertEqual(self.client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
|
|
format='json').status_code, 200)
|
|
self.pay(self.buy(self.month), ref='R1')
|
|
conversion = Conversion.objects.get(referred_user_id=self.user.id)
|
|
self.assertEqual(conversion.status, 'paid')
|
|
self.assertEqual(conversion.commission_amount, 4900)
|
|
|
|
self.pay(self.buy(self.month), ref='R2') # فقط اولین پرداخت
|
|
self.assertEqual(Conversion.objects.get(pk=conversion.pk).commission_amount, 4900)
|
|
|
|
referrer = APIClient()
|
|
referrer.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.referrer.id)}')
|
|
me = referrer.get('/api/affiliate/me').json()
|
|
self.assertEqual((me['referredCount'], me['paidCount'], me['totalCommission']), (1, 1, 4900))
|
|
|
|
def test_guests_take_no_part(self):
|
|
guest = User.objects.create(device_id='dev-aff', is_guest=True)
|
|
client = APIClient()
|
|
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(guest.id)}')
|
|
self.assertEqual(client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
|
|
format='json').status_code, 403)
|
|
self.assertEqual(client.get('/api/affiliate/me').status_code, 403)
|
|
self.assertFalse(Conversion.objects.exists())
|