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:
145
content/tests_hearts_gate.py
Normal file
145
content/tests_hearts_gate.py
Normal 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)
|
||||||
@@ -192,6 +192,42 @@ def _record_streak(user_id):
|
|||||||
_FA_DIGITS = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹')
|
_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):
|
def _completion_body(title, stars, xp):
|
||||||
"""
|
"""
|
||||||
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همانطور که
|
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همانطور که
|
||||||
@@ -455,26 +491,20 @@ class QuestionAnswerView(APIView):
|
|||||||
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
|
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
|
||||||
# خوانده/کم میشود تا lost update در پاسخهای همزمان رخ ندهد. قید یکتاییِ
|
# خوانده/کم میشود تا lost update در پاسخهای همزمان رخ ندهد. قید یکتاییِ
|
||||||
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را میگیرد.
|
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را میگیرد.
|
||||||
# قلبِ صفر = نمیشود جواب داد (مثلِ دولینگو). بدونِ این، «قلبِ نامحدود»
|
# قلبِ صفر = نمیشود جواب داد (مثلِ دولینگو)، مگر قلبِ نامحدود (اشتراک یا کارتِ قلبِ بسته).
|
||||||
# (تنها مزیتِ واقعیِ اشتراک) فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ
|
# بدونِ این، «قلبِ نامحدود» فقط در ظاهرِ اپ وجود داشت و با صدا زدنِ مستقیمِ API دور زده
|
||||||
# مستقیمِ API دور زده میشد. پشتِ HEARTS_ENFORCED است چون فرانتِ فعلی
|
# میشد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم میکند: تا ۲۰۲۶-۱۰-۰۲ بیرونش بود و
|
||||||
# برای این ۴۰۳ هنوز صفحهی «قلبها تمام شد» ندارد.
|
# دو جوابِ همزمان با یک قلبِ باقیمانده هر دو پذیرفته میشدند (فقط خوانده شد، بازتولید نه).
|
||||||
if settings.HEARTS_ENFORCED and not has_unlimited_hearts(user_id):
|
unlimited = 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
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(
|
UserAnswer.objects.create(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
question=question,
|
question=question,
|
||||||
@@ -487,12 +517,12 @@ class QuestionAnswerView(APIView):
|
|||||||
UserHearts.objects.get_or_create(user_id=user_id)
|
UserHearts.objects.get_or_create(user_id=user_id)
|
||||||
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
|
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
|
||||||
hearts.apply_regen()
|
hearts.apply_regen()
|
||||||
if not has_unlimited_hearts(user_id):
|
if not unlimited:
|
||||||
hearts.lose_heart()
|
hearts.lose_heart()
|
||||||
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
|
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
|
||||||
hearts_remaining = hearts.current_hearts
|
hearts_remaining = hearts.current_hearts
|
||||||
else:
|
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
|
hearts_remaining = hearts.current_hearts if hearts else 5
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
if not idempotency_key:
|
if not idempotency_key:
|
||||||
@@ -602,6 +632,12 @@ class LessonCompleteView(APIView):
|
|||||||
}
|
}
|
||||||
return Response(LessonCompleteResponseSerializer(data).data)
|
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 رو تریگر میکنه
|
# atomic لازمه چون create پایینتر سیگنالِ post_save رو تریگر میکنه
|
||||||
# (NodeProgress) و UserStats هم آپدیت میشه؛ بدون اون، fail شدنِ save
|
# (NodeProgress) و UserStats هم آپدیت میشه؛ بدون اون، fail شدنِ save
|
||||||
# روی stats یعنی تکمیل ثبت شده ولی xp/coins یکبارمصرف از دست رفته.
|
# روی stats یعنی تکمیل ثبت شده ولی xp/coins یکبارمصرف از دست رفته.
|
||||||
@@ -936,6 +972,12 @@ class NodeQuizCompleteView(APIView):
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return respond(existing, UserStats.objects.filter(user_id=user_id).first())
|
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:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
UserStats.objects.get_or_create(user_id=user_id)
|
UserStats.objects.get_or_create(user_id=user_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user