Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
(403 node_locked, same rule as /api/home) and a user who has not answered every question
(409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
anyone else finished any island, locked or not, and finishing a locked island's lessons
unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,10 @@ 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,
|
||||
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion, UserNodeCompletion,
|
||||
)
|
||||
from content.testing import record_answers
|
||||
from content.views import NodeQuizView
|
||||
from notifications.models import Notification
|
||||
from progress.models import UserStats
|
||||
|
||||
@@ -40,17 +42,20 @@ class NodeQuizCompleteTests(TestCase):
|
||||
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)
|
||||
def _answer(self, node, correct, wrong=0):
|
||||
"""مثلِ اپ: اول جوابها ثبت میشوند (`correct` تای اول درست، `wrong` تای بعدی غلط)، بعد پایانِ آزمون."""
|
||||
questions = list(Question.objects.filter(lesson__node=node).order_by('id')[:correct + wrong])
|
||||
for index, question in enumerate(questions):
|
||||
choice = question.choices.get(is_correct=index < correct)
|
||||
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)
|
||||
# سرور همهی سوالهای آزمون را جوابداده میخواهد (دستِکم آزمونِ استانداردِ ۴ تایی).
|
||||
pool = Question.objects.filter(lesson__node=node).count()
|
||||
needed = max(total, min(NodeQuizView.DEFAULT_SIZE, pool))
|
||||
self._answer(node, correct, wrong=max(needed - correct, 0))
|
||||
return self.client.post(
|
||||
f'/api/nodes/{node.pk}/quiz/complete',
|
||||
{'correctCount': correct, 'totalQuestions': total},
|
||||
@@ -101,14 +106,62 @@ class NodeQuizCompleteTests(TestCase):
|
||||
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)
|
||||
# پیشتر correctCount همانطور که کلاینت فرستاده بود پذیرفته میشد؛ بعد (۲۰۲۶-۱۰-۰۷) بیجواب
|
||||
# اصلاً تمام نمیشود: ۴۰۹، و نه جزیره تمام میشود نه امتیازی ثبت.
|
||||
response = self._complete(self.first, correct=4, answer=False)
|
||||
self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing'))
|
||||
self.assertFalse(UserNodeCompletion.objects.filter(user_id=self.user.id).exists())
|
||||
self.assertEqual(self._statuses()['دوم'], 'locked')
|
||||
|
||||
self._answer_correctly(self.second, 2)
|
||||
body = self._complete(self.second, correct=3, total=3, answer=False).json()
|
||||
self.assertEqual(body['xpEarned'], 20) # فقط دو جوابِ ثبتشده
|
||||
# چهار جواب ثبت شده ولی فقط دو تا درست؛ ادعای ۴ درست فقط برای همان دو امتیاز میدهد.
|
||||
self._answer(self.first, correct=2, wrong=2)
|
||||
body = self._complete(self.first, correct=4, total=4, answer=False).json()
|
||||
self.assertEqual(body['xpEarned'], 20)
|
||||
|
||||
def test_understating_the_question_count_does_not_buy_stars(self):
|
||||
"""با totalQuestions=1 و یک جوابِ درست ۳ ستاره میشد؛ آزمونِ استانداردِ سرور ۴ سوال است."""
|
||||
self._answer(self.first, correct=1, wrong=3)
|
||||
body = self._complete(self.first, correct=1, total=1, answer=False).json()
|
||||
self.assertEqual(body['stars'], 1) # ۱ از ۴
|
||||
self.assertEqual(UserNodeCompletion.objects.get(user_id=self.user.id).total_questions, 4)
|
||||
|
||||
def test_answering_fewer_questions_than_the_quiz_has_is_refused(self):
|
||||
self._answer(self.first, correct=3)
|
||||
response = self._complete(self.first, correct=3, total=4, answer=False)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual((response.json()['answered'], response.json()['required']), (3, 4))
|
||||
|
||||
def test_a_locked_island_cannot_be_finished(self):
|
||||
"""جزیرهی دوم تا اولی تمام نشده قفل است؛ حتی با جوابِ همهی سوالهایش."""
|
||||
self._answer(self.second, correct=3)
|
||||
response = self._complete(self.second, correct=3, total=3, answer=False)
|
||||
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
|
||||
self.assertFalse(UserNodeCompletion.objects.filter(user_id=self.user.id).exists())
|
||||
self.assertEqual(self._statuses()['دوم'], 'locked')
|
||||
|
||||
def test_the_first_island_of_every_unit_is_open(self):
|
||||
"""همانطور که نقشه نشان میدهد: اولین جزیرهی فصلها بیقید باز است."""
|
||||
self.assertEqual(self._statuses()['فصلِ بعد'], 'unlocked')
|
||||
self.assertEqual(self._complete(self.other_unit_first, correct=2, total=2).status_code, 200)
|
||||
|
||||
def test_an_open_island_without_questions_passes_without_answers_and_earns_nothing(self):
|
||||
"""چیزی برای جوابدادن نیست؛ رد میشود تا مسیرِ بقیه را نبندد (بیستاره، بیامتیاز)."""
|
||||
empty = Node.objects.create(unit=self.next_unit, title='خالی', order=0) # اولین جزیرهی فصل: باز
|
||||
Lesson.objects.create(node=empty, title='درسِ بیسوال')
|
||||
response = self.client.post(
|
||||
f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1},
|
||||
content_type='application/json', **self.auth)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual((response.json()['xpEarned'], response.json()['stars']), (0, 0))
|
||||
self.assertEqual(response.json()['nextNodeId'], str(self.other_unit_first.pk))
|
||||
|
||||
def test_a_locked_island_without_questions_is_still_locked(self):
|
||||
empty = Node.objects.create(unit=self.unit, title='خالیِ قفل', order=9) # بعد از دو جزیرهی تمامنشده
|
||||
Lesson.objects.create(node=empty, title='درسِ بیسوال')
|
||||
response = self.client.post(
|
||||
f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1},
|
||||
content_type='application/json', **self.auth)
|
||||
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
|
||||
|
||||
def test_same_idempotency_key_does_not_double_count(self):
|
||||
self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1')
|
||||
@@ -144,6 +197,7 @@ class NodeQuizCompleteTests(TestCase):
|
||||
"""درسِ تازه در همان جزیره نباید آن را به in_progress برگرداند."""
|
||||
self._complete(self.first)
|
||||
lesson = self.first.lessons.first()
|
||||
record_answers(self.user.id, lesson=lesson)
|
||||
self.client.post(
|
||||
f'/api/lessons/{lesson.pk}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 1},
|
||||
@@ -163,7 +217,9 @@ class NodeQuizCompleteTests(TestCase):
|
||||
self.assertEqual(home['stats']['progressPercent'], 50.0)
|
||||
|
||||
def test_lesson_completion_progress_is_per_grade_too(self):
|
||||
lesson = self.second.lessons.first()
|
||||
# درسی از جزیرهی باز (جزیرهی دوم تا اولی تمام نشده قفل است و درسش را نمیشود تمام کرد).
|
||||
lesson = self.first.lessons.first()
|
||||
record_answers(self.user.id, lesson=lesson)
|
||||
body = self.client.post(
|
||||
f'/api/lessons/{lesson.pk}/complete',
|
||||
{'correctCount': 1, 'totalQuestions': 1},
|
||||
|
||||
Reference in New Issue
Block a user