give the leaderboard and notifications a real producer
Three gaps found while seeding test data for the frontend, all of the same kind: a contract that looks complete from the outside and is empty underneath. Nothing in the codebase ever wrote a LeaderboardEntry. Not a signal, not a service — only the admin form and seed_test_data. So /api/leaderboard returned `entries: []` forever on any real server, and any row put there was a frozen number that never tracked activity. rebuild_leaderboard now derives both periods from real XP: all_time from UserStats.xp, weekly from a sum over content.UserLessonCompletion in a window. It deliberately does not count UserAnswer.xp_earned — that field is written but never added to UserStats, so summing both would report roughly double. deploy.sh runs it every release; a cron is the real answer once traffic is real, and the script says so. 'lesson_completed' was declared in Notification.TYPE_CHOICES but nothing called notify() with it, so finishing a lesson produced no notification at all. LessonCompleteView now emits one, on first completion only (the same condition that grants xp/coins), inside the completion transaction so a lost notification cannot leave a silent half-done state. The notification list serializer returned only id/type/title/isRead. body and created_at were on the model from the start but never exposed, so the frontend had neither the text of a notification nor its time. Both added; the change is purely additive, so the existing client keeps working. 472 tests green, up from 458. makemigrations --check, check and spectacular are all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -301,3 +301,95 @@ class NodeQuizTests(TestCase):
|
||||
|
||||
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(device_id='notif-on-complete', is_guest=True, 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)
|
||||
|
||||
Reference in New Issue
Block a user