Files
MathApp/campaigns/tests.py

496 lines
23 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.
import threading
from unittest.mock import patch
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.db import IntegrityError, OperationalError, connection, transaction
from django.test import TestCase, TransactionTestCase
from django.urls import reverse
from accounts.models import User
from accounts.services import SMSDeliveryError
from notifications.models import Notification
from .admin import CampaignDeliveryAdmin, MessageCampaignAdmin
from .models import CampaignDelivery, MessageCampaign
from .services import execute_campaign
class MessageCampaignChannelValidationTests(TestCase):
"""
اطمینان از این‌که مقدارِ نامعتبر برای channel اصلاً ذخیره نمی‌شه (نه
این‌که فقط بعداً موقعِ execute_campaign() شکست بخوره)، و مقادیرِ
معتبرِ فعلی (sms, in_app) بدون تغییر همچنان کار می‌کنن.
"""
def test_valid_channels_still_save(self):
for channel in ('sms', 'in_app'):
campaign = MessageCampaign.objects.create(
title=f'title-{channel}', body='body', channel=channel,
)
self.assertEqual(campaign.channel, channel)
def test_invalid_channel_rejected_on_create(self):
with self.assertRaises(ValueError):
MessageCampaign.objects.create(
title='bad', body='body', channel='email',
)
self.assertEqual(MessageCampaign.objects.count(), 0)
def test_invalid_channel_rejected_on_save(self):
campaign = MessageCampaign(title='bad', body='body', channel='whatsapp')
with self.assertRaises(ValueError):
campaign.save()
self.assertEqual(MessageCampaign.objects.count(), 0)
class ExecuteCampaignIdempotencyTests(TestCase):
"""
اجرای دوباره‌ی execute_campaign() برای همون campaign نباید کاربرهایی
که قبلاً یک CampaignDelivery با status=sent دارن رو دوباره تحویل
بده (نه CampaignDelivery تکراری، نه notify/send_sms تکراری).
"""
def setUp(self):
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
def test_in_app_second_run_skips_already_sent_users(self):
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
self.assertEqual(Notification.objects.count(), 2)
self.assertEqual(
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
{CampaignDelivery.STATUS_SENT},
)
# اجرای دوباره‌ی همون کمپین: نباید هیچ CampaignDelivery یا
# Notification جدیدی ساخته بشه، چون هر دو کاربر از قبل sent هستن.
execute_campaign(campaign)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
self.assertEqual(Notification.objects.count(), 2)
@patch('campaigns.services.account_services.send_sms')
def test_sms_second_run_skips_already_sent_users(self, mock_send_sms):
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
execute_campaign(campaign)
# هیچ فراخوانیِ اضافه‌ای برای send_sms نباید انجام بشه.
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 2)
@patch('campaigns.services.account_services.send_sms')
def test_sms_failed_delivery_is_retried_not_treated_as_duplicate(self, mock_send_sms):
"""
رکوردهای failed نشونه‌ی «تحویلِ قبلاً کامل‌شده» نیستن؛ پس اجرای
دوباره باید دوباره امتحانشون کنه، نه skip.
"""
mock_send_sms.side_effect = SMSDeliveryError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='sms', target_filter={'type': 'all'},
)
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 2)
self.assertEqual(
set(CampaignDelivery.objects.filter(campaign=campaign).values_list('status', flat=True)),
{CampaignDelivery.STATUS_FAILED},
)
mock_send_sms.side_effect = None
execute_campaign(campaign)
self.assertEqual(mock_send_sms.call_count, 4)
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 4)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
2,
)
@patch('campaigns.services.notify')
def test_in_app_unexpected_exception_marks_delivery_failed_and_reraises(self, mock_notify):
"""
اگه notify() یک استثنای غیرمنتظره بده، اون خطا نباید catch/hide
بشه (باید بالا بره، مثلِ قبل)، ولی رکوردِ pendingِ همون کاربر
باید قبل از re-raise به failed آپدیت بشه — وگرنه به‌خاطرِ
unique constraintِ CampaignDelivery، این کاربر برای همیشه گیر
می‌کنه و هیچ اجرای بعدی‌ای نمی‌تونه دوباره claim کنه.
"""
mock_notify.side_effect = RuntimeError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
with self.assertRaises(RuntimeError):
execute_campaign(campaign)
# فقط یک کاربر (اولین موردِ resolve‌شده) رکورد گرفته، چون خطا
# بلافاصله بعد از notify() اول بالا رفته و حلقه متوقف شده —
# دقیقاً همون رفتاری که قبل از این تغییر هم وجود داشت.
deliveries = CampaignDelivery.objects.filter(campaign=campaign)
self.assertEqual(deliveries.count(), 1)
self.assertEqual(deliveries.get().status, CampaignDelivery.STATUS_FAILED)
self.assertEqual(Notification.objects.count(), 0)
@patch('campaigns.services.notify')
def test_in_app_failed_delivery_after_exception_can_be_retried(self, mock_notify):
"""
بعد از اینکه یک اجرا با notify() شکست‌خورده رکوردِ failed ساخته،
اجرای بعدی نباید به‌خاطرِ constraint گیر کنه — باید بتونه دوباره
برای همون کاربر claim کنه و این‌بار اگه notify() موفق باشه، به
sent برسه.
"""
mock_notify.side_effect = RuntimeError('boom')
campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
with self.assertRaises(RuntimeError):
execute_campaign(campaign)
self.assertEqual(
CampaignDelivery.objects.filter(campaign=campaign).count(), 1,
)
mock_notify.side_effect = None
execute_campaign(campaign)
# هر دو کاربر باید حالا sent باشن: کاربرِ اول (که بارِ قبل failed
# شده بود) با یک رکوردِ جدید retry شده، و کاربرِ دوم هم برای
# اولین‌بار موفق بوده.
self.assertEqual(CampaignDelivery.objects.filter(campaign=campaign).count(), 3)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
2,
)
class CampaignDeliveryConcurrencyTests(TestCase):
"""
محافظتِ سطحِ دیتابیس در برابرِ race condition بینِ دو اجرای هم‌زمانِ
execute_campaign() برای همون campaign: هر (campaign, user_id,
channel) حداکثر یک رکوردِ «فعال» (pending یا sent) می‌تونه داشته
باشه. رکوردهای failed مستثنا هستن تا رفتارِ retryِ فعلی دست‌نخورده
بمونه (پوشش داده‌شده در ExecuteCampaignIdempotencyTests بالا).
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000003')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
def test_second_pending_delivery_for_same_user_is_rejected(self):
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
with self.assertRaises(IntegrityError):
with transaction.atomic():
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
def test_second_sent_delivery_for_same_user_is_rejected(self):
first = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
first.status = CampaignDelivery.STATUS_SENT
first.save(update_fields=['status'])
with self.assertRaises(IntegrityError):
with transaction.atomic():
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
def test_failed_delivery_does_not_block_retry(self):
first = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
first.status = CampaignDelivery.STATUS_FAILED
first.save(update_fields=['status'])
# نباید IntegrityError بده: رکوردِ failed دیگه «فعال» حساب
# نمی‌شه، پس جای رکوردِ جدید رو برای retry باز می‌ذاره.
second = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
self.assertIsNotNone(second.pk)
@patch('campaigns.services.notify')
def test_execute_campaign_skips_user_claimed_by_concurrent_run(self, mock_notify):
"""
شبیه‌سازیِ اجرای هم‌زمان: یک رکوردِ pending از قبل برای این کاربر
وجود داره (انگار یک اجرای دیگرِ همین campaign همین الان داره
روش کار می‌کنه). execute_campaign نباید notify رو صدا بزنه یا
رکوردِ دیگه‌ای برای این کاربر بسازه.
"""
CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='in_app',
)
execute_campaign(self.campaign)
mock_notify.assert_not_called()
self.assertEqual(
CampaignDelivery.objects.filter(campaign=self.campaign).count(), 1,
)
class ExecuteCampaignRealConcurrencyTests(TransactionTestCase):
"""
برخلافِ CampaignDeliveryConcurrencyTests (که وضعیتِ «قبلاً claim‌شده»
رو با ساختنِ دستیِ یک رکوردِ pending شبیه‌سازی می‌کنه)، این تست دو
execute_campaign() واقعی رو در دو تردِ جداگانه — هرکدوم با کانکشنِ
دیتابیسِ خودش — دقیقاً هم‌زمان (با threading.Barrier) اجرا می‌کنه.
از TransactionTestCase استفاده شده (نه TestCase) چون TestCase کلِ
تست رو در یک تراکنشِ واحد rollback می‌کنه و بینِ کانکشن‌ها ایزوله
نیست — یعنی race واقعیِ بینِ دو تراکنشِ مستقل رو نشون نمی‌ده.
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000004')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
def test_two_concurrent_runs_deliver_notification_exactly_once(self):
barrier = threading.Barrier(2)
errors = []
def run():
try:
barrier.wait(timeout=5)
execute_campaign(self.campaign)
except Exception as exc: # pragma: no cover - surfaced via `errors`
errors.append(exc)
finally:
connection.close()
threads = [threading.Thread(target=run) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(errors, [])
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=self.campaign, status=CampaignDelivery.STATUS_SENT,
).count(),
1,
)
self.assertEqual(Notification.objects.count(), 1)
class InAppDeliveryConsistencyTests(TestCase):
"""
برای in_app، notify() و آپدیتِ نهاییِ CampaignDelivery به sent هر دو
تویِ یک transaction.atomic() هستن. این تست ثابت می‌کنه که اگه خودِ
نوشتنِ «sent» (نه notify()) به یک خطای دیتابیسی بخوره، کل تراکنش
rollback می‌شه — یعنی Notificationِ ساخته‌شده توسطِ notify() هم
persist نمی‌مونه — و رکورد به‌جای اینکه برای همیشه pending گیر کنه،
failed می‌شه (و در نتیجه در اجرای بعدی قابلِ retry‌ه).
"""
def setUp(self):
self.user = User.objects.create(name='u', is_guest=False, phone='0910000005')
self.campaign = MessageCampaign.objects.create(
title='t', body='b', channel='in_app', target_filter={'type': 'all'},
)
def test_failed_status_update_rolls_back_notification_and_marks_failed(self):
original_save = CampaignDelivery.save
def flaky_save(self_delivery, *args, **kwargs):
# فقط دقیقاً همون save‌ای که داره سعی می‌کنه رکورد رو به sent
# ببره شکست می‌خوره (نه save‌ی claim که status هنوز pendingه،
# و نه save‌ی مسیرِ failedِ بعدی که این تابع دیگه patch نشده).
if self_delivery.status == CampaignDelivery.STATUS_SENT:
raise OperationalError('simulated transient db failure')
return original_save(self_delivery, *args, **kwargs)
with patch.object(CampaignDelivery, 'save', flaky_save):
with self.assertRaises(OperationalError):
execute_campaign(self.campaign)
# notify() واقعاً صدا زده شده بود (نه mock)، ولی چون تویِ همون
# atomic-ی بود که بعداً rollback شد، هیچ Notificationِ persist‌
# شده‌ای نباید وجود داشته باشه.
self.assertEqual(Notification.objects.count(), 0)
delivery = CampaignDelivery.objects.get(campaign=self.campaign, user_id=self.user.id)
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
# حالا که رکورد failed شده (نه pendingِ گیرکرده)، اجرای بعدی
# باید بتونه دوباره claim کنه و این‌بار (بدونِ patch) موفق بشه.
execute_campaign(self.campaign)
delivery.refresh_from_db()
self.assertEqual(delivery.status, CampaignDelivery.STATUS_FAILED)
self.assertEqual(
CampaignDelivery.objects.filter(
campaign=self.campaign, user_id=self.user.id,
status=CampaignDelivery.STATUS_SENT,
).count(),
1,
)
self.assertEqual(Notification.objects.count(), 1)
class CampaignsAdminRegistrationTests(TestCase):
"""
اطمینان از این‌که MessageCampaign و CampaignDelivery در Django Admin
ثبت شدن و یک ادمینِ staff می‌تونه changelist هرکدوم رو ببینه.
توجه: get_user_model() اینجا مدلِ auth.User جنگو (برای لاگین به
/admin/) هست، کاملاً جدا از accounts.models.User که مدلِ بیزنسیِ
این پروژه‌ست و در CampaignDelivery.user_id به‌صورت سبک ارجاع
می‌شه.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin', email='admin@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
def test_message_campaign_registered(self):
self.assertIn(MessageCampaign, admin.site._registry)
def test_campaign_delivery_registered(self):
self.assertIn(CampaignDelivery, admin.site._registry)
def test_message_campaign_changelist_accessible(self):
url = reverse('admin:campaigns_messagecampaign_changelist')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_campaign_delivery_changelist_accessible(self):
url = reverse('admin:campaigns_campaigndelivery_changelist')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class MessageCampaignAdminManagementTests(TestCase):
"""
MessageCampaign باید از ادمین قابلِ ساخت/ویرایش/حذف باشه (بدونِ هیچ
منطقِ اجرا) — یعنی رفتارِ پیش‌فرضِ ModelAdmin دست‌نخورده مونده.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin2', email='admin2@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
def test_add_permission_allowed(self):
request = type('Req', (), {'user': self.staff_user})()
model_admin = MessageCampaignAdmin(MessageCampaign, admin.site)
self.assertTrue(model_admin.has_add_permission(request))
def test_can_create_campaign_via_admin(self):
url = reverse('admin:campaigns_messagecampaign_add')
response = self.client.post(url, {
'title': 'admin-created',
'body': 'body text',
'channel': 'sms',
'target_filter': '{}',
})
# ریدایرکتِ موفق بعدِ ساختِ رکورد
self.assertEqual(response.status_code, 302)
self.assertTrue(MessageCampaign.objects.filter(title='admin-created').exists())
def test_can_delete_campaign_via_admin(self):
campaign = MessageCampaign.objects.create(title='to-delete', body='b', channel='sms')
url = reverse('admin:campaigns_messagecampaign_delete', args=[campaign.pk])
response = self.client.post(url, {'post': 'yes'})
self.assertEqual(response.status_code, 302)
self.assertFalse(MessageCampaign.objects.filter(pk=campaign.pk).exists())
class CampaignDeliveryAdminReadOnlyTests(TestCase):
"""
CampaignDelivery نباید از ادمین قابلِ افزودن/ویرایش/حذفِ دستی باشه؛
فقط باید قابلِ مشاهده باشه. این دقیقاً همون چیزیه که
campaigns.services.execute_campaign برای idempotency/unique
constraint روش تکیه می‌کنه — دستکاریِ دستیِ status می‌تونه اون
تضمین‌ها رو دور بزنه.
"""
def setUp(self):
AuthUser = get_user_model()
self.staff_user = AuthUser.objects.create_superuser(
username='admin3', email='admin3@example.com', password='pass12345',
)
self.client.force_login(self.staff_user)
self.user = User.objects.create(name='u', is_guest=False, phone='0910000009')
self.campaign = MessageCampaign.objects.create(title='t', body='b', channel='sms')
self.delivery = CampaignDelivery.objects.create(
campaign=self.campaign, user_id=self.user.id, channel='sms',
)
def test_permission_methods_deny_add_change_delete_but_allow_view(self):
request = type('Req', (), {'user': self.staff_user})()
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request, self.delivery))
self.assertFalse(model_admin.has_delete_permission(request, self.delivery))
self.assertTrue(model_admin.has_view_permission(request, self.delivery))
def test_change_detail_view_is_viewable(self):
"""
صفحه‌ی جزئیاتِ رکورد باید قابلِ مشاهده باشه (GET -> 200، به‌صورتِ
read-only)، حتی اگه has_change_permission همیشه False باشه؛
دیدن نباید به fallbackِ codename پرمیشنِ پیش‌فرضِ Django وابسته
بمونه (رجوع به has_view_permission بالا).
"""
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_all_fields_are_readonly(self):
model_admin = CampaignDeliveryAdmin(CampaignDelivery, admin.site)
self.assertEqual(set(model_admin.readonly_fields), set(model_admin.fields))
def test_add_view_forbidden(self):
url = reverse('admin:campaigns_campaigndelivery_add')
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_delete_view_forbidden(self):
url = reverse('admin:campaigns_campaigndelivery_delete', args=[self.delivery.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_change_via_post_does_not_modify_status(self):
"""
حتی اگه کسی مستقیماً POST بزنه به change URL، چون
has_change_permission همیشه False برمی‌گردونه، جنگو باید 403
بده و status رکورد نباید تغییر کنه.
"""
url = reverse('admin:campaigns_campaigndelivery_change', args=[self.delivery.pk])
response = self.client.post(url, {
'campaign': self.campaign.pk,
'user_id': self.user.id,
'channel': 'sms',
'status': CampaignDelivery.STATUS_SENT,
'error_message': '',
})
self.assertEqual(response.status_code, 403)
self.delivery.refresh_from_db()
self.assertEqual(self.delivery.status, CampaignDelivery.STATUS_PENDING)