tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
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>
This commit is contained in:
167
payment/tests_audit_fixes.py
Normal file
167
payment/tests_audit_fixes.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
پول: ایرادهایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید کرد و حالا بسته شدهاند.
|
||||
|
||||
هر تست همان سناریوی واقعی را اجرا میکند که پیشتر خراب بود.
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user