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'], '') class MarkAllReadTests(TestCase): """دکمه‌ی «همه خوانده شد» با یک درخواست، نه یکی برای هر اعلان.""" def setUp(self): from django.core.cache import cache cache.clear() from accounts.authentication import issue_access_token from accounts.models import User self.me = User.objects.create(phone='09120000021') self.other = User.objects.create(phone='09120000022') self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.me.id)}'} for user in (self.me, self.me, self.other): Notification.objects.create(user_id=user.id, type='xp_earned', title='x') def test_marks_only_my_unread_notifications(self): response = self.client.patch('/api/notifications/read-all', **self.auth) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {'success': True, 'updated': 2}) self.assertFalse(Notification.objects.filter(user_id=self.me.id, is_read=False).exists()) self.assertTrue(Notification.objects.filter(user_id=self.other.id, is_read=False).exists()) def test_requires_sign_in(self): self.assertEqual(self.client.patch('/api/notifications/read-all').status_code, 401)