accounts.0008 drops the columns and refuses to run while guest rows exist. Docs describe the current state; tests that only proved guest behaviour are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
496 lines
23 KiB
Python
496 lines
23 KiB
Python
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', phone='0910000001')
|
||
self.user2 = User.objects.create(name='u2', 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', 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', 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', 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', 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)
|