- 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>
99 lines
5.1 KiB
Python
99 lines
5.1 KiB
Python
"""
|
||
جزیرهها روی نقشه باید با بازی کردنِ درسها واقعاً تمام شوند (۲۰۲۶-۰۹-۲۵).
|
||
|
||
دو چیز با هم جلوی پیشرفت را گرفته بود:
|
||
۱. جزیره فقط وقتی تمام میشد که *همهی* درسهایش تمام شده باشند، ولی ۵۸۵ درس
|
||
هنوز سوال ندارند و اپ برایشان دکمهی شروع ندارد — ۱۸۱ جزیره از ۲۶۹.
|
||
۲. فرانت با کلیک روی جزیره همیشه lessonIds[0] را باز میکرد، یعنی درسِ اول
|
||
را بارها و بارها.
|
||
"""
|
||
from django.core.cache import cache
|
||
from django.test import TestCase
|
||
from rest_framework.test import APIClient
|
||
|
||
from accounts.authentication import issue_access_token
|
||
from accounts.models import User
|
||
|
||
from .models import Choice, Grade, Lesson, Node, Question, Unit
|
||
from .testing import record_answers
|
||
|
||
|
||
class IslandProgressTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
|
||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||
self.first = Node.objects.create(unit=unit, title='جزیرهی اول', order=1)
|
||
self.second = Node.objects.create(unit=unit, title='جزیرهی دوم', order=2)
|
||
self.a = self._lesson(self.first, 'الف', with_question=True)
|
||
self.empty = self._lesson(self.first, 'بیسوال', with_question=False)
|
||
self.b = self._lesson(self.first, 'ب', with_question=True)
|
||
self._lesson(self.second, 'ج', with_question=True)
|
||
|
||
self.user = User.objects.create(phone='09120000950', grade_id=grade.id)
|
||
self.client = APIClient()
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def _lesson(self, node, title, *, with_question):
|
||
lesson = Lesson.objects.create(node=node, title=title, intro_text='x', mascot_animation='x')
|
||
if with_question:
|
||
self._question(lesson)
|
||
return lesson
|
||
|
||
def _question(self, lesson):
|
||
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
|
||
Choice.objects.create(question=question, value='۱', is_correct=True)
|
||
Choice.objects.create(question=question, value='۲', is_correct=False)
|
||
|
||
def _nodes(self):
|
||
units = self.client.get('/api/home').json()['units']
|
||
return {n['title']: n for n in units[0]['nodes']}
|
||
|
||
def _complete(self, lesson):
|
||
record_answers(self.user.id, lesson=lesson) # مثلِ اپ: اول جواب، بعد پایان
|
||
response = self.client.post(f'/api/lessons/{lesson.id}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1}, format='json')
|
||
self.assertEqual(response.status_code, 200, response.content)
|
||
|
||
def test_tapping_the_island_opens_the_next_unfinished_lesson(self):
|
||
nodes = self._nodes()
|
||
self.assertEqual(nodes['جزیرهی اول']['nextLessonId'], self.a.id)
|
||
self.assertEqual(nodes['جزیرهی اول']['lessonIds'][0], self.a.id)
|
||
|
||
self._complete(self.a)
|
||
first = self._nodes()['جزیرهی اول']
|
||
# درسِ بیسوال رد میشود؛ lessonIds[0] همان است که فرانت باز میکند.
|
||
self.assertEqual(first['nextLessonId'], self.b.id)
|
||
self.assertEqual(first['lessonIds'][0], self.b.id)
|
||
self.assertCountEqual(first['lessonIds'], [self.a.id, self.empty.id, self.b.id])
|
||
|
||
def test_a_lesson_without_questions_does_not_hold_the_island(self):
|
||
self._complete(self.a)
|
||
self._complete(self.b)
|
||
nodes = self._nodes()
|
||
self.assertEqual(nodes['جزیرهی اول']['status'], 'completed')
|
||
self.assertEqual(nodes['جزیرهی دوم']['status'], 'unlocked')
|
||
# همهی درسها تمام شدهاند: برای مرور، درسِ اول.
|
||
self.assertEqual(nodes['جزیرهی اول']['nextLessonId'], self.a.id)
|
||
|
||
def test_progress_counts_only_lessons_that_can_be_played(self):
|
||
self._complete(self.a)
|
||
percent = self.client.get('/api/home').json()['stats']['progressPercent']
|
||
self.assertAlmostEqual(percent, 33.33, places=2) # ۱ از ۳ درسِ سوالدار
|
||
|
||
def test_new_content_is_offered_but_progress_never_goes_backwards(self):
|
||
self._complete(self.a)
|
||
self._complete(self.b)
|
||
self.assertEqual(self._nodes()['جزیرهی اول']['status'], 'completed')
|
||
|
||
self._question(self.empty) # حالا این درس هم بازیشدنی است
|
||
nodes = self._nodes()
|
||
# نقشهی کششده باطل شد: درسِ تازه پیشنهاد میشود…
|
||
self.assertEqual(nodes['جزیرهی اول']['nextLessonId'], self.empty.id)
|
||
# …ولی جزیرهی تمامشده باز و جزیرهی بعدی قفل نمیشود.
|
||
self.assertEqual(nodes['جزیرهی اول']['status'], 'completed')
|
||
self.assertEqual(nodes['جزیرهی دوم']['status'], 'unlocked')
|