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>
This commit is contained in:
154
content/tests_node_completion.py
Normal file
154
content/tests_node_completion.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
پایانِ آزمونِ جزیره → جزیرهی بعدی باز میشود.
|
||||
|
||||
شکافی که این تستها میبندند: اپ با آزمونِ چهارسوالیِ هر جزیره بازی
|
||||
میکند، ولی جزیره فقط وقتی «تمام» حساب میشد که تکتکِ درسهایش (۸ تا ۱۳
|
||||
درس) جدا تمام شده باشند؛ دانشآموز بعد از جزیرهی اولِ هر فصل گیر میکرد.
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user