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:
2026-09-22 13:30:57 +03:30
parent 66364e0e92
commit 8fce3b9548
9 changed files with 412 additions and 2 deletions

View File

@@ -301,3 +301,95 @@ class NodeQuizTests(TestCase):
def test_unknown_node_is_a_clean_404(self): def test_unknown_node_is_a_clean_404(self):
self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404) 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)

View File

@@ -519,6 +519,25 @@ class LessonCompleteView(APIView):
).values('lesson_id').distinct().count() ).values('lesson_id').distinct().count()
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
stats.save(update_fields=['xp', 'coins', 'progress_percent']) stats.save(update_fields=['xp', 'coins', 'progress_percent'])
# اعلانِ درسِ تکمیل‌شده. تا پیش از این، نوعِ 'lesson_completed'
# در Notification.TYPE_CHOICES تعریف شده بود ولی هیچ‌جای کد
# آن را نمی‌ساخت — یعنی کلِ مسیرِ کوییز هیچ اعلانی تولید
# نمی‌کرد. فقط بارِ اول (همان شرطی که xp/coins می‌دهد)؛
# تکرارِ درس اعلانِ تازه نمی‌سازد.
#
# عمداً داخلِ همین transaction است: اگر نوشتنِ اعلان شکست
# بخورد، تکمیلِ درس هم برنمی‌گردد — هم‌الگو با
# campaigns.services که اعلان و وضعیت را یک واحد می‌داند.
if is_first_completion:
from notifications.services import notify
notify(
user_id=user_id,
type='lesson_completed',
title='درس را کامل کردی',
body=f'«{lesson.title}» را با {stars} ستاره تمام کردی و {xp_earned} امتیاز گرفتی.',
related_id=lesson.id,
)
except IntegrityError: except IntegrityError:
# قید یکتاییِ سطح DB جلوی درخواست‌های هم‌زمان با همان کلید را می‌گیرد؛ # قید یکتاییِ سطح DB جلوی درخواست‌های هم‌زمان با همان کلید را می‌گیرد؛
# نتیجه‌ی completion رقیب idempotent برگردانده می‌شود. # نتیجه‌ی completion رقیب idempotent برگردانده می‌شود.

View File

@@ -175,6 +175,19 @@ step "محتوای پایه"
python manage.py seed_content 2>&1 | sed 's/^/ /' \ python manage.py seed_content 2>&1 | sed 's/^/ /' \
|| warn "seed_content شکست خورد؛ کد به‌روز شد ولی محتوا دست‌نخورده ماند." || warn "seed_content شکست خورد؛ کد به‌روز شد ولی محتوا دست‌نخورده ماند."
# جدولِ لیدربورد کاملاً مشتق‌شده است و تا پیش از این هیچ تولیدکننده‌ای نداشت —
# یعنی /api/leaderboard روی سرورِ واقعی همیشه `entries: []` می‌داد. این مرحله
# آن را در هر ریلیز از روی XPی واقعی از نو می‌سازد.
#
# ⚠️ این «تازه‌سازیِ هر ریلیز» است، نه یک زمان‌بندِ واقعی: بینِ دو دیپلوی،
# رتبه‌ها ثابت می‌مانند. وقتی ترافیک واقعی شد، همین دستور باید در یک cron
# (مثلاً هر ساعت) بنشیند.
#
# ایدمپوتنت است و شکستش دیپلوی را نمی‌خواباند.
step "بازسازیِ لیدربورد"
python manage.py rebuild_leaderboard 2>&1 | sed 's/^/ /' \
|| warn "rebuild_leaderboard شکست خورد؛ رتبه‌ها از اجرای قبلی مانده‌اند."
# ─── ۵. فایل‌های استاتیک ─────────────────────────────────────────────────── # ─── ۵. فایل‌های استاتیک ───────────────────────────────────────────────────
# بی‌قید و شرط اجرا می‌شود، حتی وقتی DEBUG روشن است. # بی‌قید و شرط اجرا می‌شود، حتی وقتی DEBUG روشن است.
# #

View File

@@ -6,10 +6,15 @@ from .models import Notification
class NotificationItemSerializer(serializers.ModelSerializer): class NotificationItemSerializer(serializers.ModelSerializer):
id = serializers.IntegerField() id = serializers.IntegerField()
isRead = serializers.BooleanField(source='is_read') isRead = serializers.BooleanField(source='is_read')
# body و createdAt از ابتدا روی مدل بودند ولی در پاسخ نبودند، یعنی فرانت
# نه متنِ اعلان را داشت نه زمانش — و هر لیستِ اعلانی به هر دو نیاز دارد.
# هر دو فقط «اضافه» می‌شوند؛ هیچ فیلدی حذف یا تغییرِ نام نداده، پس
# کلاینتِ فعلی نمی‌شکند.
createdAt = serializers.DateTimeField(source='created_at', read_only=True)
class Meta: class Meta:
model = Notification model = Notification
fields = ['id', 'type', 'title', 'isRead'] fields = ['id', 'type', 'title', 'body', 'isRead', 'createdAt']
class NotificationListResponseSerializer(serializers.Serializer): class NotificationListResponseSerializer(serializers.Serializer):

View File

@@ -108,3 +108,41 @@ class NotificationListCapTests(TestCase):
# جدیدترین اول (Meta.ordering = -created_at؛ اینجا هم‌زمان → -id غالب) # جدیدترین اول (Meta.ordering = -created_at؛ اینجا هم‌زمان → -id غالب)
ids = [it['id'] if isinstance(it, dict) else it.id for it in r.data['items']] ids = [it['id'] if isinstance(it, dict) else it.id for it in r.data['items']]
self.assertEqual(ids, sorted(ids, reverse=True)) self.assertEqual(ids, sorted(ids, reverse=True))
class NotificationPayloadShapeTests(TestCase):
"""
body و createdAt پیش‌تر روی مدل بودند ولی در پاسخ نبودند، یعنی فرانت نه
متنِ اعلان را می‌دید نه زمانش. این تست همان شکاف را قفل می‌کند تا دوباره
از قرارداد نیفتد.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='dev-notif-shape', is_guest=True, name='n')
self.token = issue_access_token(self.user.id)
def test_list_item_carries_body_and_created_at(self):
Notification.objects.create(
user_id=self.user.id, type='order_paid',
title='پرداخت موفق', body='اشتراک ماهانه فعال شد.', is_read=False,
)
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.status_code, 200)
item = r.data['items'][0]
self.assertEqual(item['body'], 'اشتراک ماهانه فعال شد.')
self.assertIsNotNone(item['createdAt'])
# فیلدهای قبلی هنوز سرِ جایشان‌اند (تغییر فقط افزایشی بوده).
self.assertEqual(
set(item.keys()), {'id', 'type', 'title', 'body', 'isRead', 'createdAt'},
)
def test_empty_body_is_returned_as_empty_string_not_omitted(self):
# body روی مدل blank=True است؛ نبودِ متن نباید فیلد را از پاسخ حذف کند،
# وگرنه کلاینت باید برای دو شکلِ متفاوتِ پاسخ کد بنویسد.
Notification.objects.create(user_id=self.user.id, type='xp_earned', title='t')
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.data['items'][0]['body'], '')

View File

View File

View File

@@ -0,0 +1,117 @@
"""
ساختِ دوباره‌ی LeaderboardEntry از روی فعالیتِ واقعیِ کاربرها.
چرا این دستور لازم شد: تا پیش از این **هیچ‌جای کد** یک LeaderboardEntry
نمی‌ساخت — نه سیگنالی، نه سرویسی. تنها راهِ پرشدنِ جدول، فرمِ جنگو-ادمین یا
seed_test_data بود، یعنی `GET /api/leaderboard` روی هر سرورِ واقعی برای همیشه
`entries: []` می‌داد. این دستور آن شکاف را می‌بندد.
منبعِ اعداد (و دلیلش):
- `all_time` از `UserStats.xp` خوانده می‌شود، چون همان عددی است که خودِ اپ
در طولِ عمرِ کاربر بالا می‌برد و در `/api/user/summary` هم نشان داده می‌شود.
- `weekly` از جمعِ `content.UserLessonCompletion.xp_earned` در بازه‌ی اخیر
ساخته می‌شود.
⚠️ چرا `UserAnswer.xp_earned` شمرده **نمی‌شود**: با اینکه آن فیلد پر می‌شود،
`QuestionAnswerView` هیچ‌وقت آن را به `UserStats.xp` اضافه نمی‌کند — تنها
جایی که `UserStats.xp` زیاد می‌شود `LessonCompleteView` است و مقدارش
`correct_count * 10` یعنی همان `UserLessonCompletion.xp_earned`. اگر هر دو را
جمع بزنیم، XPی هفتگی تقریباً دو برابرِ all_time در می‌آید. این را پیش از
تغییرِ منبعِ اعداد دوباره بررسی کن.
python manage.py rebuild_leaderboard # هر دو بازه
python manage.py rebuild_leaderboard --period weekly
python manage.py rebuild_leaderboard --days 30 # بازه‌ی weekly را عوض می‌کند
python manage.py rebuild_leaderboard --dry-run # فقط گزارش
idempotent است: ردیف‌های همان بازه اول پاک و دوباره ساخته می‌شوند، پس اجرای
مکرر نه ردیفِ تکراری می‌سازد نه رتبه‌ی پراکنده. بازه‌ی دیگر دست نمی‌خورد.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
PERIODS = ('weekly', 'all_time')
# سقفِ ردیف‌هایی که نگه می‌داریم. LeaderboardView فقط ۵۰ تای اول را می‌خواند؛
# ۲۰۰ فضای کافی برای «رتبه‌ی من» در آینده می‌دهد بدون اینکه جدول با ده‌ها هزار
# ردیفِ هیچ‌وقت‌خوانده‌نشده رشد کند.
DEFAULT_LIMIT = 200
class Command(BaseCommand):
help = 'ساختِ دوباره‌ی جدولِ لیدربورد از روی XPی واقعیِ کاربرها (idempotent).'
def add_arguments(self, parser):
parser.add_argument('--period', choices=PERIODS, default=None,
help='فقط همین بازه. پیش‌فرض: هر دو.')
parser.add_argument('--days', type=int, default=7,
help='طولِ بازه‌ی weekly به روز (پیش‌فرض ۷).')
parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT,
help=f'چند نفرِ اول نگه داشته شود (پیش‌فرض {DEFAULT_LIMIT}).')
parser.add_argument('--include-zero', action='store_true',
help='کاربرهای با XP صفر هم بیایند (پیش‌فرض: نه).')
parser.add_argument('--dry-run', action='store_true',
help='چیزی نوشته نمی‌شود، فقط نتیجه چاپ می‌شود.')
def handle(self, *args, **opts):
periods = [opts['period']] if opts['period'] else list(PERIODS)
for period in periods:
self._rebuild(period, opts)
# -- جمع‌آوریِ امتیازها ---------------------------------------------------
def _collect(self, period, days):
if period == 'all_time':
from progress.models import UserStats
# values_list مادی می‌شود (list)، نه iterator — طبق درسِ
# ExecuteCampaignRealConcurrencyTests: کرسرِ بازِ خواندن روی SQLite
# در حلقه‌ای که می‌نویسد، جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند.
return list(UserStats.objects.values_list('user_id', 'xp'))
from content.models import UserLessonCompletion
since = timezone.now() - timezone.timedelta(days=days)
rows = (
UserLessonCompletion.objects
.filter(created_at__gte=since)
.values('user_id')
.annotate(total=Sum('xp_earned'))
)
return [(r['user_id'], r['total'] or 0) for r in rows]
# -- نوشتن ---------------------------------------------------------------
def _rebuild(self, period, opts):
from progress.models import LeaderboardEntry
days = opts['days']
scores = self._collect(period, days)
if not opts['include_zero']:
scores = [(uid, xp) for uid, xp in scores if xp > 0]
# ترتیبِ قطعی: امتیازِ بیشتر جلوتر، و در تساوی user_id کوچک‌تر جلوتر —
# وگرنه دو اجرای پشت‌سرهم روی داده‌ی یکسان رتبه‌های متفاوت می‌دهند.
scores.sort(key=lambda p: (-p[1], p[0]))
scores = scores[:opts['limit']]
label = period if period == 'all_time' else f'{period} ({days} روزِ اخیر)'
if opts['dry_run']:
self.stdout.write(self.style.WARNING(f'[dry-run] {label}: {len(scores)} ردیف'))
for rank, (uid, xp) in enumerate(scores[:10], 1):
self.stdout.write(f' {rank:>3}. user={uid} xp={xp}')
if len(scores) > 10:
self.stdout.write(f' … و {len(scores) - 10} ردیفِ دیگر')
return
with transaction.atomic():
# فقط همین بازه پاک می‌شود؛ بازه‌ی دیگر دست نمی‌خورد. این جدول
# کاملاً مشتق‌شده است — هیچ داده‌ای که جای دیگری نباشد اینجا نیست.
LeaderboardEntry.objects.filter(period=period).delete()
LeaderboardEntry.objects.bulk_create([
LeaderboardEntry(user_id=uid, period=period, xp_in_period=xp, rank=rank)
for rank, (uid, xp) in enumerate(scores, 1)
])
self.stdout.write(self.style.SUCCESS(f'{label}: {len(scores)} ردیف ساخته شد.'))

View File

@@ -1,5 +1,6 @@
from datetime import timedelta from datetime import timedelta
from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -7,7 +8,13 @@ from rest_framework.test import APIClient
from accounts.authentication import issue_access_token from accounts.authentication import issue_access_token
from accounts.models import User from accounts.models import User
from .models import HEART_REFILL_INTERVAL, Character, UserHearts, UserStats from .models import (
HEART_REFILL_INTERVAL,
Character,
LeaderboardEntry,
UserHearts,
UserStats,
)
class UserHeartsRegenTests(TestCase): class UserHeartsRegenTests(TestCase):
@@ -129,3 +136,122 @@ class CharacterGamificationTests(TestCase):
by_slug = {c['slug']: c for c in response.data['characters']} by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['selected']) self.assertTrue(by_slug['owl']['selected'])
self.assertFalse(by_slug['fox']['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)