Files
MathApp/config/tests_audit_round2.py
Zareian 39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

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

139 lines
7.0 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.
"""
بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵: ایرادهایی که بازتولید و بسته شدند.
هر تست همان سناریویی را اجرا می‌کند که پیش‌تر خراب بود. ایرادهایی که عمداً
باز ماندند (تصمیمِ محصولی یا خطرِ شکستنِ فرانت) در docs/security-before-production.md
و کامنتِ همان کدها ثبت‌اند.
"""
from unittest.mock import MagicMock, 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 content.models import Choice, Grade, Lesson, Node, Question, Unit
from discount.models import Offer
from payment.models import Order
from payment.services import apply_order_payment_result
from subscription.models import Plan, Subscription
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
class _Base(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120008001', is_guest=False)
self.client = _client(self.user)
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
def tearDown(self):
cache.clear()
def buy(self, client=None):
response = (client or self.client).post(
'/api/subscription/purchase', {'planId': str(self.year.id)}, format='json')
self.assertEqual(response.status_code, 200, response.content)
return Order.objects.get(pk=response.json()['orderId'])
def pay(self, order, ref):
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
new_status='paid', event='test')
class OfferNeverShortensSubscription(_Base):
def test_trial_code_after_a_paid_year_keeps_the_year(self):
self.pay(self.buy(), ref='R1')
before = Subscription.objects.get(user_id=self.user.id).expires_at
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
response = self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(Subscription.objects.get(user_id=self.user.id).expires_at, before)
@override_settings(DEV_MASTER_OTP='000000')
def test_guest_who_paid_keeps_the_year_after_registering(self):
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7,
auto_assign_on_signup=True)
guest = User.objects.create(device_id='audit-2-device', is_guest=True)
guest_client = _client(guest)
self.pay(self.buy(guest_client), ref='R2')
response = guest_client.post('/api/auth/login', {'phone': '09125550001', 'otp': '000000'},
format='json')
self.assertEqual(response.status_code, 200, response.content)
days = (Subscription.objects.get(user_id=guest.id).expires_at - timezone.now()).days
self.assertGreater(days, 360)
def test_trial_still_works_for_a_user_without_subscription(self):
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
sub = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(sub.status, 'active')
self.assertIn((sub.expires_at - timezone.now()).days, (6, 7))
def test_free_full_still_upgrades_a_paid_year_to_unlimited(self):
self.pay(self.buy(), ref='R3')
Offer.objects.create(name='رایگانِ کامل', kind='free_full', code='FULL')
self.client.post('/api/discount/offers/redeem', {'code': 'FULL'}, format='json')
self.assertIsNone(Subscription.objects.get(user_id=self.user.id).expires_at)
@override_settings(ZARINPAL_MERCHANT_ID='x' * 36, ZARINPAL_REDIRECT_URL='https://app.example/')
class ZarinpalErrorBodyReturnsToApp(_Base):
def test_documented_error_body_redirects_instead_of_500(self):
order = self.buy()
Order.objects.filter(pk=order.pk).update(gateway_authority='A' * 36)
fake = MagicMock(status_code=200)
fake.json.return_value = {'data': [], 'errors': {'code': -51, 'message': 'failed'}}
with patch('payment.zarinpal.requests.post', return_value=fake):
response = APIClient().get('/api/payment/zarinpal/callback',
{'Authority': 'A' * 36, 'Status': 'OK'})
self.assertEqual(response.status_code, 302)
self.assertIn('status=failed', response['Location'])
class WeeklyReportCountsIslands(TestCase):
# تا ۲۰۲۶-۱۰-۰۱ همین سناریو گزارشِ والد را از /api/family/weekly-report می‌خواند؛
# آن اپ حذف شد و گزارش حالا در حسابِ خودِ دانش‌آموز است.
def test_island_quiz_xp_reaches_the_weekly_report(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1),
title='جزیره', order=1)
child = User.objects.create(phone='09120008002', grade_id=grade.id)
child_client = _client(child)
for i in range(4):
question = Question.objects.create(
lesson=Lesson.objects.create(node=node, title=f'درس {i}'),
type='multiple_choice', prompt=f'سوال {i}')
right = Choice.objects.create(question=question, value='الف', is_correct=True)
Choice.objects.create(question=question, value='ب', is_correct=False)
child_client.post(f'/api/questions/{question.id}/answer', {'selectedOptionId': right.id},
format='json')
child_client.post(f'/api/nodes/{node.id}/quiz/complete',
{'correctCount': 4, 'totalQuestions': 4}, format='json')
week = child_client.get('/api/user/weekly-report').json()['thisWeek']
self.assertEqual(week['xp'], 40)
self.assertEqual(week['islands'], 1)
self.assertEqual(week['lessons'], 0)
self.assertEqual((week['questions'], week['correct']), (4, 4))
class AskWithNonNumericQuestionId(TestCase):
def test_answer_is_saved_not_500(self):
cache.clear()
user = User.objects.create(phone='09120008004')
response = _client(user).post('/api/ai/ask',
{'userQuestion': 'کسر چیست؟', 'questionId': 'q-12'},
format='json')
self.assertEqual(response.status_code, 200, response.content)
from ai_tutor.models import Message
self.assertIsNone(Message.objects.get(pk=response.json()['messageId']).question_id)