accounts.0008 drops the columns and refuses to run while guest rows exist. Docs describe the current state; tests that only proved guest behaviour are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
388 lines
19 KiB
Python
388 lines
19 KiB
Python
"""
|
||
تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد.
|
||
|
||
یک کاربر از صفر تا آخر میرود: ورود → انتخاب پایه و اسم → نقشهی مهارت →
|
||
درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همانجا
|
||
گیر میکند؛ این تست دقیقاً همان زنجیره را میبندد.
|
||
|
||
عمداً از APIهای واقعی میرود (نه از ORM)، چون شکافی که ماه پیش پیدا شد
|
||
(«/api/home بدونِ ?grade= خالی برمیگشت») فقط از همین زاویه دیده میشد.
|
||
"""
|
||
from django.core.cache import cache
|
||
from django.test import TestCase
|
||
from rest_framework.test import APIClient
|
||
|
||
from accounts.models import User
|
||
from accounts.authentication import issue_access_token
|
||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||
|
||
|
||
class BasicLearningFlowTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.client = APIClient()
|
||
|
||
self.grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
|
||
unit = Unit.objects.create(grade=self.grade, title='فصل ۴: توان و ریشه', order=4)
|
||
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
|
||
self.lesson = Lesson.objects.create(
|
||
node=node, title='محاسبه توان منفی',
|
||
intro_text='۱. معکوسِ پایه را بگیر. ۲. توان را مثبت کن.',
|
||
mascot_animation='explain',
|
||
)
|
||
question = Question.objects.create(
|
||
lesson=self.lesson, type='multiple_choice', prompt='۲ به توان منفی ۳ چند است؟',
|
||
)
|
||
self.correct = Choice.objects.create(question=question, value='۱/۸', is_correct=True)
|
||
Choice.objects.create(question=question, value='-۸', is_correct=False)
|
||
Choice.objects.create(question=question, value='۸', is_correct=False)
|
||
Choice.objects.create(question=question, value='-۱/۸', is_correct=False)
|
||
self.question = question
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def _auth(self, token):
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||
|
||
def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self):
|
||
# ۱. کاربرِ ثبتنامشده با شماره (توکنِ ورود).
|
||
session = {'token': issue_access_token(User.objects.create(phone='09000000101', name='').id)}
|
||
self._auth(session['token'])
|
||
|
||
# ۲. صفحهی «بیا باهم آشنا بشیم»: اسم + پایه.
|
||
response = self.client.patch('/api/user/profile', {'name': 'سارا'}, format='json')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.data['name'], 'سارا')
|
||
|
||
grades = self.client.get('/api/grades').data['grades']
|
||
self.assertIn('پایه نهم', [g['title'] for g in grades])
|
||
response = self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# ۳. صفحهی خانه — بدونِ پارامتر، از پایهی خودِ کاربر.
|
||
home = self.client.get('/api/home')
|
||
self.assertEqual(home.status_code, 200)
|
||
self.assertEqual(len(home.data['units']), 1)
|
||
node = home.data['units'][0]['nodes'][0]
|
||
self.assertEqual(node['status'], 'unlocked')
|
||
self.assertIn(self.lesson.id, node['lessonIds'])
|
||
|
||
# ۴. صفحهی درس.
|
||
intro = self.client.get(f'/api/lessons/{self.lesson.id}/intro')
|
||
self.assertEqual(intro.status_code, 200)
|
||
self.assertTrue(intro.data['introText'])
|
||
self.assertTrue(intro.data['mascotAnimation'])
|
||
|
||
questions = self.client.get(f'/api/lessons/{self.lesson.id}/questions').data['questions']
|
||
self.assertEqual(len(questions), 1)
|
||
self.assertEqual(len(questions[0]['options']), 4)
|
||
|
||
# ۵. پاسخِ درست.
|
||
answer = self.client.post(
|
||
f'/api/questions/{self.question.id}/answer',
|
||
{'selectedOptionId': self.correct.id}, format='json',
|
||
)
|
||
self.assertEqual(answer.status_code, 200, answer.data)
|
||
self.assertTrue(answer.data['correct'])
|
||
self.assertEqual(str(answer.data['correctOptionId']), str(self.correct.id))
|
||
self.assertGreater(answer.data['xpEarned'], 0)
|
||
self.assertEqual(answer.data['mascotMood'], 'happy')
|
||
|
||
# ۶. اتمامِ درس. correctCount/totalQuestions را کلاینت میفرستد، ولی
|
||
# سرور آن را با تعدادِ واقعیِ سوالهای درس سقف میزند.
|
||
complete = self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1}, format='json',
|
||
)
|
||
self.assertEqual(complete.status_code, 200, complete.data)
|
||
self.assertGreater(complete.data['stars'], 0)
|
||
|
||
# ۷. آمار باید پیشرفت را نشان دهد و گره کامل شده باشد.
|
||
summary = self.client.get('/api/user/summary')
|
||
self.assertGreater(summary.data['xp'], 0)
|
||
self.assertEqual(summary.data['name'], 'سارا')
|
||
|
||
home = self.client.get('/api/home')
|
||
self.assertEqual(home.data['units'][0]['nodes'][0]['status'], 'completed')
|
||
self.assertGreater(home.data['stats']['progressPercent'], 0)
|
||
|
||
def test_completing_twice_with_the_same_key_does_not_double_count(self):
|
||
from content.models import UserLessonCompletion
|
||
|
||
session = {'token': issue_access_token(User.objects.create(phone='09000000102', name='').id)}
|
||
self._auth(session['token'])
|
||
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
|
||
self.client.post(
|
||
f'/api/questions/{self.question.id}/answer',
|
||
{'selectedOptionId': self.correct.id}, format='json',
|
||
)
|
||
|
||
body = {'correctCount': 1, 'totalQuestions': 1}
|
||
first = self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete', body, format='json',
|
||
HTTP_IDEMPOTENCY_KEY='same-key',
|
||
)
|
||
second = self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete', body, format='json',
|
||
HTTP_IDEMPOTENCY_KEY='same-key',
|
||
)
|
||
self.assertEqual(first.status_code, 200)
|
||
self.assertEqual(second.status_code, 200)
|
||
|
||
# قطعیِ شبکه + دکمهی دوباره نباید دوبار امتیاز بدهد.
|
||
self.assertEqual(
|
||
UserLessonCompletion.objects.filter(lesson=self.lesson).count(), 1,
|
||
)
|
||
summary = self.client.get('/api/user/summary')
|
||
self.assertEqual(summary.data['xp'], first.data['xpEarned'])
|
||
|
||
def test_answer_xp_lands_in_the_total_only_after_the_lesson_is_completed(self):
|
||
"""
|
||
قراردادی که فرانت باید بداند: `xpEarned` در پاسخِ هر سوال، ارزشِ همان
|
||
سوال است و بلافاصله در مجموعِ کاربر نمینشیند؛ مجموع موقعِ اتمامِ درس
|
||
بهروز میشود. بدونِ دانستنِ این، توسعهدهندهی فرانت دنبالِ باگی
|
||
میگردد که وجود ندارد.
|
||
"""
|
||
session = {'token': issue_access_token(User.objects.create(phone='09000000104', name='').id)}
|
||
self._auth(session['token'])
|
||
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
|
||
|
||
answer = self.client.post(
|
||
f'/api/questions/{self.question.id}/answer',
|
||
{'selectedOptionId': self.correct.id}, format='json',
|
||
)
|
||
self.assertGreater(answer.data['xpEarned'], 0)
|
||
self.assertEqual(self.client.get('/api/user/summary').data['xp'], 0)
|
||
|
||
self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1}, format='json',
|
||
)
|
||
self.assertGreater(self.client.get('/api/user/summary').data['xp'], 0)
|
||
|
||
def test_the_map_is_empty_but_healthy_before_a_grade_is_chosen(self):
|
||
session = {'token': issue_access_token(User.objects.create(phone='09000000103', name='').id)}
|
||
self._auth(session['token'])
|
||
|
||
home = self.client.get('/api/home')
|
||
self.assertEqual(home.status_code, 200)
|
||
self.assertEqual(home.data['units'], [])
|
||
|
||
|
||
class LessonWithoutQuestionsTests(TestCase):
|
||
"""
|
||
درسی که هنوز سوال ندارد نباید بنبستِ خاموش باشد.
|
||
|
||
الان همهی درسهای واردشده از کتاب در همین حالتاند (ساختِ سوال به اعتبارِ
|
||
API نیاز دارد)، پس فرانت باید بتواند قبل از باز کردنِ کویز بفهمد.
|
||
"""
|
||
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.client = APIClient()
|
||
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
|
||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
|
||
self.empty = Lesson.objects.create(node=node, title='بدونِ سوال', intro_text='متنِ درس')
|
||
self.filled = Lesson.objects.create(node=node, title='با سوال', intro_text='متنِ درس')
|
||
question = Question.objects.create(
|
||
lesson=self.filled, type='multiple_choice', prompt='؟',
|
||
)
|
||
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||
|
||
user = User.objects.create(phone='09000000008', grade_id=grade.id)
|
||
from accounts.authentication import issue_access_token
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def test_intro_reports_the_question_count(self):
|
||
self.assertEqual(
|
||
self.client.get(f'/api/lessons/{self.empty.id}/intro').data['questionCount'], 0,
|
||
)
|
||
self.assertEqual(
|
||
self.client.get(f'/api/lessons/{self.filled.id}/intro').data['questionCount'], 1,
|
||
)
|
||
|
||
def test_intro_still_serves_the_teaching_text(self):
|
||
response = self.client.get(f'/api/lessons/{self.empty.id}/intro')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.data['introText'], 'متنِ درس')
|
||
|
||
|
||
class NodeQuizTests(TestCase):
|
||
"""
|
||
آزمونِ مبحث: واحدِ بازی در اپ «مبحث» است نه «درس».
|
||
|
||
درس در این پروژه ریزترین دانه است (یک نوعِ مسئله از کتاب) و معمولاً یکی-دو
|
||
سوال دارد؛ اپ برای هر جزیره چهار سوال میخواهد. بدونِ این اندپوینت، فرانت
|
||
باید درسبهدرس میگشت و خودش سرِ هم میکرد.
|
||
"""
|
||
|
||
def setUp(self):
|
||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||
unit = Unit.objects.create(grade=grade, title='فصل ۴', order=1)
|
||
self.node = Node.objects.create(unit=unit, title='توان', order=1)
|
||
for i in range(6):
|
||
lesson = Lesson.objects.create(node=self.node, title=f'درس {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)
|
||
|
||
def test_returns_four_questions_by_default(self):
|
||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
|
||
|
||
self.assertEqual(body['questionCount'], 4)
|
||
self.assertEqual(len(body['questions']), 4)
|
||
self.assertEqual(body['nodeTitle'], 'توان')
|
||
|
||
def test_each_question_says_which_lesson_it_came_from(self):
|
||
"""برای اینکه بعدِ جوابِ غلط بشود گفت کدام مهارت را مرور کند."""
|
||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
|
||
|
||
for question in body['questions']:
|
||
self.assertTrue(question['lessonId'])
|
||
self.assertTrue(question['lessonTitle'])
|
||
|
||
def test_it_spreads_across_lessons_instead_of_drilling_one(self):
|
||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
|
||
|
||
lesson_ids = [q['lessonId'] for q in body['questions']]
|
||
self.assertEqual(len(set(lesson_ids)), len(lesson_ids))
|
||
|
||
def test_the_same_seed_gives_the_same_quiz(self):
|
||
"""رفرشِ صفحه نباید آزمونِ تازهای بدهد و پیشرفت را بیمعنا کند."""
|
||
first = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
|
||
again = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
|
||
|
||
self.assertEqual([q['id'] for q in first['questions']],
|
||
[q['id'] for q in again['questions']])
|
||
|
||
def test_a_different_seed_gives_a_different_order(self):
|
||
a = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=one').json()
|
||
b = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=two').json()
|
||
|
||
self.assertNotEqual([q['id'] for q in a['questions']],
|
||
[q['id'] for q in b['questions']])
|
||
|
||
def test_a_thin_node_reports_what_it_actually_has(self):
|
||
"""
|
||
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
|
||
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
|
||
"""
|
||
thin_unit = Unit.objects.create(
|
||
grade=self.node.unit.grade, title='فصل ۵', order=2)
|
||
thin = Node.objects.create(unit=thin_unit, title='کمسوال', order=1)
|
||
lesson = Lesson.objects.create(node=thin, title='تنها درس')
|
||
question = Question.objects.create(
|
||
lesson=lesson, type='multiple_choice', prompt='تنها سوال')
|
||
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||
|
||
body = self.client.get(f'/api/nodes/{thin.pk}/quiz').json()
|
||
|
||
self.assertEqual(body['questionCount'], 1)
|
||
self.assertEqual(body['requested'], 4)
|
||
|
||
def test_size_is_capped_so_one_request_cannot_pull_everything(self):
|
||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?size=999').json()
|
||
|
||
self.assertLessEqual(body['questionCount'], 20)
|
||
|
||
def test_unknown_node_is_a_clean_404(self):
|
||
self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404)
|
||
|
||
|
||
class LessonCompletionNotificationTests(TestCase):
|
||
"""
|
||
شکافی که بسته شد: 'lesson_completed' در Notification.TYPE_CHOICES تعریف
|
||
شده بود ولی هیچجای کد آن را نمیساخت، یعنی کلِ مسیرِ کوییز هیچ اعلانی
|
||
تولید نمیکرد و /api/notifications برای کاربرِ واقعی همیشه خالی میماند.
|
||
"""
|
||
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.client = APIClient()
|
||
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
|
||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||
node = Node.objects.create(unit=unit, title='نود', order=1)
|
||
self.lesson = Lesson.objects.create(node=node, title='توانِ منفی')
|
||
question = Question.objects.create(
|
||
lesson=self.lesson, type='multiple_choice', prompt='؟',
|
||
)
|
||
self.correct = Choice.objects.create(question=question, value='الف', is_correct=True)
|
||
self.question = question
|
||
|
||
self.user = User.objects.create(phone='09000000009', grade_id=grade.id)
|
||
from accounts.authentication import issue_access_token
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def _complete(self, **extra):
|
||
return self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1},
|
||
format='json', **extra,
|
||
)
|
||
|
||
def test_finishing_a_lesson_creates_a_notification_the_user_can_read(self):
|
||
from notifications.models import Notification
|
||
|
||
self.assertEqual(Notification.objects.count(), 0)
|
||
self.assertEqual(self._complete().status_code, 200)
|
||
|
||
notification = Notification.objects.get()
|
||
self.assertEqual(notification.user_id, self.user.id)
|
||
self.assertEqual(notification.type, 'lesson_completed')
|
||
self.assertEqual(notification.related_id, self.lesson.id)
|
||
self.assertIn('توانِ منفی', notification.body)
|
||
self.assertFalse(notification.is_read)
|
||
|
||
# و از همان راهی که فرانت میبیند هم برگردد.
|
||
listing = self.client.get('/api/notifications')
|
||
self.assertEqual(listing.data['unreadCount'], 1)
|
||
self.assertEqual(listing.data['items'][0]['type'], 'lesson_completed')
|
||
|
||
def test_repeating_the_same_lesson_does_not_pile_up_notifications(self):
|
||
# تکرارِ درس عمداً xp/coins نمیدهد؛ اعلان هم نباید بدهد، وگرنه کاربری
|
||
# که یک درس را ده بار تمرین کند ده اعلانِ یکسان میگیرد.
|
||
from notifications.models import Notification
|
||
|
||
self._complete()
|
||
self._complete()
|
||
self._complete()
|
||
|
||
self.assertEqual(Notification.objects.count(), 1)
|
||
|
||
def test_idempotent_replay_does_not_create_a_second_notification(self):
|
||
from notifications.models import Notification
|
||
|
||
self._complete(HTTP_IDEMPOTENCY_KEY='same-key')
|
||
self._complete(HTTP_IDEMPOTENCY_KEY='same-key')
|
||
|
||
self.assertEqual(Notification.objects.count(), 1)
|
||
|
||
def test_a_failing_notification_rolls_the_whole_completion_back(self):
|
||
# اعلان عمداً داخلِ همان transactionِ تکمیل است. این تست همان ادعا را
|
||
# میسنجد: اگر نوشتنِ اعلان بشکند، نباید تکمیلی ثبت شده باشد که
|
||
# xp داده ولی اعلانش گم شده — یعنی نه «نیمهتمامِ خاموش».
|
||
from unittest.mock import patch
|
||
|
||
from content.models import UserLessonCompletion
|
||
from notifications.models import Notification
|
||
from progress.models import UserStats
|
||
|
||
with patch('notifications.services.Notification.objects.create',
|
||
side_effect=RuntimeError('boom')):
|
||
response = self._complete()
|
||
|
||
self.assertEqual(response.status_code, 500)
|
||
self.assertEqual(Notification.objects.count(), 0)
|
||
self.assertEqual(UserLessonCompletion.objects.count(), 0)
|
||
stats = UserStats.objects.filter(user_id=self.user.id).first()
|
||
self.assertEqual(stats.xp if stats else 0, 0)
|