""" تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد. یک کاربر از صفر تا آخر می‌رود: ورود → انتخاب پایه و اسم → نقشه‌ی مهارت → درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همان‌جا گیر می‌کند؛ این تست دقیقاً همان زنجیره را می‌بندد. عمداً از 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=Grade.objects.create(title='پایه دهم', icon='', color=''), title='فصل ۵', order=1) 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 from content.testing import record_answers record_answers(self.user.id, lesson=self.lesson) # پایانِ درس بی‌جواب پذیرفته نمی‌شود 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)