', html)
labelled = re.findall(
r'
\s*<(?:label|span) class="smsx-label"', html,
)
self.assertTrue(cells)
self.assertEqual(len(cells), len(labelled))
def test_save_redirects_back_to_the_same_card(self):
template = SMSTemplate.objects.get(sms_type=SMSType.VISIT)
response = self.client.post(
reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
{'title': template.title, 'text': template.text,
'is_enabled': 'on', 'timing_value': '9'},
HTTP_REFERER='/panel/',
)
self.assertEqual(response.status_code, 302)
self.assertTrue(response['Location'].endswith('#type-visit'))
def test_invalid_timing_also_returns_to_the_same_card(self):
template = SMSTemplate.objects.get(sms_type=SMSType.CHEQUE_DUE)
response = self.client.post(
reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
{'title': template.title, 'text': template.text,
'is_enabled': 'on', 'timing_value': 'abc'},
HTTP_REFERER='/panel/',
)
self.assertEqual(response.status_code, 302)
self.assertTrue(response['Location'].endswith('#type-cheque_due'))
class AutoSendTests(PanelTestCase):
"""«ارسال خودکار» هر نوع پیامک: پیشفرض خاموش (نیازمند تأیید مدیر)."""
username = 'smsAuto'
def _overdue_visit_customer(self):
customer = make_customer('مشتری بازدید خودکار')
PhoneNumber.objects.create(customer=customer, phone_number='09120000031')
Customer.objects.filter(pk=customer.pk).update(
installDate=timezone.localdate() - timedelta(days=300),
)
template = SMSTemplate.objects.get(sms_type=SMSType.VISIT)
apply_timing(template, 9)
template.save()
return customer, template
def test_default_is_pending_so_manager_must_approve(self):
customer, template = self._overdue_visit_customer()
self.assertFalse(template.auto_send) # پیشفرض خاموش
activate_sms()
self.assertEqual(run_all(timezone.localdate())['visit'], 1)
item = SMSQueue.objects.get(customer=customer, sms_type=SMSType.VISIT)
self.assertEqual(item.status, SMSQueue.Status.PENDING)
def test_auto_send_enqueues_approved_without_manager(self):
customer, template = self._overdue_visit_customer()
template.auto_send = True
template.save(update_fields=['auto_send'])
activate_sms()
self.assertEqual(run_all(timezone.localdate())['visit'], 1)
item = SMSQueue.objects.get(customer=customer, sms_type=SMSType.VISIT)
self.assertEqual(item.status, SMSQueue.Status.APPROVED)
def test_auto_send_does_not_bypass_opt_out_or_disabled(self):
customer, template = self._overdue_visit_customer()
template.auto_send = True
template.is_enabled = False
template.save(update_fields=['auto_send', 'is_enabled'])
activate_sms()
self.assertEqual(run_all(timezone.localdate())['visit'], 0)
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
def test_welcome_auto_send_skips_approval_on_customer_creation(self):
template = SMSTemplate.objects.get(sms_type=SMSType.WELCOME)
template.auto_send = True
template.save(update_fields=['auto_send'])
activate_sms()
self.client.post(
reverse('admin_panel:customersAdd'),
{
'an_name': 'مشتری خوشآمد خودکار', 'an_address': 'تهران',
'installDate': '1400/01/01', 'idnCode': '888',
'phone_titles': ['موبایل'], 'phones': ['09120000032'],
'phone_primary': ['0'],
},
HTTP_REFERER='/panel/',
)
item = SMSQueue.objects.get(sms_type=SMSType.WELCOME,
customer__name_family='مشتری خوشآمد خودکار')
self.assertEqual(item.status, SMSQueue.Status.APPROVED)
def test_template_edit_persists_auto_send(self):
template = SMSTemplate.objects.get(sms_type=SMSType.NESIYE_DUE)
url = reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id})
self.client.post(
url,
{'title': template.title, 'text': template.text,
'is_enabled': 'on', 'auto_send': 'on'},
HTTP_REFERER='/panel/',
)
template.refresh_from_db()
self.assertTrue(template.auto_send)
# ارسال دوبارهٔ فرم بدون تیک ⇒ خاموش میشود (چکباکس عدمحضور = خاموش).
self.client.post(
url,
{'title': template.title, 'text': template.text, 'is_enabled': 'on'},
HTTP_REFERER='/panel/',
)
template.refresh_from_db()
self.assertFalse(template.auto_send)
def test_templates_page_shows_auto_send_switch(self):
html = self.client.get(reverse('admin_panel:sms_templates')).content.decode('utf-8')
self.assertIn('name="auto_send"', html)
class PrimaryNumberPanelTests(PanelTestCase):
"""تعیین شمارهٔ اصلی از صفحهٔ مشتری، مستقل از اینکه شماره کجا ذخیره شده است."""
username = 'smsPrimary'
def test_toggle_marks_legacy_number_as_primary(self):
customer = make_customer('مشتری شماره قدیمی')
customer.phone1 = '9133722815'
customer.phone2 = '09121112222'
customer.save(update_fields=['phone1', 'phone2'])
self.client.post(
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': customer.id}),
{'phone': '09121112222', 'is_primary': 'true'},
HTTP_REFERER='/panel/',
)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '+989121112222')
self.assertEqual(resolve_recipients(customer), ['+989121112222'])
def test_toggle_accepts_number_without_leading_zero(self):
customer = make_customer('مشتری بدون صفر')
customer.phone1 = '9133722815'
customer.save(update_fields=['phone1'])
self.client.post(
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': customer.id}),
{'phone': '9133722815', 'is_primary': 'true'},
HTTP_REFERER='/panel/',
)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '+989133722815')
def test_toggle_rejects_unknown_number(self):
customer = make_customer('مشتری ناشناس')
self.client.post(
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': customer.id}),
{'phone': '09129999999', 'is_primary': 'true'},
HTTP_REFERER='/panel/',
)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '')
def test_customer_page_lists_legacy_numbers_and_toggles(self):
customer = make_customer('مشتری صفحه')
customer.phone1 = '9133722815'
customer.save(update_fields=['phone1'])
response = self.client.get(
reverse('admin_panel:customer', kwargs={'c_id': customer.id}),
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, '09133722815')
self.assertContains(
response, reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': customer.id}),
)
class SMSPermissionTests(TestCase):
"""دسترسی به بخش پیامک فقط با پرمیشن ``admin_panel.ManageSMS``.
واگذاری این پرمیشن از Django Admin انجام میشود (کاربر → User permissions یا
از طریق Group). کاربران بدون این پرمیشن — حتی کاربران عادی پنل — نباید به هیچیک
از مسیرهای پیامک دسترسی داشته باشند.
"""
def setUp(self):
customer = make_customer('مشتری دسترسی')
self.phone = PhoneNumber.objects.create(
customer=customer, phone_number='09120000000',
)
self.template = SMSTemplate.objects.get(sms_type=SMSType.WELCOME)
self.queue_item = SMSQueue.objects.create(
customer=customer, sms_type=SMSType.CUSTOM,
phone_number='+989120000000', message='سلام', status=SMSQueue.Status.PENDING,
)
def _urls(self):
return [
('sms_settings', {}, 'get'),
('sms_templates', {}, 'get'),
('sms_queue', {}, 'get'),
('sms_template_edit', {'t_id': self.template.id}, 'post'),
('sms_customer_toggle', {'c_id': self.phone.customer_id}, 'post'),
('sms_primary_toggle', {'c_id': self.phone.customer_id}, 'post'),
('sms_send_custom', {'c_id': self.phone.customer_id}, 'post'),
('sms_queue_retry', {'q_id': self.queue_item.id}, 'post'),
('sms_queue_approve', {'q_id': self.queue_item.id}, 'post'),
('sms_queue_bulk', {}, 'post'),
('sms_queue_empty', {}, 'post'),
('sms_queue_delete', {'q_id': self.queue_item.id}, 'post'),
]
def _call(self, name, kwargs, method):
url = reverse(f'admin_panel:{name}', kwargs=kwargs)
headers = {'HTTP_REFERER': '/panel/'}
if method == 'get':
return self.client.get(url, **headers)
return self.client.post(url, {}, **headers)
def test_every_sms_url_is_covered_by_this_test(self):
"""اگر مسیر پیامکی اضافه شد، این تست یادآوری میکند که پوشش داده شود."""
from admin_panel.urls import urlpatterns
sms_names = {
p.name for p in urlpatterns if p.name and p.name.startswith('sms_')
}
self.assertEqual(sms_names, {name for name, _, _ in self._urls()})
def test_anonymous_user_is_redirected_to_login(self):
for name, kwargs, method in self._urls():
with self.subTest(url=name):
response = self._call(name, kwargs, method)
self.assertEqual(response.status_code, 302)
self.assertIn('/panel/p_login/', response['Location'])
def test_panel_user_without_permission_gets_403(self):
self.client.force_login(make_user('smsNoPerm'))
for name, kwargs, method in self._urls():
with self.subTest(url=name):
response = self._call(name, kwargs, method)
self.assertEqual(response.status_code, 403)
def test_superuser_has_access(self):
self.client.force_login(make_user('smsSuper', superuser=True))
for name, kwargs, method in self._urls():
with self.subTest(url=name):
self.assertNotIn(self._call(name, kwargs, method).status_code, (403, 405))
def test_permission_granted_in_django_admin_grants_access(self):
# همان کاری که Django Admin انجام میدهد: افزودن Permission به کاربر.
self.client.force_login(make_user('smsAllowed', perms=['ManageSMS']))
for name, kwargs, method in self._urls():
with self.subTest(url=name):
response = self._call(name, kwargs, method)
self.assertNotIn(response.status_code, (403, 405))
def test_permission_granted_via_group_grants_access(self):
from django.contrib.auth.models import Group, Permission
group = Group.objects.create(name='اپراتور پیامک')
group.permissions.add(Permission.objects.get(codename='ManageSMS'))
user = make_user('smsGrouped')
user.groups.add(group)
self.client.force_login(user)
self.assertNotEqual(
self._call('sms_settings', {}, 'get').status_code, 403,
)
def test_navbar_link_visible_only_with_permission(self):
self.client.force_login(make_user('smsNavNoPerm'))
hidden = self.client.get(reverse('admin_panel:index'))
self.assertNotContains(hidden, reverse('admin_panel:sms_settings'))
self.client.force_login(make_user('smsNavPerm', perms=['ManageSMS']))
shown = self.client.get(reverse('admin_panel:index'))
self.assertContains(shown, reverse('admin_panel:sms_settings'))
def test_permission_is_assignable_from_django_admin_user_page(self):
"""مسیر واگذاری: Django Admin → کاربر → User permissions."""
admin_user = make_user('smsSiteAdmin', superuser=True)
target = make_user('smsTarget')
self.client.force_login(admin_user)
response = self.client.get(
reverse('admin:auth_user_change', args=[target.id]),
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'ManageSMS')
def test_customer_page_hides_sms_box_without_permission(self):
self.client.force_login(make_user('smsCustNoPerm'))
response = self.client.get(
reverse('admin_panel:customer2', kwargs={'c_id': self.phone.customer_id}),
)
self.assertNotContains(response, reverse('admin_panel:sms_send_custom',
kwargs={'c_id': self.phone.customer_id}))
class SettingsPanelTests(PanelTestCase):
username = 'smsSettings'
def test_flow_controls_are_saved_from_panel(self):
response = self.client.post(
reverse('admin_panel:sms_settings'),
{
'provider': 'console', 'is_enabled': 'on',
'scan_since': '2026-01-01 08:30:00',
'allow_backlog': 'on', 'backlog_max': '7',
'max_per_run': '25', 'send_interval_seconds': '3',
},
HTTP_REFERER='/panel/',
)
self.assertEqual(response.status_code, 302)
config = SMSProviderConfig.load()
self.assertTrue(config.allow_backlog)
self.assertEqual(config.backlog_max, 7)
self.assertEqual(config.max_per_run, 25)
self.assertEqual(config.send_interval_seconds, 3)
self.assertEqual(
timezone.localtime(config.scan_since).strftime('%Y-%m-%d %H:%M:%S'),
'2026-01-01 08:30:00',
)
def test_jalali_period_start_saved_as_start_of_day(self):
"""ورودی شمسی (YYYY/MM/DD) روی «آغاز همان روز» در تایمزون پروژه ذخیره میشود."""
from persiantools.jdatetime import JalaliDate
self.client.post(
reverse('admin_panel:sms_settings'),
{'provider': 'console', 'scan_since': '1403/07/18'},
HTTP_REFERER='/panel/',
)
local = timezone.localtime(SMSProviderConfig.load().scan_since)
self.assertEqual(local.date(), JalaliDate(1403, 7, 18).to_gregorian())
self.assertEqual((local.hour, local.minute, local.second), (0, 0, 0))
def test_invalid_scan_since_keeps_previous_value(self):
config = SMSProviderConfig.load()
previous = config.scan_since
self.client.post(
reverse('admin_panel:sms_settings'),
{'provider': 'console', 'scan_since': 'not-a-date'},
HTTP_REFERER='/panel/',
)
self.assertEqual(SMSProviderConfig.load().scan_since, previous)
class WelcomeTriggerTests(PanelTestCase):
username = 'smsAdmin'
def test_customer_creation_enqueues_welcome(self):
response = self.client.post(
reverse('admin_panel:customersAdd'),
{
'an_name': 'مشتری پیامکی', 'an_address': 'تهران',
'installDate': '1400/01/01', 'idnCode': '999',
'phone_titles': ['موبایل'], 'phones': ['09120000000'],
'phone_primary': ['0'],
},
HTTP_REFERER='/panel/',
)
self.assertIn(response.status_code, (200, 302))
customer = Customer.objects.get(name_family='مشتری پیامکی')
# شمارهای که «اصلی» علامت خورده در فهرست شمارههای اصلی ذخیره میشود.
self.assertEqual(customer.sms_primary_phones, '+989120000000')
queue = SMSQueue.objects.filter(customer=customer, sms_type=SMSType.WELCOME)
self.assertEqual(queue.count(), 1)
self.assertEqual(queue.first().phone_number, '+989120000000')
def test_customer_edit_preserves_primary_selection(self):
customer = make_customer('مشتری ویرایشی')
PhoneNumber.objects.create(
customer=customer, title='موبایل', phone_number='09120000000',
)
mark_primary(customer, '09120000000')
response = self.client.post(
reverse('admin_panel:customerEdit', kwargs={'c_id': customer.id}),
{
'an_name': 'مشتری ویرایشی', 'an_address': 'تهران',
'installDate': '1400/01/01', 'idnCode': '777',
'phone_titles': ['موبایل'], 'phones': ['09120000000'],
},
HTTP_REFERER='/panel/',
)
self.assertIn(response.status_code, (200, 302))
customer.refresh_from_db()
# انتخابِ اصلی به رکورد وابسته نیست؛ با بازسازی رکوردها از بین نمیرود.
self.assertEqual(customer.sms_primary_phones, '+989120000000')
self.assertEqual(resolve_recipients(customer), ['+989120000000'])
class QueueReviewTests(PanelTestCase):
"""دروازهٔ بازبینی مدیر: ارسال فقط پس از تأیید صریح، بههمراه حذف از صف."""
username = 'smsReview'
def _item(self, **kwargs):
defaults = dict(
customer=make_customer('مشتری صف'), sms_type=SMSType.CHEQUE_DUE,
phone_number='+989120000000', message='یادآوری',
)
defaults.update(kwargs)
return SMSQueue.objects.create(**defaults)
def test_scan_enqueue_lands_in_pending_review(self):
item = self._item()
self.assertEqual(item.status, SMSQueue.Status.PENDING)
def test_approve_view_moves_item_to_approved(self):
item = self._item()
self.client.post(reverse('admin_panel:sms_queue_approve', kwargs={'q_id': item.id}))
item.refresh_from_db()
self.assertEqual(item.status, SMSQueue.Status.APPROVED)
def test_approve_view_ignores_non_pending_items(self):
item = self._item(status=SMSQueue.Status.SENT)
self.client.post(reverse('admin_panel:sms_queue_approve', kwargs={'q_id': item.id}))
item.refresh_from_db()
self.assertEqual(item.status, SMSQueue.Status.SENT)
def test_delete_view_removes_item_from_queue(self):
item = self._item()
self.client.post(reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}))
self.assertFalse(SMSQueue.objects.filter(id=item.id).exists())
def test_bulk_approve_all_approves_only_pending(self):
pending = self._item()
sent = self._item(status=SMSQueue.Status.SENT)
self.client.post(reverse('admin_panel:sms_queue_bulk'), {'action': 'approve_all'})
pending.refresh_from_db()
sent.refresh_from_db()
self.assertEqual(pending.status, SMSQueue.Status.APPROVED)
self.assertEqual(sent.status, SMSQueue.Status.SENT)
def test_bulk_delete_selected_removes_only_chosen(self):
chosen = self._item()
kept = self._item()
self.client.post(
reverse('admin_panel:sms_queue_bulk'),
{'action': 'delete_selected', 'ids': [chosen.id]},
)
self.assertFalse(SMSQueue.objects.filter(id=chosen.id).exists())
self.assertTrue(SMSQueue.objects.filter(id=kept.id).exists())
def test_custom_manual_send_is_auto_approved(self):
customer = make_customer('مشتری دستی')
PhoneNumber.objects.create(customer=customer, phone_number='09120000000')
mark_primary(customer, '09120000000')
self.client.post(
reverse('admin_panel:sms_send_custom', kwargs={'c_id': customer.id}),
{'text': 'سلام دستی'},
)
item = SMSQueue.objects.get(customer=customer)
self.assertEqual(item.status, SMSQueue.Status.APPROVED)
class NoticeMechanismTests(PanelTestCase):
"""پیام نتیجهٔ عملیات از طریق ``?notice=`` رد و بدل میشود، نه flash message.
پیامهای جنگو در session میمانند و اگر قالب مقصد بلوک پیام نداشته باشد، در
صفحهٔ بعدیِ دارای بلوک «سرگردان» ظاهر میشوند؛ اینجا کد فقط روی همان URL وجود
دارد پس پیام هم فقط همانجا دیده میشود.
"""
username = 'smsNotice'
def _item(self, **kwargs):
defaults = dict(
customer=make_customer('مشتری اعلان'), sms_type=SMSType.CHEQUE_DUE,
phone_number='+989120000000', message='یادآوری',
)
defaults.update(kwargs)
return SMSQueue.objects.create(**defaults)
def test_resolve_returns_text_and_kind_for_known_code(self):
from admin_panel.sms.notices import resolve
request = mock.Mock(GET={'notice': 'approved'})
data = resolve(request)
self.assertEqual(data['notice_kind'], 'is-success')
self.assertIn('تأیید', data['notice_text'])
def test_resolve_ignores_unknown_or_missing_code(self):
from admin_panel.sms.notices import resolve
self.assertEqual(resolve(mock.Mock(GET={'notice': 'nope'})), {})
self.assertEqual(resolve(mock.Mock(GET={})), {})
def test_resolve_fills_placeholders_from_querystring(self):
from admin_panel.sms.notices import resolve
request = mock.Mock(GET={'notice': 'tpl_saved', 'title': 'خوشآمدگویی'})
self.assertIn('خوشآمدگویی', resolve(request)['notice_text'])
def test_approve_redirects_with_notice_code_not_session_message(self):
item = self._item()
response = self.client.post(
reverse('admin_panel:sms_queue_approve', kwargs={'q_id': item.id})
)
self.assertEqual(response.status_code, 302)
self.assertIn('notice=approved', response.url)
# هیچ پیام sessionی باقی نمیماند.
self.assertEqual(list(self.client.session.get('_messages', [])), [])
def test_rendered_page_shows_the_notice(self):
url = reverse('admin_panel:sms_queue') + '?notice=deleted'
response = self.client.get(url)
self.assertContains(response, 'پیامک از صف حذف شد.')
def test_bulk_counts_are_passed_through_querystring(self):
self._item()
response = self.client.post(
reverse('admin_panel:sms_queue_bulk'), {'action': 'approve_all'}
)
self.assertIn('notice=bulk_approved_all', response.url)
self.assertIn('count=1', response.url)
class TestMessageSettingsTests(PanelTestCase):
"""متن پیامک آزمایشی از پنل قابل ویرایش است و در ارسالهای بعدی میماند."""
username = 'smsTestMsg'
def test_editing_test_message_persists_and_is_sent(self):
response = self.client.post(reverse('admin_panel:sms_settings'), {
'action': 'test', 'test_phone': '09120000000',
'test_message': 'متن آزمایشی دلخواه',
})
self.assertEqual(response.status_code, 302)
self.assertEqual(SMSProviderConfig.load().test_message, 'متن آزمایشی دلخواه')
self.assertTrue(
SMSQueue.objects.filter(message='متن آزمایشی دلخواه').exists()
)
def test_default_test_message_has_no_brand_name(self):
text = SMSProviderConfig.load().test_message
self.assertNotIn('کتیبه', text)
class DeletedStaysDeletedTests(PanelTestCase):
"""پیامکِ حذفشده از صف با اجرای مجدد برنامه بازنمیگردد.
کلید یکتاسازی در دفتر ``SMSDedup`` میماند، پس اسکنر (که در هر اجرا همان
کلیدهای تکرارشونده را میسازد) دیگر آن را از نو در صف نمیگذارد.
"""
username = 'smsNoRevive'
def test_deleted_queue_item_is_not_re_enqueued(self):
customer = make_customer('مشتری حذف')
PhoneNumber.objects.create(customer=customer, phone_number='09120000000')
mark_primary(customer, '09120000000')
key = f'cheque_due:{customer.id}'
self.assertEqual(
enqueue_sms(customer=customer, sms_type=SMSType.CHEQUE_DUE,
message='یادآوری', dedup_key=key),
1,
)
SMSQueue.objects.filter(dedup_key=key).delete()
# اجرای دوبارهٔ همان اسکنر: چون کلید در دفتر ثبت شده، صفر ساخته میشود.
self.assertEqual(
enqueue_sms(customer=customer, sms_type=SMSType.CHEQUE_DUE,
message='یادآوری', dedup_key=key),
0,
)
self.assertFalse(SMSQueue.objects.filter(dedup_key=key).exists())
def test_delete_view_then_rescan_creates_nothing(self):
from admin_panel.sms.scanner import run_all
customer = make_customer('مشتری اسکن')
PhoneNumber.objects.create(customer=customer, phone_number='09120000000')
mark_primary(customer, '09120000000')
Sarresid.objects.create(
customer=customer, price=5000, payStatus='false', type='check',
sarresidDT=timezone.now() + timedelta(days=2),
)
activate_sms()
template = SMSTemplate.objects.get(sms_type=SMSType.CHEQUE_DUE)
apply_timing(template, 2)
template.save()
self.assertEqual(run_all(timezone.localdate())['cheque_due'], 1)
item = SMSQueue.objects.get(sms_type=SMSType.CHEQUE_DUE)
self.client.post(reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}))
self.assertFalse(SMSQueue.objects.filter(id=item.id).exists())
# اجرای مجدد اسکنر: صفر مورد جدید.
self.assertEqual(run_all(timezone.localdate())['cheque_due'], 0)
self.assertEqual(SMSQueue.objects.filter(sms_type=SMSType.CHEQUE_DUE).count(), 0)
class QueueStatsTests(PanelTestCase):
"""صفحهٔ صف، آمار کل و شمارش هر وضعیت را نشان میدهد."""
username = 'smsStats'
def _item(self, status):
return SMSQueue.objects.create(
customer=make_customer('مشتری آمار'), sms_type=SMSType.CUSTOM,
phone_number='+989120000000', message='x', status=status,
)
def test_page_shows_total_and_per_status_counts(self):
self._item(SMSQueue.Status.PENDING)
self._item(SMSQueue.Status.PENDING)
self._item(SMSQueue.Status.SENT)
response = self.client.get(reverse('admin_panel:sms_queue'))
self.assertEqual(response.context['total_count'], 3)
self.assertEqual(response.context['pending_count'], 2)
stats = dict((code, n) for code, _label, n in response.context['status_stats'])
self.assertEqual(stats['pending'], 2)
self.assertEqual(stats['sent'], 1)
self.assertEqual(stats['failed'], 0)
def test_filtered_count_reflects_active_filter(self):
self._item(SMSQueue.Status.PENDING)
self._item(SMSQueue.Status.SENT)
response = self.client.get(reverse('admin_panel:sms_queue'), {'status': 'sent'})
self.assertEqual(response.context['filtered_count'], 1)
self.assertEqual(response.context['total_count'], 2)
# آمار هر وضعیت روی کل صف است، نه فیلترشده.
self.assertEqual(response.context['pending_count'], 1)
def test_list_is_paginated(self):
for _ in range(55):
self._item(SMSQueue.Status.SENT)
response = self.client.get(reverse('admin_panel:sms_queue'))
self.assertEqual(len(response.context['queue']), 50)
self.assertEqual(response.context['queue'].paginator.num_pages, 2)
def test_customer_name_links_to_customer_page(self):
customer = make_customer('مشتری لینک')
SMSQueue.objects.create(
customer=customer, sms_type=SMSType.CUSTOM,
phone_number='+989120000000', message='x',
)
response = self.client.get(reverse('admin_panel:sms_queue'))
html = response.content.decode()
self.assertIn(
reverse('admin_panel:customer', kwargs={'c_id': customer.id}), html,
)
self.assertIn('مشتری لینک', html)
def test_dates_render_as_jalali_datetime(self):
import re
self._item(SMSQueue.Status.SENT)
response = self.client.get(reverse('admin_panel:sms_queue'))
html = response.content.decode()
# تاریخ شمسی با ساعت، و هیچ ISO میلادی باقی نمانده باشد.
self.assertRegex(html, r'\d{4}/\d{2}/\d{2} \d{2}:\d{2}')
self.assertIsNone(re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', html))
def test_jalali_datetime_filter_handles_none_and_localizes(self):
from admin_panel.templatetags.custom_tags import toJalaliDT
self.assertEqual(toJalaliDT(None), '')
from django.utils import timezone
dt = timezone.localtime(timezone.now()).replace(hour=13, minute=5)
self.assertTrue(toJalaliDT(dt).endswith('13:05'))
class EmptyQueueTests(PanelTestCase):
"""خالی کردن کل صف: همهٔ رکوردها حذف و کلیدها در دفتر حفظ میشوند."""
username = 'smsEmpty'
def test_empty_removes_every_row_regardless_of_status(self):
for status in (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED,
SMSQueue.Status.SENT, SMSQueue.Status.FAILED):
SMSQueue.objects.create(
customer=make_customer('مشتری'), sms_type=SMSType.CUSTOM,
phone_number='+989120000000', message='x', status=status,
dedup_key=f'custom:{status}',
)
response = self.client.post(reverse('admin_panel:sms_queue_empty'))
self.assertEqual(response.status_code, 302)
self.assertIn('notice=queue_emptied', response.url)
self.assertEqual(SMSQueue.objects.count(), 0)
def test_emptied_keys_are_ledgered_so_nothing_returns(self):
SMSQueue.objects.create(
customer=make_customer('مشتری'), sms_type=SMSType.CUSTOM,
phone_number='+989120000000', message='x', dedup_key='custom:keep',
)
self.client.post(reverse('admin_panel:sms_queue_empty'))
self.assertTrue(SMSDedup.objects.filter(key='custom:keep').exists())
self.assertEqual(
enqueue_sms(customer=None, sms_type=SMSType.CUSTOM, message='x',
recipients=['+989120000000'], dedup_key='custom:keep'),
0,
)
self.assertEqual(SMSQueue.objects.count(), 0)
def test_get_does_not_empty(self):
SMSQueue.objects.create(
customer=make_customer('مشتری'), sms_type=SMSType.CUSTOM,
phone_number='+989120000000', message='x',
)
self.client.get(reverse('admin_panel:sms_queue_empty'))
self.assertEqual(SMSQueue.objects.count(), 1)
class PhoneCleanupTests(TestCase):
"""قاعدهٔ پاکسازی فیلدهای تلفن قدیمی و idempotent بودنش."""
def test_mobile_without_leading_zero_becomes_canonical(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
# مقدار استاندارد میشود و متن اصلی جداگانه برمیگردد تا در notes بماند.
self.assertEqual(clean_legacy_cell('9133722815'), ('09133722815', '9133722815'))
self.assertEqual(clean_legacy_cell('۰۹۱۳۳۷۲۲۸۱۵'), ('09133722815', '۰۹۱۳۳۷۲۲۸۱۵'))
self.assertEqual(clean_legacy_cell('0913-372-2815'), ('09133722815', '0913-372-2815'))
def test_canonical_cell_is_noop(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
self.assertEqual(clean_legacy_cell('09133722815'), (None, None))
self.assertEqual(clean_legacy_cell(''), (None, None))
def test_embedded_mobile_is_extracted_and_text_noted(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
value, note = clean_legacy_cell('موبایل: 09133722815')
self.assertEqual(value, '09133722815')
self.assertEqual(note, 'موبایل: 09133722815')
def test_eight_digit_landline_untouched(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
self.assertEqual(clean_legacy_cell('57387608'), (None, None))
def test_name_only_cell_cleared_and_noted(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
self.assertEqual(clean_legacy_cell('اعظمی'), ('', 'اعظمی'))
def test_pure_junk_fragment_cleared_but_preserved_in_note(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
self.assertEqual(clean_legacy_cell('0'), ('', '0'))
self.assertEqual(clean_legacy_cell('573'), ('', '573'))
def test_embedded_mobile_keeps_dropped_landline_in_note(self):
from admin_panel.sms.phone_cleanup import clean_legacy_cell
value, note = clean_legacy_cell('57322182/9130261671')
self.assertEqual(value, '09130261671')
self.assertEqual(note, '57322182/9130261671')
def test_clean_customer_is_idempotent(self):
from admin_panel.sms.phone_cleanup import clean_customer
customer = make_customer('مشتری', phones=[])
customer.phone1, customer.phone2, customer.phone3 = '9133722815', 'اعظمی', '09120000000'
customer.save()
self.assertEqual(clean_customer(customer), 2)
customer.refresh_from_db()
self.assertEqual(customer.phone1, '09133722815')
self.assertEqual(customer.phone2, '')
self.assertEqual(customer.phone3, '09120000000')
self.assertIn('اعظمی', customer.notes)
self.assertEqual(clean_customer(customer), 0)
again = Customer.objects.get(id=customer.id)
self.assertEqual(again.notes.count('پاکسازی خودکار'), 1)
def test_clean_all_reports_stats(self):
from admin_panel.sms.phone_cleanup import clean_all
customer = make_customer('مشتری')
customer.phone1 = '9133722815'
customer.save()
make_customer('دستنخورده') # بدون شمارهٔ مشکلدار
stats = clean_all(dry_run=True)
self.assertGreaterEqual(stats['customers'], 1)
self.assertGreaterEqual(stats['fields'], 1)
def test_command_dry_run_is_noop(self):
from io import StringIO
from django.core.management import call_command
customer = make_customer('مشتری')
customer.phone1 = '9133722815'
customer.save()
out = StringIO()
call_command('fix_customer_phones', '--dry-run', stdout=out)
customer.refresh_from_db()
self.assertEqual(customer.phone1, '9133722815')
def test_command_applies_and_reruns_clean(self):
from io import StringIO
from django.core.management import call_command
customer = make_customer('مشتری')
customer.phone1 = '9133722815'
customer.save()
call_command('fix_customer_phones', stdout=StringIO())
customer.refresh_from_db()
self.assertEqual(customer.phone1, '09133722815')
call_command('fix_customer_phones', stdout=StringIO())
customer.refresh_from_db()
self.assertEqual(customer.phone1, '09133722815')
self.assertIn('9133722815', customer.notes) # مقدار اصلی حفظ شده
self.assertEqual(customer.notes.count('پاکسازی خودکار'), 1) # اجرای دوباره تکرار نمیکند