Files
MathApp/content/tests.py
Zareian 7ecb337263 Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:30:24 +03:30

740 lines
35 KiB
Python
Raw Permalink 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.
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
from progress.models import UserHearts, UserStats
from subscription.models import Plan, Subscription
from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
from .testing import record_answers
class LessonCompleteImpossibleCountTests(TestCase):
"""رگرسیون: correctCount باید با تعدادِ واقعیِ سوال‌های درس مقایسه بشه، وگرنه
کلاینتِ مخرب می‌تونه xp/coins رو متورم کنه."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(phone='09000000011', name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
for i in range(3):
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
Choice.objects.create(question=question, value='درست', is_correct=True)
def _complete(self, correct_count, total_questions):
# مثلِ اپ: جواب‌ها اول ثبت می‌شوند؛ امتیاز فقط برای جوابِ ثبت‌شده است.
for question in self.lesson.questions.order_by('id')[:max(correct_count, 0)]:
self.client.post(
f'/api/questions/{question.id}/answer',
{'selectedOptionId': str(question.choices.get(is_correct=True).id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_correct_count_exceeding_actual_questions_is_rejected(self):
# فقط ۳ سوال واقعی در این درس هست؛ کلاینت ادعای ۱۰۰ تای درست از
# ۱۰ تای کل رو می‌کنه.
response = self._complete(correct_count=100, total_questions=10)
self.assertEqual(response.status_code, 400)
self.assertIn('correctCount', response.data)
def test_negative_correct_count_is_rejected(self):
response = self._complete(correct_count=-1, total_questions=3)
self.assertEqual(response.status_code, 400)
def test_zero_total_questions_is_rejected(self):
response = self._complete(correct_count=1, total_questions=0)
self.assertEqual(response.status_code, 400)
def test_valid_completion_still_grants_expected_reward(self):
# ۳ از ۳ سوالِ واقعیِ همین درس، درست — باید ۳ ستاره و xp کامل بده.
response = self._complete(correct_count=3, total_questions=3)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['stars'], 3)
self.assertEqual(response.data['xpEarned'], 30)
self.assertEqual(response.data['coinsEarned'], 15)
class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
"""رگرسیون: ratio باید از actual_question_count محاسبه بشه نه از totalQuestیِ
ارسالیِ کلاینت، وگرنه حتی بعدِ سقف‌زدنِ correctCount کلاینت می‌تونه با
totalQuestions دروغین ratio رو مصنوعاً ۱۰۰٪ کنه."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(phone='09000000012', name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه دوم', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۲', order=1)
node = Node.objects.create(unit=unit, title='گره ۲', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۲', intro_text='...')
for i in range(3):
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
Choice.objects.create(question=question, value='درست', is_correct=True)
def _complete(self, correct_count, total_questions):
# مثلِ اپ: همه‌ی سوال‌ها جواب داده می‌شوند (پایانِ درس بی‌جواب پذیرفته نمی‌شود)، ولی فقط
# `correct_count` تایشان درست؛ امتیاز فقط برای جوابِ درستِ ثبت‌شده است.
for index, question in enumerate(self.lesson.questions.order_by('id')):
Choice.objects.create(question=question, value='غلط', is_correct=False)
self.client.post(
f'/api/questions/{question.id}/answer',
{'selectedOptionId': str(question.choices.get(
is_correct=index < max(correct_count, 0)).id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_manipulated_total_questions_does_not_yield_full_stars(self):
# درسِ ۳-سوالی؛ کلاینت فقط ۱ درست داشته ولی totalQuestions=1
# می‌فرسته تا وانمود کنه ۱۰۰٪ جواب داده.
response = self._complete(correct_count=1, total_questions=1)
self.assertEqual(response.status_code, 200)
self.assertNotEqual(response.data['stars'], 3)
def test_ratio_is_based_on_actual_lesson_question_count(self):
# ۱ از ۳ سوالِ واقعی = ratio نزدیکِ ۰.۳۳ → طبقِ آستانه‌های موجود
# (>=0.9 → 3, >=0.6 → 2, >0 → 1)، باید دقیقاً ۱ ستاره بده.
response = self._complete(correct_count=1, total_questions=1)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['stars'], 1)
class QuestionAnswerThrottleTests(TestCase):
"""محدودیتِ per-user روی QuestionAnswerView (throttle_scope='question_answer')؛
Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت."""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000013', name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
self.question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
)
self.correct_choice = Choice.objects.create(
question=self.question, value='درست', is_correct=True,
)
def _answer(self, idempotency_key=None, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if idempotency_key:
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.correct_choice.id)},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
for _ in range(3):
response = self._answer()
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
for _ in range(3):
response = self._answer()
self.assertEqual(response.status_code, 200)
blocked_response = self._answer()
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# کلیدِ throttle شناسه‌ی کاربر است، نه IP؛ تغییرِ IP سطلِ تازه نمی‌دهد.
registered_user = User.objects.create(name='', phone='+989120000701')
registered_token = issue_access_token(registered_user.id)
def _answer_as(remote_addr):
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.correct_choice.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
for i in range(3):
response = _answer_as(f'10.2.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _answer_as('10.2.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_answer_and_no_hearts_or_progress_change(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
first_response = self._answer()
self.assertEqual(first_response.status_code, 200)
answer_count_before = UserAnswer.objects.filter(user_id=self.user.id).count()
hearts_before = UserHearts.objects.filter(user_id=self.user.id).first()
hearts_snapshot = hearts_before.current_hearts if hearts_before else None
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
progress_snapshot = (
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
if progress_before else None
)
blocked_response = self._answer()
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(
UserAnswer.objects.filter(user_id=self.user.id).count(), answer_count_before,
)
hearts_after = UserHearts.objects.filter(user_id=self.user.id).first()
self.assertEqual(
hearts_after.current_hearts if hearts_after else None, hearts_snapshot,
)
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
if progress_snapshot is None:
self.assertIsNone(progress_after)
else:
self.assertEqual(progress_after.status, progress_snapshot['status'])
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
def test_idempotency_key_retry_still_works_within_the_limit(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '10/min'}):
first_response = self._answer(idempotency_key='retry-key-1')
self.assertEqual(first_response.status_code, 200)
second_response = self._answer(idempotency_key='retry-key-1')
self.assertEqual(second_response.status_code, 200)
self.assertEqual(
UserAnswer.objects.filter(
user_id=self.user.id, question=self.question, idempotency_key='retry-key-1',
).count(),
1,
)
class LessonCompleteThrottleTests(TestCase):
"""محدودیتِ per-user روی LessonCompleteView (throttle_scope='lesson_complete')؛
Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت."""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000014', name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
# پایانِ درس بی‌جواب پذیرفته نمی‌شود؛ این تست‌ها فقط سقفِ نرخ را می‌سنجند.
record_answers(self.user.id, lesson=self.lesson)
def _complete(self, idempotency_key=None, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if idempotency_key:
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 3},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
for _ in range(3):
response = self._complete()
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
for _ in range(3):
response = self._complete()
self.assertEqual(response.status_code, 200)
blocked_response = self._complete()
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# کلیدِ throttle شناسه‌ی کاربر است، نه IP؛ تغییرِ IP سطلِ تازه نمی‌دهد.
registered_user = User.objects.create(name='', phone='+989120000702')
registered_token = issue_access_token(registered_user.id)
record_answers(registered_user.id, lesson=self.lesson)
def _complete_as(remote_addr):
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 3},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
for i in range(3):
response = _complete_as(f'10.3.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _complete_as('10.3.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_completion_or_other_side_effects(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/min'}):
first_response = self._complete()
self.assertEqual(first_response.status_code, 200)
completion_count_before = UserLessonCompletion.objects.filter(user_id=self.user.id).count()
stats_before = UserStats.objects.filter(user_id=self.user.id).first()
stats_snapshot = (
{'xp': stats_before.xp, 'coins': stats_before.coins, 'progress_percent': stats_before.progress_percent}
if stats_before else None
)
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
progress_snapshot = (
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
if progress_before else None
)
blocked_response = self._complete()
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(
UserLessonCompletion.objects.filter(user_id=self.user.id).count(), completion_count_before,
)
stats_after = UserStats.objects.filter(user_id=self.user.id).first()
if stats_snapshot is None:
self.assertIsNone(stats_after)
else:
self.assertEqual(stats_after.xp, stats_snapshot['xp'])
self.assertEqual(stats_after.coins, stats_snapshot['coins'])
self.assertEqual(stats_after.progress_percent, stats_snapshot['progress_percent'])
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
if progress_snapshot is None:
self.assertIsNone(progress_after)
else:
self.assertEqual(progress_after.status, progress_snapshot['status'])
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
class QuestionAnswerPerUserThrottleTests(TestCase):
"""هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد."""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
self.question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
)
self.correct_choice = Choice.objects.create(
question=self.question, value='درست', is_correct=True,
)
def _answer(self, token):
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.correct_choice.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
user_a = User.objects.create(name='', phone='+989120000711')
response_a = self._answer(issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(name='', phone='+989120000712')
response_b = self._answer(issue_access_token(user_b.id))
self.assertEqual(response_b.status_code, 200)
class LessonCompletePerUserThrottleTests(TestCase):
"""هر کاربرِ ثبت‌نام‌شده سطلِ throttleِ جدای خودش را دارد؛ مصرفِ حسابِ دیگر به او نمی‌رسد."""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
def _complete(self, token):
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 3},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
user_a = User.objects.create(name='', phone='+989120000721')
record_answers(user_a.id, lesson=self.lesson)
response_a = self._complete(issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(name='', phone='+989120000722')
record_answers(user_b.id, lesson=self.lesson)
response_b = self._complete(issue_access_token(user_b.id))
self.assertEqual(response_b.status_code, 200)
class QuestionAnswerMascotMoodTests(TestCase):
"""پاسخِ /answer باید mascotMood بده: درست → happy، غلط → encourage
(هم‌خوان با RobotMood در MathApp_frontend)."""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000015', name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه اول', icon='i', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
self.question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='۱+۱؟')
self.correct = Choice.objects.create(question=self.question, value='۲', is_correct=True)
self.wrong = Choice.objects.create(question=self.question, value='۳', is_correct=False)
def _answer(self, choice_id):
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(choice_id)}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_correct_answer_mood_happy(self):
r = self._answer(self.correct.id)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data['mascotMood'], 'happy')
def test_wrong_answer_mood_encourage(self):
r = self._answer(self.wrong.id)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data['mascotMood'], 'encourage')
def test_idempotent_replay_keeps_mood(self):
k = 'mascot-idem-1'
first = self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.wrong.id)}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}', HTTP_IDEMPOTENCY_KEY=k,
)
replay = self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.wrong.id)}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}', HTTP_IDEMPOTENCY_KEY=k,
)
self.assertEqual(first.data['mascotMood'], 'encourage')
self.assertEqual(replay.data['mascotMood'], 'encourage')
# ─────────────────────────────────────────────────────────────────────────────
# مقیاس‌پذیریِ HomeView: رفعِ N+1، کشِ برنامه‌ی درسی، ۴۰۴ گریدِ ناموجود
# ─────────────────────────────────────────────────────────────────────────────
from django.core.cache import cache as _django_cache
from django.test.utils import CaptureQueriesContext
from django.db import connection
class HomeViewScalabilityTests(TestCase):
def setUp(self):
_django_cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000016', name='')
self.token = issue_access_token(self.user.id)
def _grade_with_nodes(self, n_nodes):
grade = Grade.objects.create(title=f'g{n_nodes}', icon='i', color='#fff')
unit = Unit.objects.create(grade=grade, title='u', order=1)
for i in range(n_nodes):
node = Node.objects.create(unit=unit, title=f'n{i}', order=i)
Lesson.objects.create(node=node, title=f'l{i}', intro_text='.')
return grade
def _home(self, grade_id):
return self.client.get(
f'/api/home?gradeId={grade_id}', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def test_query_count_is_flat_regardless_of_node_count(self):
g_small = self._grade_with_nodes(2)
g_big = self._grade_with_nodes(20)
_django_cache.clear()
with CaptureQueriesContext(connection) as q_small:
self.assertEqual(self._home(g_small.id).status_code, 200)
_django_cache.clear()
with CaptureQueriesContext(connection) as q_big:
self.assertEqual(self._home(g_big.id).status_code, 200)
# قبلِ رفعِ N+1، q_big چند برابرِ q_small بود (یک COUNT به‌ازای هر node).
self.assertEqual(len(q_small), len(q_big))
def test_second_call_hits_cache_not_curriculum_tables(self):
g = self._grade_with_nodes(5)
self.assertEqual(self._home(g.id).status_code, 200) # پر کردنِ کش
with CaptureQueriesContext(connection) as q:
self.assertEqual(self._home(g.id).status_code, 200)
sql = ' '.join(e['sql'] for e in q).lower()
self.assertNotIn('content_unit', sql)
# content_nodeprogress دیتای کاربر است (جزیره‌های تمام‌شده)، نه برنامه‌ی درسی.
self.assertNotRegex(sql, r'content_node(?!progress)')
# فقط کوئریِ per-user باید بمونه
self.assertIn('userlessoncompletion', sql.replace('_', ''))
def test_content_change_invalidates_tree_cache(self):
g = self._grade_with_nodes(3)
self._home(g.id)
unit = g.units.first()
Node.objects.create(unit=unit, title='new-node', order=99) # post_save → bump version
r = self._home(g.id)
node_titles = [n['title'] for u in r.data['units'] for n in u['nodes']]
self.assertIn('new-node', node_titles)
def test_missing_grade_returns_404(self):
r = self._home(999999)
self.assertEqual(r.status_code, 404)
def test_grade_with_no_units_returns_empty_not_404(self):
g = Grade.objects.create(title='empty', icon='i', color='#fff')
r = self._home(g.id)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data['units'], [])
class GradesListCacheTests(TestCase):
def setUp(self):
_django_cache.clear()
self.client = APIClient()
def test_list_is_cached_and_invalidated_on_new_grade(self):
Grade.objects.create(title='اول', icon='i', color='#111')
r1 = self.client.get('/api/grades')
self.assertEqual(len(r1.data['grades']), 1)
with CaptureQueriesContext(connection) as q:
self.client.get('/api/grades')
self.assertEqual(len([e for e in q if 'content_grade' in e['sql'].lower()]), 0)
Grade.objects.create(title='دوم', icon='i', color='#222') # post_save → bump
r3 = self.client.get('/api/grades')
self.assertEqual(len(r3.data['grades']), 2)
class UnlimitedHeartsSubscriptionTests(TestCase):
"""رگرسیون: هر مشترکِ فعال (فارغ از پلن) نباید با جوابِ غلط هارت از دست بده."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(phone='09000000017', name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
self.question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='سوال ۱')
Choice.objects.create(question=self.question, value='درست', is_correct=True)
self.wrong_choice = Choice.objects.create(question=self.question, value='غلط', is_correct=False)
def _answer_wrong(self):
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.wrong_choice.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_wrong_answer_costs_a_heart_without_subscription(self):
UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5)
response = self._answer_wrong()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['heartsRemaining'], 4)
def test_wrong_answer_does_not_cost_a_heart_with_active_subscription(self):
UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5)
plan = Plan.objects.create(title='ماهانه', price=100000)
Subscription.objects.create(
user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() + timedelta(days=30),
)
response = self._answer_wrong()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['heartsRemaining'], 5)
self.assertEqual(UserHearts.objects.get(user_id=self.user.id).current_hearts, 5)
def test_expired_subscription_still_costs_a_heart(self):
UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5)
plan = Plan.objects.create(title='ماهانه', price=100000)
Subscription.objects.create(
user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() - timedelta(days=1),
)
response = self._answer_wrong()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['heartsRemaining'], 4)
class ReadThrottleTests(TestCase):
def setUp(self):
_django_cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000018', name='')
self.token = issue_access_token(self.user.id)
self.grade = Grade.objects.create(title='g', icon='i', color='#fff')
def test_read_endpoint_throttles_after_limit(self):
# rate پیش‌فرض '120/min' — بعد از ۱۲۰ درخواستِ موفق، ۱۲۱اُم باید 429 بشه.
codes = set()
for _ in range(122):
codes.add(self.client.get(
f'/api/home?gradeId={self.grade.id}',
HTTP_AUTHORIZATION=f'Bearer {self.token}').status_code)
self.assertIn(429, codes)
class HomeGradeFallbackTests(TestCase):
"""
/api/home باید در نبودِ ?grade= از پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده کند.
پیش‌تر این‌طور نبود و نتیجه‌اش در اپ این می‌شد: کاربر در صفحه‌ی آشنایی پایه
را انتخاب می‌کرد و بعد صفحه‌ی خانه خالی می‌آمد.
"""
def setUp(self):
_django_cache.clear()
self.client = APIClient()
self.user = User.objects.create(phone='09000000019', name='')
self.token = issue_access_token(self.user.id)
self.grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
Lesson.objects.create(node=node, title='توان صحیح', intro_text='.')
def tearDown(self):
_django_cache.clear()
def _home(self, query=''):
return self.client.get(f'/api/home{query}', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def test_uses_the_saved_grade_when_no_param_is_given(self):
self.user.grade_id = self.grade.id
self.user.save(update_fields=['grade_id'])
response = self._home()
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data['units']), 1)
self.assertEqual(response.data['units'][0]['title'], 'فصل ۱')
def test_explicit_param_wins_over_the_saved_grade(self):
other = Grade.objects.create(title='پایه دهم', icon='grade-10', color='#E91E63')
unit = Unit.objects.create(grade=other, title='فصل مثلثات', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
Lesson.objects.create(node=node, title='سینوس', intro_text='.')
self.user.grade_id = self.grade.id
self.user.save(update_fields=['grade_id'])
response = self._home(f'?gradeId={other.id}')
self.assertEqual(response.data['units'][0]['title'], 'فصل مثلثات')
def test_user_without_a_grade_gets_an_empty_map_not_an_error(self):
response = self._home()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['units'], [])
def test_unknown_explicit_grade_is_still_404(self):
self.assertEqual(self._home('?gradeId=999999').status_code, 404)
def test_a_deleted_saved_grade_degrades_to_an_empty_map(self):
# پایه‌ای که کاربر ذخیره کرده حذف شده — این خطای کاربر نیست.
self.user.grade_id = 999999
self.user.save(update_fields=['grade_id'])
response = self._home()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['units'], [])
class NodeProgressUnitParamTests(TestCase):
def test_non_numeric_unit_is_a_400_not_a_500(self):
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
user = User.objects.create(phone='09000000020')
response = APIClient().get('/api/progress/nodes?unit=abc',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
self.assertEqual(response.status_code, 400)