Files
MathApp/content/tests_node_completion.py
Zareian b32194497c let a finished island quiz open the next island, and ground ask-AI in the student's book
The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.

Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
  of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 02:49:02 +03:30

155 lines
7.1 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 _complete(self, node, correct=3, total=4, **headers):
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_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())