diff --git a/content/tests_hearts_gate.py b/content/tests_hearts_gate.py new file mode 100644 index 0000000..8866eba --- /dev/null +++ b/content/tests_hearts_gate.py @@ -0,0 +1,145 @@ +""" +قلبِ صفر واقعاً جلوی ادامه را می‌گیرد (۲۰۲۶-۱۰-۰۲، خواستِ صاحبِ پروژه). + +راه‌های شکستن که امتحان می‌شوند: جواب دادن با قلبِ صفر، تمام کردنِ درس یا جزیره بی‌جواب و +بی‌قلب (پیش از این ممکن بود)، دو جوابِ غلطِ هم‌زمان با یک قلبِ باقی‌مانده (پیش از این هر دو +پذیرفته می‌شدند چون چکِ قلب بیرونِ تراکنش بود)، و این‌که کسی که آخرین قلبش را روی آخرین +سوال از دست داده هنوز بتواند جزیره را تمام کند. +""" +import threading +from datetime import timedelta + +from django.core.cache import cache +from django.db import connection +from django.test import TestCase, TransactionTestCase, 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 progress.models import UserHearts +from subscription.models import HeartsPass + +from .models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer + + +def _client(user): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + return client + + +class _World: + def build(self, phone): + cache.clear() + self.user = User.objects.create(phone=phone, is_guest=False) + self.client = _client(self.user) + grade = Grade.objects.create(title='پایه نهم', icon='', color='') + self.node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1), + title='جزیره', order=1) + self.lessons, self.questions = [], [] + for i in range(2): + lesson = Lesson.objects.create(node=self.node, title=f'درس {i}') + question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟') + Choice.objects.create(question=question, value='درست', is_correct=True) + Choice.objects.create(question=question, value='غلط', is_correct=False) + self.lessons.append(lesson) + self.questions.append(question) + + def hearts(self, n, next_in=timedelta(hours=4)): + now = timezone.now() + UserHearts.objects.update_or_create(user_id=self.user.id, defaults={ + 'current_hearts': n, 'next_refill_at': now + next_in, 'full_refill_at': now + timedelta(hours=20), + }) + + def answer(self, question, correct, client=None): + choice = question.choices.get(is_correct=correct) + return (client or self.client).post(f'/api/questions/{question.id}/answer', + {'selectedOptionId': str(choice.id)}, format='json') + + def finish_island(self, total=2): + return self.client.post(f'/api/nodes/{self.node.id}/quiz/complete', + {'correctCount': 0, 'totalQuestions': total}, format='json') + + def finish_lesson(self, lesson): + return self.client.post(f'/api/lessons/{lesson.id}/complete', + {'correctCount': 0, 'totalQuestions': 1}, format='json') + + +@override_settings(HEARTS_ENFORCED=True) +class HeartsGateTests(_World, TestCase): + def setUp(self): + self.build('09120007301') + + def tearDown(self): + cache.clear() + + def test_no_answer_with_zero_hearts_and_the_reply_says_when_they_come_back(self): + self.hearts(0) + response = self.answer(self.questions[0], correct=True) + self.assertEqual(response.status_code, 403) + body = response.json() + self.assertEqual(body['code'], 'no_hearts') + self.assertIsNotNone(body['nextRefillAt']) + self.assertIsNotNone(body['fullRefillAt']) + self.assertFalse(UserAnswer.objects.exists()) + + def test_an_island_cannot_be_finished_without_hearts_and_without_answers(self): + self.hearts(0) + response = self.finish_island() + self.assertEqual((response.status_code, response.json().get('code')), (403, 'no_hearts')) + + def test_a_lesson_cannot_be_finished_without_hearts_and_without_answers(self): + self.hearts(0) + response = self.finish_lesson(self.lessons[0]) + self.assertEqual((response.status_code, response.json().get('code')), (403, 'no_hearts')) + + def test_losing_the_last_heart_on_the_last_question_still_finishes_the_island(self): + self.hearts(2) + self.assertEqual(self.answer(self.questions[0], correct=False).json()['heartsRemaining'], 1) + self.assertEqual(self.answer(self.questions[1], correct=False).json()['heartsRemaining'], 0) + self.assertEqual(self.finish_island().status_code, 200) + + def test_unlimited_hearts_from_a_pack_are_never_blocked(self): + self.hearts(0) + HeartsPass.objects.create(user_id=self.user.id, expires_at=timezone.now() + timedelta(days=7)) + self.assertEqual(self.answer(self.questions[0], correct=False).status_code, 200) + self.assertEqual(self.finish_island().status_code, 200) + + def test_after_the_wait_a_heart_comes_back_and_play_continues(self): + self.hearts(0, next_in=timedelta(seconds=-1)) + response = self.answer(self.questions[0], correct=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['heartsRemaining'], 1) + + def test_hearts_off_changes_nothing(self): + self.hearts(0) + with override_settings(HEARTS_ENFORCED=False): + self.assertEqual(self.finish_island().status_code, 200) + + +@override_settings(HEARTS_ENFORCED=True) +class ConcurrentLastHeartTests(_World, TransactionTestCase): + def setUp(self): + self.build('09120007302') + + def test_two_wrong_answers_at_once_with_one_heart_accept_only_one(self): + self.hearts(1) + codes = [] + barrier = threading.Barrier(2) + + def go(question): + try: + client = _client(self.user) + barrier.wait() + codes.append(self.answer(question, correct=False, client=client).status_code) + finally: + connection.close() + + threads = [threading.Thread(target=go, args=(q,)) for q in self.questions] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(sorted(codes), [200, 403]) + self.assertEqual(UserAnswer.objects.count(), 1) diff --git a/content/views.py b/content/views.py index 0ae16e6..a16e117 100644 --- a/content/views.py +++ b/content/views.py @@ -192,6 +192,42 @@ def _record_streak(user_id): _FA_DIGITS = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹') +def no_hearts_response(hearts) -> Response: + """۴۰۳ِ «قلب تمام شد»؛ فرانت با code=no_hearts صفحه‌ی انتظار را با شمارشِ معکوس نشان می‌دهد.""" + return Response( + { + 'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.', + 'code': 'no_hearts', + 'nextRefillAt': hearts.next_refill_at, + 'fullRefillAt': hearts.full_refill_at, + }, + status=status.HTTP_403_FORBIDDEN, + ) + + +def blocked_without_hearts(user_id, answered: int, needed: int) -> Response | None: + """ + پایانِ درس یا جزیره برای کسی که قلب ندارد و سوال‌هایش را جواب نداده: ۴۰۳. + + جواب دادن بی‌قلب ممکن نیست (QuestionAnswerView)، ولی تا ۲۰۲۶-۱۰-۰۲ پایانِ آزمون + (`quiz/complete`) و پایانِ درس هیچ شرطی نداشتند، پس با قلبِ صفر هم می‌شد جزیره پشتِ جزیره + جلو رفت. کسی که آخرین قلبش را روی آخرین سوال از دست داده همه را جواب داده و رد نمی‌شود. + """ + if not settings.HEARTS_ENFORCED or answered >= needed or has_unlimited_hearts(user_id): + return None + hearts = UserHearts.objects.filter(user_id=user_id).first() + if hearts is None: + return None + if hearts.apply_regen(): + hearts.save(update_fields=UserHearts.REGEN_FIELDS) + return no_hearts_response(hearts) if hearts.current_hearts <= 0 else None + + +def _answered(user_id, **lookup) -> int: + """چند سوالِ متفاوت از این درس/جزیره را این کاربر تا حالا جواب داده.""" + return UserAnswer.objects.filter(user_id=user_id, **lookup).values('question_id').distinct().count() + + def _completion_body(title, stars, xp): """ متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همان‌طور که @@ -455,26 +491,20 @@ class QuestionAnswerView(APIView): # اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل # خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ # سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد. - # قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو). بدونِ این، «قلبِ نامحدود» - # (تنها مزیتِ واقعیِ اشتراک) فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ - # مستقیمِ API دور زده می‌شد. پشتِ HEARTS_ENFORCED است چون فرانتِ فعلی - # برای این ۴۰۳ هنوز صفحه‌ی «قلب‌ها تمام شد» ندارد. - if settings.HEARTS_ENFORCED and not has_unlimited_hearts(user_id): - hearts = UserHearts.objects.filter(user_id=user_id).first() - if hearts is not None: - hearts.apply_regen() - if hearts.current_hearts <= 0: - return Response( - { - 'detail': 'قلب‌هایت تمام شده؛ کمی صبر کن تا دوباره پر شوند.', - 'code': 'no_hearts', - 'nextRefillAt': hearts.next_refill_at, - }, - status=status.HTTP_403_FORBIDDEN, - ) - + # قلبِ صفر = نمی‌شود جواب داد (مثلِ دولینگو)، مگر قلبِ نامحدود (اشتراک یا کارتِ قلبِ بسته). + # بدونِ این، «قلبِ نامحدود» فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ مستقیمِ API دور زده + # می‌شد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند: تا ۲۰۲۶-۱۰-۰۲ بیرونش بود و + # دو جوابِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (فقط خوانده شد، بازتولید نه). + unlimited = has_unlimited_hearts(user_id) try: with transaction.atomic(): + hearts = None + if settings.HEARTS_ENFORCED and not unlimited: + hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first() + if hearts is not None and hearts.apply_regen(): + hearts.save(update_fields=UserHearts.REGEN_FIELDS) + if hearts is not None and hearts.current_hearts <= 0: + return no_hearts_response(hearts) UserAnswer.objects.create( user_id=user_id, question=question, @@ -487,12 +517,12 @@ class QuestionAnswerView(APIView): UserHearts.objects.get_or_create(user_id=user_id) hearts = UserHearts.objects.select_for_update().get(user_id=user_id) hearts.apply_regen() - if not has_unlimited_hearts(user_id): + if not unlimited: hearts.lose_heart() hearts.save(update_fields=UserHearts.REGEN_FIELDS) hearts_remaining = hearts.current_hearts else: - hearts = UserHearts.objects.filter(user_id=user_id).first() + hearts = hearts or UserHearts.objects.filter(user_id=user_id).first() hearts_remaining = hearts.current_hearts if hearts else 5 except IntegrityError: if not idempotency_key: @@ -602,6 +632,12 @@ class LessonCompleteView(APIView): } return Response(LessonCompleteResponseSerializer(data).data) + blocked = blocked_without_hearts( + user_id, _answered(user_id, question__lesson=lesson), actual_question_count, + ) + if blocked is not None: + return blocked + # atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه # (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save # روی stats یعنی تکمیل ثبت شده ولی xp/coins یک‌بارمصرف از دست رفته. @@ -936,6 +972,12 @@ class NodeQuizCompleteView(APIView): if existing is not None: return respond(existing, UserStats.objects.filter(user_id=user_id).first()) + blocked = blocked_without_hearts( + user_id, _answered(user_id, question__lesson__node_id=node_id), total_questions, + ) + if blocked is not None: + return blocked + try: with transaction.atomic(): UserStats.objects.get_or_create(user_id=user_id)