Files
MathApp/progress/tests.py
Zareian ab5e53730a Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards
- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
  stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
  lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
  overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
  400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:05:27 +03:30

276 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
class FourCompanionsCatalogTests(TestCase):
"""migrationهای 0004 و 0005: سه‌یار و تدی همان چهار slugی‌اند که فرانت می‌شناسد."""
def test_catalog_has_the_four_companions_in_order(self):
rows = list(
Character.objects.filter(slug__in=['sigma', 'delta', 'pi', 'tedi'])
.order_by('order').values_list('slug', 'unlock_xp')
)
self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 500)])
class LeaderboardPeriodValidationTests(TestCase):
def test_unknown_period_is_a_400(self):
from rest_framework.test import APIClient
self.assertEqual(APIClient().get('/api/leaderboard?period=xyz').status_code, 400)
self.assertEqual(APIClient().get('/api/leaderboard?period=all_time').status_code, 200)