Files
MathApp/notifications/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

149 lines
6.6 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 unittest.mock import patch
from django.test import TestCase
from accounts.services import SMSDeliveryError
from .models import Notification
from .services import notify
class NotifyInAppChannelTests(TestCase):
"""
channel='in_app' (پیش‌فرض) باید دقیقاً همون رفتارِ قبلیِ notify() رو
داشته باشه: صرفاً یک Notification.objects.create ساده، بدونِ
فراخوانیِ send_sms.
"""
@patch('notifications.services.send_sms')
def test_default_channel_creates_notification_and_does_not_send_sms(self, mock_send_sms):
result = notify(user_id=1, type='order_paid', title='t', body='b', related_id=5)
self.assertIsInstance(result, Notification)
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.get()
self.assertEqual(notification.user_id, 1)
self.assertEqual(notification.type, 'order_paid')
self.assertEqual(notification.title, 't')
self.assertEqual(notification.body, 'b')
self.assertEqual(notification.related_id, 5)
mock_send_sms.assert_not_called()
@patch('notifications.services.send_sms')
def test_explicit_in_app_channel_same_as_default(self, mock_send_sms):
result = notify(user_id=2, type='xp_earned', title='t', channel='in_app')
self.assertIsInstance(result, Notification)
self.assertEqual(Notification.objects.count(), 1)
mock_send_sms.assert_not_called()
class NotifySmsChannelTests(TestCase):
"""
channel='sms' نباید هیچ Notificationی بسازه؛ فقط باید
accounts.services.send_sms رو صدا بزنه (بدونِ تکرارِ منطقِ provider).
"""
@patch('notifications.services.send_sms')
def test_sms_channel_calls_send_sms_with_body_and_creates_no_notification(self, mock_send_sms):
result = notify(
user_id=3, type='order_paid', title='t', body='پیام پیامکی',
channel='sms', phone='0910000000',
)
mock_send_sms.assert_called_once_with(phone='0910000000', message='پیام پیامکی')
self.assertIsNone(result)
self.assertEqual(Notification.objects.count(), 0)
@patch('notifications.services.send_sms')
def test_sms_channel_falls_back_to_title_when_body_empty(self, mock_send_sms):
notify(user_id=3, type='order_paid', title='fallback-title', channel='sms', phone='0910000000')
mock_send_sms.assert_called_once_with(phone='0910000000', message='fallback-title')
def test_sms_channel_without_phone_raises_value_error(self):
with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='sms')
self.assertEqual(Notification.objects.count(), 0)
@patch('notifications.services.send_sms')
def test_sms_delivery_error_propagates_and_creates_no_notification(self, mock_send_sms):
mock_send_sms.side_effect = SMSDeliveryError('boom')
with self.assertRaises(SMSDeliveryError):
notify(user_id=3, type='order_paid', title='t', channel='sms', phone='0910000000')
self.assertEqual(Notification.objects.count(), 0)
class NotifyInvalidChannelTests(TestCase):
def test_unsupported_channel_raises_value_error_and_creates_nothing(self):
with self.assertRaises(ValueError):
notify(user_id=3, type='order_paid', title='t', channel='email')
self.assertEqual(Notification.objects.count(), 0)
from django.core.cache import cache
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
class NotificationListCapTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='dev-notif-cap', is_guest=True, name='n')
self.token = issue_access_token(self.user.id)
def test_returns_at_most_100_newest_with_correct_unread_count(self):
Notification.objects.bulk_create([
Notification(user_id=self.user.id, type='xp_earned', title=f't{i}', is_read=(i % 2 == 0))
for i in range(150)
])
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.status_code, 200)
self.assertEqual(len(r.data['items']), 100)
self.assertEqual(r.data['unreadCount'], 75) # از کلِ ۱۵۰، نه از ۱۰۰ برگشتی
# جدیدترین اول (Meta.ordering = -created_at؛ اینجا هم‌زمان → -id غالب)
ids = [it['id'] if isinstance(it, dict) else it.id for it in r.data['items']]
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'], '')