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)