""" قلبِ صفر واقعاً جلوی ادامه را می‌گیرد (۲۰۲۶-۱۰-۰۲، خواستِ صاحبِ پروژه). راه‌های شکستن که امتحان می‌شوند: جواب دادن با قلبِ صفر، تمام کردنِ درس یا جزیره بی‌جواب و بی‌قلب (پیش از این ممکن بود)، دو جوابِ غلطِ هم‌زمان با یک قلبِ باقی‌مانده (پیش از این هر دو پذیرفته می‌شدند چون چکِ قلب بیرونِ تراکنش بود)، و این‌که کسی که آخرین قلبش را روی آخرین سوال از دست داده هنوز بتواند جزیره را تمام کند. """ 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) 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)) # جزیره دو سوال دارد و پایان بی‌جواب پذیرفته نمی‌شود؛ قلبِ نامحدود هر دو جواب را می‌گیرد. for question in self.questions: self.assertEqual(self.answer(question, 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): # با قلبِ خاموش، قلبِ صفر هم جواب‌دادن را نمی‌بندد؛ پایانِ جزیره هم پس از جواب‌ها می‌رسد. for question in self.questions: self.assertEqual(self.answer(question, correct=True).status_code, 200) 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)