- 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>
151 lines
6.9 KiB
Python
151 lines
6.9 KiB
Python
"""
|
||
قلبِ صفر واقعاً جلوی ادامه را میگیرد (۲۰۲۶-۱۰-۰۲، خواستِ صاحبِ پروژه).
|
||
|
||
راههای شکستن که امتحان میشوند: جواب دادن با قلبِ صفر، تمام کردنِ درس یا جزیره بیجواب و
|
||
بیقلب (پیش از این ممکن بود)، دو جوابِ غلطِ همزمان با یک قلبِ باقیمانده (پیش از این هر دو
|
||
پذیرفته میشدند چون چکِ قلب بیرونِ تراکنش بود)، و اینکه کسی که آخرین قلبش را روی آخرین
|
||
سوال از دست داده هنوز بتواند جزیره را تمام کند.
|
||
"""
|
||
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)
|