Files
MathApp/content/tests_hearts_gate.py
Zareian 7ecb337263 Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:30:24 +03:30

151 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
قلبِ صفر واقعاً جلوی ادامه را می‌گیرد (۲۰۲۶-۱۰-۰۲، خواستِ صاحبِ پروژه).
راه‌های شکستن که امتحان می‌شوند: جواب دادن با قلبِ صفر، تمام کردنِ درس یا جزیره بی‌جواب و
بی‌قلب (پیش از این ممکن بود)، دو جوابِ غلطِ هم‌زمان با یک قلبِ باقی‌مانده (پیش از این هر دو
پذیرفته می‌شدند چون چکِ قلب بیرونِ تراکنش بود)، و این‌که کسی که آخرین قلبش را روی آخرین
سوال از دست داده هنوز بتواند جزیره را تمام کند.
"""
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)