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>
258 lines
11 KiB
Python
258 lines
11 KiB
Python
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)
|