hearts: zero hearts really stops play

- The zero-hearts check moved inside the answer transaction: two wrong
  answers sent at once with one heart left were both accepted (reproduced
  with two threads).
- Finishing a lesson or an island without hearts and without having
  answered its questions is now 403 no_hearts; before, quiz/complete let
  a student move on island after island with zero hearts. Losing the last
  heart on the last question still finishes the island.
- A heart that came back while waiting is now saved before answering, so
  the reply no longer says 0 hearts left (the app then showed the
  no-hearts page to someone who had a heart).
- The 403 also carries fullRefillAt for the waiting screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:31:44 +03:30
parent 0f0b779a4e
commit 4700ccbf91
2 changed files with 207 additions and 20 deletions

View File

@@ -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)

View File

@@ -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)