Files
MathApp/content/tests_node_completion.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
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>
2026-09-25 06:24:13 +03:30

175 lines
8.3 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.
"""
پایانِ آزمونِ جزیره → جزیره‌ی بعدی باز می‌شود.
شکافی که این تست‌ها می‌بندند: اپ با آزمونِ چهارسوالیِ هر جزیره بازی
می‌کند، ولی جزیره فقط وقتی «تمام» حساب می‌شد که تک‌تکِ درس‌هایش (۸ تا ۱۳
درس) جدا تمام شده باشند؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد.
"""
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())