The math-app frontend prototype drives a RobotMascot with a fixed set of
moods; this gives that animation an explicit backend signal. Complements
the existing Lesson.mascot_animation / LessonIntro.mascotAnimation, which
are unchanged.
serializers.py
- AnswerResponseSerializer: new mascotMood field.
views.py
- QuestionAnswerView: mascotMood = 'happy' for a correct answer else
'encourage', on both the fresh response and the idempotent-replay
response.
tests.py
- QuestionAnswerMascotMoodTests: correct -> happy, wrong -> encourage,
and the mood is preserved across an idempotent replay.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
544 lines
28 KiB
Python
544 lines
28 KiB
Python
from unittest.mock import patch
|
||
|
||
from django.test import TestCase
|
||
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 .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
|
||
|
||
|
||
class LessonCompleteImpossibleCountTests(TestCase):
|
||
"""رگرسیون: correctCount باید با تعدادِ واقعیِ سوالهای درس مقایسه بشه، وگرنه
|
||
کلاینتِ مخرب میتونه xp/coins رو متورم کنه."""
|
||
|
||
def setUp(self):
|
||
self.client = APIClient()
|
||
self.user = User.objects.create(device_id='device-complete-1', is_guest=True, 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.objects.create(
|
||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||
)
|
||
|
||
def _complete(self, correct_count, total_questions):
|
||
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(device_id='device-complete-2', is_guest=True, 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.objects.create(
|
||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||
)
|
||
|
||
def _complete(self, correct_count, total_questions):
|
||
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(device_id='device-answer-throttle-1', is_guest=True, 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):
|
||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
|
||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||
# QuestionAnswerGuestThrottleBypassTests پایینتر) — این تست فقط
|
||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||
# میکنه که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||
registered_user = User.objects.create(is_guest=False, 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(device_id='device-complete-throttle-1', is_guest=True, 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}',
|
||
)
|
||
|
||
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):
|
||
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده میشه (نه self.user که
|
||
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
|
||
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
|
||
# LessonCompleteGuestThrottleBypassTests پایینتر) — این تست فقط
|
||
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبتنامشده رو تضمین
|
||
# میکنه که باید دقیقاً مثلِ قبل دستنخورده بمونه.
|
||
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000702')
|
||
registered_token = issue_access_token(registered_user.id)
|
||
|
||
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 QuestionAnswerGuestThrottleBypassTests(TestCase):
|
||
"""اثباتِ فیکسِ throttle: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید
|
||
bucket تازهی question_answer بده — GuestAwareContentThrottle برای
|
||
مهمانها کلید رو بر اساسِ IP میسازه، نه user.pk."""
|
||
|
||
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_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '2/min'}):
|
||
guest1 = User.objects.create(device_id='device-answer-guest-rot-1', is_guest=True, name='')
|
||
response1 = self._answer(issue_access_token(guest1.id))
|
||
self.assertEqual(response1.status_code, 200)
|
||
|
||
guest2 = User.objects.create(device_id='device-answer-guest-rot-2', is_guest=True, name='')
|
||
response2 = self._answer(issue_access_token(guest2.id))
|
||
self.assertEqual(response2.status_code, 200)
|
||
|
||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||
guest3 = User.objects.create(device_id='device-answer-guest-rot-3', is_guest=True, name='')
|
||
response3 = self._answer(issue_access_token(guest3.id))
|
||
|
||
self.assertEqual(response3.status_code, 429)
|
||
|
||
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(is_guest=False, 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(is_guest=False, name='', phone='+989120000712')
|
||
response_b = self._answer(issue_access_token(user_b.id))
|
||
|
||
self.assertEqual(response_b.status_code, 200)
|
||
|
||
def test_blocked_guest_request_creates_no_answer_or_hearts_change(self):
|
||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید UserAnswer بسازه
|
||
# یا هارت/progress رو تغییر بده.
|
||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
|
||
guest1 = User.objects.create(device_id='device-answer-guest-rot-4', is_guest=True, name='')
|
||
first_response = self._answer(issue_access_token(guest1.id))
|
||
self.assertEqual(first_response.status_code, 200)
|
||
|
||
guest2 = User.objects.create(device_id='device-answer-guest-rot-5', is_guest=True, name='')
|
||
blocked_response = self._answer(issue_access_token(guest2.id))
|
||
|
||
self.assertEqual(blocked_response.status_code, 429)
|
||
self.assertEqual(UserAnswer.objects.filter(user_id=guest2.id).count(), 0)
|
||
self.assertFalse(UserHearts.objects.filter(user_id=guest2.id).exists())
|
||
|
||
|
||
class LessonCompleteGuestThrottleBypassTests(TestCase):
|
||
"""اثباتِ فیکسِ throttle: چرخشِ حساب مهمان نباید bucket تازهی
|
||
lesson_complete بده — GuestAwareContentThrottle برای مهمانها کلید رو
|
||
بر اساسِ IP میسازه، نه user.pk."""
|
||
|
||
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_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||
# سه حساب مهمانِ متفاوت (شبیهسازیِ چرخشِ حساب برای دورزدنِ throttle)،
|
||
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
|
||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '2/hour'}):
|
||
guest1 = User.objects.create(device_id='device-complete-guest-rot-1', is_guest=True, name='')
|
||
response1 = self._complete(issue_access_token(guest1.id))
|
||
self.assertEqual(response1.status_code, 200)
|
||
|
||
guest2 = User.objects.create(device_id='device-complete-guest-rot-2', is_guest=True, name='')
|
||
response2 = self._complete(issue_access_token(guest2.id))
|
||
self.assertEqual(response2.status_code, 200)
|
||
|
||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||
guest3 = User.objects.create(device_id='device-complete-guest-rot-3', is_guest=True, name='')
|
||
response3 = self._complete(issue_access_token(guest3.id))
|
||
|
||
self.assertEqual(response3.status_code, 429)
|
||
|
||
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(is_guest=False, name='', phone='+989120000721')
|
||
response_a = self._complete(issue_access_token(user_a.id))
|
||
self.assertEqual(response_a.status_code, 200)
|
||
|
||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000722')
|
||
response_b = self._complete(issue_access_token(user_b.id))
|
||
|
||
self.assertEqual(response_b.status_code, 200)
|
||
|
||
def test_blocked_guest_request_creates_no_completion_or_reward_side_effects(self):
|
||
# درخواستِ بلاکشده (از حسابِ مهمانِ چرخشی) نباید UserLessonCompletion
|
||
# بسازه یا UserStats/NodeProgress رو تغییر بده.
|
||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
|
||
guest1 = User.objects.create(device_id='device-complete-guest-rot-4', is_guest=True, name='')
|
||
first_response = self._complete(issue_access_token(guest1.id))
|
||
self.assertEqual(first_response.status_code, 200)
|
||
|
||
guest2 = User.objects.create(device_id='device-complete-guest-rot-5', is_guest=True, name='')
|
||
blocked_response = self._complete(issue_access_token(guest2.id))
|
||
|
||
self.assertEqual(blocked_response.status_code, 429)
|
||
self.assertEqual(UserLessonCompletion.objects.filter(user_id=guest2.id).count(), 0)
|
||
self.assertFalse(UserStats.objects.filter(user_id=guest2.id).exists())
|
||
self.assertFalse(NodeProgress.objects.filter(user_id=guest2.id, node=self.node).exists())
|
||
|
||
|
||
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(device_id='device-mascot-1', is_guest=True, 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')
|