Files
MathApp/content/tests_finish_requires_answers.py
Zareian 7ecb337263 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>
2026-10-07 13:30:24 +03:30

193 lines
11 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.
"""
پایانِ درس و جزیره بی‌جواب و روی جزیره‌ی قفل پذیرفته نمی‌شود (۲۰۲۶-۱۰-۰۷).
تا پیش از این: کسی که قلب داشت و هیچ سوالی جواب نداده بود جزیره را تمام می‌کرد، حتی جزیره‌ی قفل را
(با correctCount=0)؛ نقشه هم وضعیتِ «باز» را از روی درس‌های تمام‌شده حساب می‌کرد، پس تمام‌کردنِ
درسِ جزیره‌ی قفل آن را باز می‌کرد. فقط قلبِ صفر جلوی بی‌جواب‌تمام‌کردن را می‌گرفت.
"""
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from progress.models import UserHearts, UserStats
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion, UserNodeCompletion,
)
from .testing import record_answers
from .views import node_is_unlocked
class _World:
def build(self, phone):
cache.clear()
self.grade = Grade.objects.create(title='پایه نهم', icon='', color='')
self.unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
self.other_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2)
self.n1 = self.node(self.unit, 'یک', 1)
self.n2 = self.node(self.unit, 'دو', 2)
self.n3 = self.node(self.unit, 'سه', 3)
self.m1 = self.node(self.other_unit, 'الف', 1)
self.user = User.objects.create(phone=phone, grade_id=self.grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def node(self, unit, title, order, lessons=2):
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}')
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=q, value='درست', is_correct=True)
Choice.objects.create(question=q, value='غلط', is_correct=False)
return node
def finish_node(self, node, correct=0, total=2):
return self.client.post(f'/api/nodes/{node.id}/quiz/complete',
{'correctCount': correct, 'totalQuestions': total}, format='json')
def finish_lesson(self, lesson):
return self.client.post(f'/api/lessons/{lesson.id}/complete',
{'correctCount': 0, 'totalQuestions': 1}, format='json')
def statuses(self):
units = self.client.get('/api/home').json()['units']
return {n['title']: n['status'] for u in units for n in u['nodes']}
class FinishWithoutAnswersTests(_World, TestCase):
def setUp(self):
self.build('09120007801')
def tearDown(self):
cache.clear()
def test_a_user_with_hearts_and_no_answers_cannot_finish_the_island(self):
"""همان جمله‌ای که باز مانده بود: قلب دارد، هیچ سوالی جواب نداده."""
self.assertEqual(UserHearts.objects.filter(user_id=self.user.id).count(), 0) # قلبِ پیش‌فرضِ پر
response = self.finish_node(self.n1)
self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing'))
self.assertEqual((response.json()['answered'], response.json()['required']), (0, 2))
self.assertFalse(UserNodeCompletion.objects.exists())
self.assertFalse(NodeProgress.objects.filter(status='completed').exists())
self.assertEqual(self.statuses()['دو'], 'locked')
def test_the_locked_island_cannot_be_finished_even_after_answering_everything(self):
record_answers(self.user.id, node=self.n2)
response = self.finish_node(self.n2, correct=2)
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
self.assertFalse(UserNodeCompletion.objects.exists())
self.assertFalse(UserStats.objects.filter(user_id=self.user.id, xp__gt=0).exists())
def test_the_locked_island_cannot_be_finished_with_zero_answers_either(self):
response = self.finish_node(self.n3)
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
def test_finishing_the_lessons_of_a_locked_island_does_not_unlock_it(self):
"""بدونِ قفلِ درس، تمام‌کردنِ یک درسِ جزیره‌ی قفل وضعیتِ آن را «باز» می‌کرد."""
lesson = self.n2.lessons.first()
record_answers(self.user.id, lesson=lesson)
response = self.finish_lesson(lesson)
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
self.assertFalse(UserLessonCompletion.objects.exists())
self.assertEqual(self.statuses()['دو'], 'locked')
def test_a_lesson_cannot_be_finished_without_answers(self):
lesson = self.n1.lessons.first()
response = self.finish_lesson(lesson)
self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing'))
self.assertFalse(UserLessonCompletion.objects.exists())
def test_a_lesson_with_some_answers_missing_is_refused(self):
lesson = self.n1.lessons.first()
second = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='دوم؟')
Choice.objects.create(question=second, value='x', is_correct=True)
first = lesson.questions.exclude(pk=second.pk).get()
record_answers(self.user.id, lesson=lesson)
from .models import UserAnswer
UserAnswer.objects.filter(question=second).delete() # فقط سوالِ اول جواب داده شده
response = self.finish_lesson(lesson)
self.assertEqual((response.status_code, response.json()['answered']), (409, 1))
record_answers(self.user.id, lesson=lesson)
self.assertEqual(self.finish_lesson(lesson).status_code, 200)
self.assertTrue(first.answers.exists())
def test_a_lesson_without_questions_in_an_open_island_still_passes_and_earns_nothing(self):
empty = Lesson.objects.create(node=self.n1, title='بی‌سوال')
response = self.finish_lesson(empty)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual((response.json()['xpEarned'], response.json()['stars']), (0, 0))
def test_a_lesson_without_questions_in_a_locked_island_is_still_locked(self):
empty = Lesson.objects.create(node=self.n3, title='بی‌سوالِ قفل')
response = self.finish_lesson(empty)
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
self.assertFalse(UserLessonCompletion.objects.exists())
def test_the_normal_path_still_works_in_order(self):
"""جزیره‌ی اول، بعد دوم، بعد سوم؛ و اولین جزیره‌ی فصلِ دیگر از اول باز است."""
self.assertEqual(self.statuses(), {'یک': 'unlocked', 'دو': 'locked', 'سه': 'locked', 'الف': 'unlocked'})
for node in (self.n1, self.n2, self.n3, self.m1):
record_answers(self.user.id, node=node)
response = self.finish_node(node, correct=2)
self.assertEqual(response.status_code, 200, (node.title, response.content))
self.assertEqual(set(self.statuses().values()), {'completed'})
def test_replaying_a_finished_island_still_works(self):
record_answers(self.user.id, node=self.n1)
self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200)
self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200)
def test_an_idempotent_retry_of_a_finished_island_returns_the_first_result(self):
record_answers(self.user.id, node=self.n1)
kwargs = {'HTTP_IDEMPOTENCY_KEY': 'k-1'}
first = self.client.post(f'/api/nodes/{self.n1.id}/quiz/complete',
{'correctCount': 2, 'totalQuestions': 2}, format='json', **kwargs)
again = self.client.post(f'/api/nodes/{self.n1.id}/quiz/complete',
{'correctCount': 2, 'totalQuestions': 2}, format='json', **kwargs)
self.assertEqual((first.status_code, again.status_code), (200, 200))
self.assertEqual(first.json()['xpEarned'], again.json()['xpEarned'])
def test_unanswered_with_zero_hearts_still_gets_the_hearts_page_not_a_generic_error(self):
from datetime import timedelta
from django.utils import timezone
now = timezone.now()
UserHearts.objects.create(user_id=self.user.id, current_hearts=0,
next_refill_at=now + timedelta(hours=4), full_refill_at=now + timedelta(hours=20))
with override_settings(HEARTS_ENFORCED=True):
response = self.finish_node(self.n1)
self.assertEqual((response.status_code, response.json()['code']), (403, 'no_hearts'))
class NodeIsUnlockedMatchesHomeTests(_World, TestCase):
"""قاعده‌ی قفلِ سرور باید دقیقاً همانی باشد که نقشه (`/api/home`) نشان می‌دهد."""
def setUp(self):
self.build('09120007802')
def _assert_matches_home(self, label):
home = self.statuses()
for node in (self.n1, self.n2, self.n3, self.m1):
self.assertEqual(
node_is_unlocked(self.user.id, node), home[node.title] != 'locked',
f'{label}: {node.title} (نقشه: {home[node.title]})',
)
def test_matches_at_every_step(self):
self._assert_matches_home('شروع')
record_answers(self.user.id, node=self.n1)
self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200)
self._assert_matches_home('بعد از جزیره‌ی یک')
# تمام‌کردنِ درس‌های جزیره‌ی دو (از راهِ درس، نه آزمون)
for lesson in self.n2.lessons.all():
record_answers(self.user.id, lesson=lesson)
self.assertEqual(self.finish_lesson(lesson).status_code, 200)
self._assert_matches_home('بعد از درس‌های جزیره‌ی دو')
self.assertEqual(self.statuses()['سه'], 'unlocked')
def test_a_node_missing_from_the_tree_is_locked_and_logged(self):
ghost = Node(id=999999, unit=self.unit, title='شبح', order=9)
with self.assertLogs('content.views', level='WARNING'):
self.assertFalse(node_is_unlocked(self.user.id, ghost))