- 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>
193 lines
11 KiB
Python
193 lines
11 KiB
Python
"""
|
||
پایانِ درس و جزیره بیجواب و روی جزیرهی قفل پذیرفته نمیشود (۲۰۲۶-۱۰-۰۷).
|
||
|
||
تا پیش از این: کسی که قلب داشت و هیچ سوالی جواب نداده بود جزیره را تمام میکرد، حتی جزیرهی قفل را
|
||
(با 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))
|