Files
MathApp/accounts/tests_audit_fixes.py
Zareian 58c8e6d014 question packs instead of monthly/yearly plans; hearts are real
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:39 +03:30

156 lines
7.2 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 time
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import UserHearts, UserStats
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
class LogoutTests(TestCase):
def setUp(self):
cache.clear()
def test_logout_kills_the_current_token_and_its_refresh(self):
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'},
format='json').json()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
self.assertEqual(client.get('/api/user/profile').status_code, 200)
time.sleep(1.1) # زمانِ توکن ثانیه‌ای است؛ توکن باید پیش از خروج صادر شده باشد
response = client.post('/api/auth/logout', {'refreshToken': guest['refreshToken']}, format='json')
self.assertEqual(response.status_code, 200)
self.assertEqual(client.get('/api/user/profile').status_code, 401)
refreshed = APIClient().post('/api/auth/refresh', {'refreshToken': guest['refreshToken']}, format='json')
self.assertEqual(refreshed.status_code, 401)
# ورودِ دوباره کار می‌کند
again = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-out'},
format='json').json()
fresh = APIClient()
fresh.credentials(HTTP_AUTHORIZATION=f'Bearer {again["token"]}')
self.assertEqual(fresh.get('/api/user/profile').status_code, 200)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
class OtpTests(TestCase):
def setUp(self):
cache.clear()
def _request(self, phone, code='123456'):
with patch('accounts.views.secrets.randbelow', return_value=int(code)), \
patch('accounts.views.send_otp_sms'):
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
def test_a_code_dies_after_five_wrong_guesses(self):
phone = '09121234001'
self._request(phone)
for guess in ('000001', '000002', '000003', '000004', '000005'):
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
self.assertTrue(OTPCode.objects.get(phone=phone).is_used)
right = APIClient().post('/api/auth/login', {'phone': phone, 'otp': '123456'}, format='json')
self.assertEqual(right.status_code, 400)
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
TEST_LOGIN_PHONES={'09120000101': '4321'})
class GuestSignupKeepsProgressTests(TestCase):
def setUp(self):
cache.clear()
guest = APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': 'dev-up'},
format='json').json()
self.guest_id = int(guest['userId'])
UserStats.objects.create(user_id=self.guest_id, xp=120)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {guest["token"]}')
def _login(self, phone, otp):
return self.client.post('/api/auth/login', {'phone': phone, 'otp': otp}, format='json')
def test_the_guest_becomes_the_registered_user(self):
with patch('accounts.views.secrets.randbelow', return_value=654321), \
patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': '09121234002'}, format='json')
body = self._login('09121234002', '654321').json()
self.assertEqual(int(body['userId']), self.guest_id)
user = User.objects.get(pk=self.guest_id)
self.assertEqual((user.phone, user.is_guest), ('09121234002', False))
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
def test_an_existing_account_is_not_merged(self):
existing = User.objects.create(phone='09121234003', is_guest=False)
with patch('accounts.views.secrets.randbelow', return_value=111222), \
patch('accounts.views.send_otp_sms'):
self.client.post('/api/auth/otp/request', {'phone': '09121234003'}, format='json')
body = self._login('09121234003', '111222').json()
self.assertEqual(int(body['userId']), existing.id)
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
def test_demo_test_phones_never_absorb_a_guest(self):
body = self._login('09120000101', '4321').json()
self.assertNotEqual(int(body['userId']), self.guest_id)
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
class DisplayNameTests(TestCase):
def test_contact_details_are_stripped_from_the_name(self):
user = User.objects.create(phone='09121234004', is_guest=False)
body = _client(user).patch('/api/user/profile', {'name': 'سارا 0912 345 6789 @sara'},
format='json').json()
self.assertEqual(body['name'], 'سارا')
class HeartsTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1), title='ن', order=1)
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
type='multiple_choice', prompt='؟')
self.right = Choice.objects.create(question=self.question, value='۱', is_correct=True)
self.user = User.objects.create(phone='09121234005', is_guest=False)
UserHearts.objects.create(user_id=self.user.id, current_hearts=0)
def _answer(self):
return _client(self.user).post(f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.right.id}, format='json')
# از ۲۰۲۶-۱۰-۰۲ پیش‌فرض روشن است (قلبِ هدیه‌ی بسته‌ها بدونِ آن اثری نداشت)؛ با .env خاموش می‌شود.
@override_settings(HEARTS_ENFORCED=False)
def test_can_be_switched_off(self):
self.assertEqual(self._answer().status_code, 200)
@override_settings(HEARTS_ENFORCED=True)
def test_when_enforced_no_hearts_means_no_answer(self):
response = self._answer()
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()['code'], 'no_hearts')
class AdminLoginThrottleTests(TestCase):
def setUp(self):
cache.clear()
@override_settings(ADMIN_LOGIN_MAX_ATTEMPTS=3)
def test_password_guessing_is_capped(self):
codes = [self.client.post('/admin/login/', {'username': 'a', 'password': 'b'}).status_code
for _ in range(4)]
self.assertEqual(codes[-1], 429)
self.assertNotEqual(codes[0], 429)