Files
MathApp/progress/tests.py
Zareian 8fce3b9548 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>
2026-09-22 13:30:57 +03:30

258 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import timedelta
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import (
HEART_REFILL_INTERVAL,
Character,
LeaderboardEntry,
UserHearts,
UserStats,
)
class UserHeartsRegenTests(TestCase):
"""رگرسیون: قبلاً next_refill_at هیچ‌وقت واقعاً چک نمی‌شد و هارتِ ازدست‌رفته
برای همیشه صفر می‌موند."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='dev-hearts-regen', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def _get_hearts(self):
return self.client.get('/api/user/hearts', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def test_no_refill_before_interval_elapses(self):
hearts = UserHearts.objects.create(user_id=self.user.id, current_hearts=3, max_hearts=5)
hearts.lose_heart() # شبیه‌سازیِ از دست دادنِ یک هارتِ دیگه؛ تایمر رو ست می‌کنه
hearts.save()
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 2)
self.assertIsNotNone(response.data['nextRefillAt'])
def test_refill_after_interval_elapses(self):
hearts = UserHearts.objects.create(
user_id=self.user.id, current_hearts=3, max_hearts=5,
next_refill_at=timezone.now() - timedelta(seconds=1),
)
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 4)
hearts.refresh_from_db()
self.assertEqual(hearts.current_hearts, 4)
self.assertIsNotNone(hearts.next_refill_at) # هنوز به max نرسیده، تایمرِ بعدی ست شده
def test_refill_catches_up_multiple_missed_intervals(self):
UserHearts.objects.create(
user_id=self.user.id, current_hearts=1, max_hearts=5,
next_refill_at=timezone.now() - 3 * HEART_REFILL_INTERVAL,
)
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 5)
self.assertIsNone(response.data['nextRefillAt']) # به max رسیده، تایمر خاموش می‌شه
def test_full_hearts_have_no_refill_timer(self):
UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5)
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 5)
self.assertIsNone(response.data['nextRefillAt'])
class CharacterGamificationTests(TestCase):
"""کاتالوگِ کاراکترها (مثلِ ماسکات‌های Duolingo): باز/بسته‌بودن تابعی از
XP کاربره، و فقط کاراکترِ باز‌شده قابلِ انتخاب‌شدنه."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-characters-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.owl = Character.objects.create(slug='owl', name='جغد', icon='owl', unlock_xp=0, order=1)
self.fox = Character.objects.create(slug='fox', name='روباه', icon='fox', unlock_xp=100, order=2)
def _list(self):
return self.client.get('/api/characters', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def _select(self, character_id):
return self.client.post(
'/api/characters/select', {'characterId': str(character_id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_list_reports_locked_and_unlocked_by_xp(self):
UserStats.objects.create(user_id=self.user.id, xp=50)
response = self._list()
self.assertEqual(response.status_code, 200)
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['unlocked'])
self.assertFalse(by_slug['fox']['unlocked'])
def test_list_with_no_stats_yet_only_zero_xp_characters_unlocked(self):
response = self._list()
self.assertEqual(response.status_code, 200)
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['unlocked'])
self.assertFalse(by_slug['fox']['unlocked'])
def test_can_select_unlocked_character(self):
UserStats.objects.create(user_id=self.user.id, xp=10)
response = self._select(self.owl.id)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['selected'])
stats = UserStats.objects.get(user_id=self.user.id)
self.assertEqual(stats.selected_character_id, self.owl.id)
def test_cannot_select_locked_character(self):
UserStats.objects.create(user_id=self.user.id, xp=10)
response = self._select(self.fox.id)
self.assertEqual(response.status_code, 400)
stats = UserStats.objects.get(user_id=self.user.id)
self.assertIsNone(stats.selected_character_id)
def test_selection_reflected_in_list(self):
UserStats.objects.create(user_id=self.user.id, xp=10)
self._select(self.owl.id)
response = self._list()
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['selected'])
self.assertFalse(by_slug['fox']['selected'])
class RebuildLeaderboardCommandTests(TestCase):
"""
تا پیش از این هیچ‌جای کد LeaderboardEntry نمی‌ساخت و /api/leaderboard روی
هر سرورِ واقعی برای همیشه خالی بود. این تست‌ها تولیدکننده‌ی تازه را قفل
می‌کنند.
"""
def _complete(self, user_id, lesson, xp, when=None):
from content.models import UserLessonCompletion
row = UserLessonCompletion.objects.create(
user_id=user_id, lesson=lesson, correct_count=1, stars=3, xp_earned=xp,
)
if when is not None:
# auto_now_add را نمی‌شود موقعِ create گذاشت؛ بعدش update می‌کنیم.
UserLessonCompletion.objects.filter(pk=row.pk).update(created_at=when)
return row
def _lesson(self, title='درس'):
from content.models import Grade, Lesson, Node, Unit
grade = Grade.objects.create(title='پایه تست')
unit = Unit.objects.create(grade=grade, title='مبحث', order=1)
node = Node.objects.create(unit=unit, title='نود', order=1)
return Lesson.objects.create(node=node, title=title)
def test_all_time_ranks_come_from_user_stats_xp(self):
UserStats.objects.create(user_id=1, xp=50)
UserStats.objects.create(user_id=2, xp=300)
UserStats.objects.create(user_id=3, xp=120)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
rows = list(
LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('rank', 'user_id', 'xp_in_period')
)
self.assertEqual(rows, [(1, 2, 300), (2, 3, 120), (3, 1, 50)])
def test_weekly_sums_only_completions_inside_the_window(self):
lesson = self._lesson()
now = timezone.now()
self._complete(1, lesson, 30, when=now - timedelta(days=1))
self._complete(1, lesson, 20, when=now - timedelta(days=2))
# خارج از بازه‌ی ۷ روزه — نباید شمرده شود.
self._complete(1, lesson, 999, when=now - timedelta(days=30))
self._complete(2, lesson, 40, when=now - timedelta(days=3))
call_command('rebuild_leaderboard', '--period', 'weekly', verbosity=0)
rows = list(
LeaderboardEntry.objects.filter(period='weekly')
.order_by('rank').values_list('rank', 'user_id', 'xp_in_period')
)
self.assertEqual(rows, [(1, 1, 50), (2, 2, 40)])
def test_days_flag_widens_the_window(self):
lesson = self._lesson()
self._complete(1, lesson, 999, when=timezone.now() - timedelta(days=30))
call_command('rebuild_leaderboard', '--period', 'weekly', '--days', '60', verbosity=0)
self.assertEqual(
list(LeaderboardEntry.objects.filter(period='weekly')
.values_list('user_id', 'xp_in_period')),
[(1, 999)],
)
def test_rerun_is_idempotent_and_does_not_duplicate_or_gap_ranks(self):
UserStats.objects.create(user_id=1, xp=10)
UserStats.objects.create(user_id=2, xp=20)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
ranks = list(
LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('rank', flat=True)
)
self.assertEqual(ranks, [1, 2])
def test_zero_xp_users_are_excluded_unless_asked_for(self):
UserStats.objects.create(user_id=1, xp=0)
UserStats.objects.create(user_id=2, xp=5)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='all_time').count(), 1)
call_command('rebuild_leaderboard', '--period', 'all_time', '--include-zero', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='all_time').count(), 2)
def test_rebuilding_one_period_leaves_the_other_untouched(self):
LeaderboardEntry.objects.create(user_id=9, period='weekly', xp_in_period=7, rank=1)
UserStats.objects.create(user_id=1, xp=10)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='weekly').count(), 1)
def test_ties_break_deterministically_so_two_runs_agree(self):
UserStats.objects.create(user_id=7, xp=100)
UserStats.objects.create(user_id=3, xp=100)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
first = list(LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('user_id', flat=True))
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
second = list(LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('user_id', flat=True))
self.assertEqual(first, [3, 7]) # تساوی → user_id کوچک‌تر جلوتر
self.assertEqual(first, second)
def test_dry_run_writes_nothing(self):
UserStats.objects.create(user_id=1, xp=10)
call_command('rebuild_leaderboard', '--period', 'all_time', '--dry-run', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.count(), 0)