Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
8.3 KiB
Python
175 lines
8.3 KiB
Python
"""
|
||
پایانِ آزمونِ جزیره → جزیرهی بعدی باز میشود.
|
||
|
||
شکافی که این تستها میبندند: اپ با آزمونِ چهارسوالیِ هر جزیره بازی
|
||
میکند، ولی جزیره فقط وقتی «تمام» حساب میشد که تکتکِ درسهایش (۸ تا ۱۳
|
||
درس) جدا تمام شده باشند؛ دانشآموز بعد از جزیرهی اولِ هر فصل گیر میکرد.
|
||
"""
|
||
from django.core.cache import cache
|
||
from django.test import TestCase
|
||
|
||
from accounts.authentication import issue_access_token
|
||
from accounts.models import User
|
||
from content.models import (
|
||
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion,
|
||
)
|
||
from notifications.models import Notification
|
||
from progress.models import UserStats
|
||
|
||
|
||
class NodeQuizCompleteTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||
self.unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
|
||
self.next_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2)
|
||
self.first = self._node(self.unit, 'اول', 1, lessons=5)
|
||
self.second = self._node(self.unit, 'دوم', 2, lessons=3)
|
||
self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2)
|
||
|
||
self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id)
|
||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||
|
||
def _node(self, unit, title, order, lessons):
|
||
node = Node.objects.create(unit=unit, title=title, order=order)
|
||
for i in range(lessons):
|
||
lesson = Lesson.objects.create(node=node, title=f'{title} {i}')
|
||
question = Question.objects.create(
|
||
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}')
|
||
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||
Choice.objects.create(question=question, value='ب', is_correct=False)
|
||
return node
|
||
|
||
def _answer_correctly(self, node, count):
|
||
"""مثلِ اپ: اول جوابها ثبت میشوند، بعد پایانِ آزمون."""
|
||
questions = Question.objects.filter(lesson__node=node).order_by('id')[:count]
|
||
for question in questions:
|
||
choice = question.choices.get(is_correct=True)
|
||
self.client.post(f'/api/questions/{question.pk}/answer', {'selectedOptionId': choice.pk},
|
||
content_type='application/json', **self.auth)
|
||
|
||
def _complete(self, node, correct=3, total=4, answer=True, **headers):
|
||
if answer:
|
||
self._answer_correctly(node, correct)
|
||
return self.client.post(
|
||
f'/api/nodes/{node.pk}/quiz/complete',
|
||
{'correctCount': correct, 'totalQuestions': total},
|
||
content_type='application/json', **self.auth, **headers,
|
||
)
|
||
|
||
def _statuses(self):
|
||
units = self.client.get('/api/home', **self.auth).json()['units']
|
||
return {n['title']: n['status'] for u in units for n in u['nodes']}
|
||
|
||
def test_finishing_the_quiz_opens_the_next_island(self):
|
||
self.assertEqual(self._statuses()['دوم'], 'locked')
|
||
|
||
response = self._complete(self.first)
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
statuses = self._statuses()
|
||
self.assertEqual(statuses['اول'], 'completed')
|
||
self.assertEqual(statuses['دوم'], 'unlocked')
|
||
|
||
def test_a_low_score_still_moves_on(self):
|
||
"""قاعده عمداً ساده است: آزمون که تمام شد، جزیره تمام است."""
|
||
self._complete(self.first, correct=0)
|
||
|
||
self.assertEqual(self._statuses()['دوم'], 'unlocked')
|
||
|
||
def test_it_names_the_next_island(self):
|
||
body = self._complete(self.first).json()
|
||
self.assertEqual(body['nextNodeId'], str(self.second.pk))
|
||
|
||
# آخرین جزیرهی فصل → اولین جزیرهی فصلِ بعد
|
||
body = self._complete(self.second, total=3).json()
|
||
self.assertEqual(body['nextNodeId'], str(self.other_unit_first.pk))
|
||
|
||
# آخرین جزیرهی پایه
|
||
body = self._complete(self.other_unit_first, correct=2, total=2).json()
|
||
self.assertIsNone(body['nextNodeId'])
|
||
|
||
def test_rewards_only_the_first_time(self):
|
||
first = self._complete(self.first, correct=4).json()
|
||
again = self._complete(self.first, correct=4).json()
|
||
|
||
self.assertEqual(first['xpEarned'], 40)
|
||
self.assertEqual(first['stars'], 3)
|
||
self.assertEqual(first['coinsEarned'], 15)
|
||
self.assertEqual(again['xpEarned'], 0)
|
||
self.assertEqual(again['coinsEarned'], 0)
|
||
self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40)
|
||
|
||
def test_answers_that_were_never_given_earn_nothing(self):
|
||
# پیشتر correctCount همانطور که کلاینت فرستاده بود پذیرفته میشد.
|
||
body = self._complete(self.first, correct=4, answer=False).json()
|
||
self.assertEqual(body['xpEarned'], 0)
|
||
self.assertEqual(body['stars'], 0)
|
||
|
||
self._answer_correctly(self.second, 2)
|
||
body = self._complete(self.second, correct=3, total=3, answer=False).json()
|
||
self.assertEqual(body['xpEarned'], 20) # فقط دو جوابِ ثبتشده
|
||
|
||
def test_same_idempotency_key_does_not_double_count(self):
|
||
self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1')
|
||
body = self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1').json()
|
||
|
||
self.assertEqual(body['xpEarned'], 40) # همان نتیجهی قبلی
|
||
self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40)
|
||
|
||
def test_cannot_claim_more_questions_than_the_island_has(self):
|
||
response = self._complete(self.first, correct=9, total=9)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_cannot_claim_more_correct_than_asked(self):
|
||
response = self._complete(self.first, correct=5, total=4)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_requires_sign_in(self):
|
||
response = self.client.post(
|
||
f'/api/nodes/{self.first.pk}/quiz/complete',
|
||
{'correctCount': 1, 'totalQuestions': 4}, content_type='application/json',
|
||
)
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
def test_it_sends_a_notification_once(self):
|
||
self._complete(self.first)
|
||
self._complete(self.first)
|
||
|
||
notes = Notification.objects.filter(user_id=self.user.id, type='node_completed')
|
||
self.assertEqual(notes.count(), 1)
|
||
self.assertIn('اول', notes.first().body)
|
||
|
||
def test_node_progress_is_completed_and_stays_completed(self):
|
||
"""درسِ تازه در همان جزیره نباید آن را به in_progress برگرداند."""
|
||
self._complete(self.first)
|
||
lesson = self.first.lessons.first()
|
||
self.client.post(
|
||
f'/api/lessons/{lesson.pk}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1},
|
||
content_type='application/json', **self.auth,
|
||
)
|
||
|
||
progress = NodeProgress.objects.get(user_id=self.user.id, node=self.first)
|
||
self.assertEqual(progress.status, 'completed')
|
||
|
||
def test_progress_percent_counts_the_islands_lessons(self):
|
||
"""جزیرهی ۵ درسی از ۱۰ درسِ پایه → ۵۰٪، و فقط در پایهی خودِ کاربر."""
|
||
Grade.objects.create(title='پایه دهم', icon='', color='') # مخرج را بزرگ نکند
|
||
body = self._complete(self.first).json()
|
||
|
||
self.assertEqual(body['stats']['progressPercent'], 50.0)
|
||
home = self.client.get('/api/home', **self.auth).json()
|
||
self.assertEqual(home['stats']['progressPercent'], 50.0)
|
||
|
||
def test_lesson_completion_progress_is_per_grade_too(self):
|
||
lesson = self.second.lessons.first()
|
||
body = self.client.post(
|
||
f'/api/lessons/{lesson.pk}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1},
|
||
content_type='application/json', **self.auth,
|
||
).json()
|
||
|
||
self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس
|
||
self.assertTrue(UserLessonCompletion.objects.filter(user_id=self.user.id).exists())
|