Files
MathApp/content/tests_hearts_gate.py
Zareian 947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:38 +03:30

142 lines
6.3 KiB
Python
Raw Permalink 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 .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_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)