', 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_REGISTRATION)
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_registration'))
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}),
)
def test_primary_toggle_button_renders_next_to_phone_in_info_column(self):
"""دکمهٔ تعیین «اصلی» باید کنار شماره در ستون اطلاعات مشتری باشد (نه پایین صفحه)."""
customer = make_customer('مشتری ستون راست')
PhoneNumber.objects.create(customer=customer, phone_number='09120000000')
html = self.client.get(
reverse('admin_panel:customer', kwargs={'c_id': customer.id}),
).content.decode('utf-8')
self.assertIn('sms-primary-toggle', html)
self.assertIn('data-phone="09120000000"', html)
# URL بهصورت data-url در ستون اطلاعات میآید (نه بهشکل فرم قدیمی پایین صفحه).
self.assertIn(
'data-url="%s"' % reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': customer.id}),
html,
)
def test_primary_marker_reflects_current_selection(self):
customer = make_customer('مشتری انتخابشده')
PhoneNumber.objects.create(customer=customer, phone_number='09120000001')
PhoneNumber.objects.create(customer=customer, phone_number='09120000002')
mark_primary(customer, '09120000002')
html = self.client.get(
reverse('admin_panel:customer', kwargs={'c_id': customer.id}),
).content.decode('utf-8')
# شمارهٔ اصلی با ستارهٔ پرشده (★) و data-primary="true" علامت خورده است.
self.assertIn('data-phone="09120000002"', html)
self.assertIn('data-primary="true"', html)
self.assertIn('★', html)
def test_sms_box_no_longer_contains_primary_phone_list(self):
"""بخش حذفشده نباید دیگر در صفحه باشد."""
customer = make_customer('مشتری باکس')
PhoneNumber.objects.create(customer=customer, phone_number='09120000000')
html = self.client.get(
reverse('admin_panel:customer', kwargs={'c_id': customer.id}),
).content.decode('utf-8')
self.assertNotIn('شمارههای اصلی (گیرنده پیامک)', html)
self.assertNotIn('گیرندگان فعلی', html)
class SMSPermissionTests(TestCase):
"""دسترسی به بخش پیامک دو سطح دارد:
* ``ManageSMS`` — مسیرهای عملیاتی (صف، گزارش، پیامک مشتری).
* ``ManageSMSSettings`` — صفحات حساس «تنظیمات سامانه» و «قالبها».
واگذاری از Django Admin انجام میشود. کاربر بدون هیچکدام، به هیچ مسیری دسترسی
ندارد؛ و کاربرِ فقط دارای ``ManageSMS`` نباید به تنظیمات/قالبها دسترسی داشته باشد.
"""
# مسیرهای عملیاتی: کافی است ManageSMS باشد.
OPERATIONAL = [
('sms_queue', {}, 'get'),
('sms_report', {}, 'get'),
('sms_customer_toggle', {'c_id': None}, 'post'),
('sms_primary_toggle', {'c_id': None}, 'post'),
('sms_send_custom', {'c_id': None}, 'post'),
('sms_queue_retry', {'q_id': None}, 'post'),
('sms_queue_approve', {'q_id': None}, 'post'),
('sms_queue_bulk', {}, 'post'),
('sms_queue_empty', {}, 'post'),
('sms_queue_delete', {'q_id': None}, 'post'),
('customerSmsHistory', {'c_id': None}, 'get'),
]
# مسیرهای تنظیمات/قالب: نیاز به ManageSMSSettings.
SETTINGS = [
('sms_settings', {}, 'get'),
('sms_templates', {}, 'get'),
('sms_template_edit', {'t_id': None}, 'post'),
]
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.WELCOME,
phone_number='+989120000000', message='سلام', status=SMSQueue.Status.PENDING,
)
self.fills = {
'c_id': self.phone.customer_id,
'q_id': self.queue_item.id,
't_id': self.template.id,
}
def _urls(self):
return self.OPERATIONAL + self.SETTINGS
def _resolve(self, kwargs):
return {k: (self.fills.get(k) if v is None else v) for k, v in kwargs.items()}
def _call(self, name, kwargs, method):
url = reverse(f'admin_panel:{name}', kwargs=self._resolve(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_')
}
covered = {name for name, _, _ in self._urls() if name.startswith('sms_')}
self.assertEqual(sms_names, covered)
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_full_permissions_grant_access(self):
# همان کاری که Django Admin انجام میدهد: افزودن Permission به کاربر.
self.client.force_login(
make_user('smsAllowed', perms=['ManageSMS', 'ManageSMSSettings']))
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_manage_sms_alone_cannot_reach_settings_pages(self):
"""کاربر فقط با ManageSMS نباید تنظیمات/قالبها را ببیند یا ویرایش کند."""
self.client.force_login(make_user('smsOpsOnly', perms=['ManageSMS']))
for name, kwargs, method in self.SETTINGS:
with self.subTest(url=name):
self.assertEqual(self._call(name, kwargs, method).status_code, 403)
# ولی مسیرهای عملیاتی برایش باز است.
self.assertNotEqual(self._call('sms_queue', {}, 'get').status_code, 403)
def test_manage_sms_settings_alone_can_open_settings_pages(self):
"""کاربر فقط با ManageSMSSettings باید تنظیمات/قالبها را ببیند."""
self.client.force_login(make_user('smsCfgOnly', perms=['ManageSMSSettings']))
for name, kwargs, method in self.SETTINGS:
with self.subTest(url=name):
self.assertNotEqual(self._call(name, kwargs, method).status_code, 403)
# ولی به مسیرهای عملیاتی دسترسی ندارد.
self.assertEqual(self._call('sms_queue', {}, 'get').status_code, 403)
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'))
group.permissions.add(Permission.objects.get(codename='ManageSMSSettings'))
user = make_user('smsGrouped')
user.groups.add(group)
self.client.force_login(user)
self.assertNotEqual(
self._call('sms_settings', {}, 'get').status_code, 403,
)
self.assertNotEqual(
self._call('sms_queue', {}, 'get').status_code, 403,
)
def test_navbar_links_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.assertNotContains(hidden, reverse('admin_panel:sms_queue'))
# با ManageSMS: فقط صف/گزارش دیده میشود، تنظیمات نه.
self.client.force_login(make_user('smsNavOps', perms=['ManageSMS']))
ops = self.client.get(reverse('admin_panel:index'))
self.assertContains(ops, reverse('admin_panel:sms_queue'))
self.assertNotContains(ops, reverse('admin_panel:sms_settings'))
# با ManageSMSSettings: تنظیمات دیده میشود، صف نه.
self.client.force_login(make_user('smsNavCfg', perms=['ManageSMSSettings']))
cfg = self.client.get(reverse('admin_panel:index'))
self.assertContains(cfg, reverse('admin_panel:sms_settings'))
self.assertNotContains(cfg, reverse('admin_panel:sms_queue'))
def test_permissions_are_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')
self.assertContains(response, 'ManageSMSSettings')
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}))
# دکمهٔ تعیین شمارهٔ اصلی (منتقلشده به ستون اطلاعات) هم نباید دیده شود.
self.assertNotContains(response, reverse('admin_panel:sms_primary_toggle',
kwargs={'c_id': self.phone.customer_id}))
self.assertNotContains(response, 'sms-primary-toggle')
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_REGISTRATION,
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_REGISTRATION,
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_fund:{customer.id}'
self.assertEqual(
enqueue_sms(customer=customer, sms_type=SMSType.CHEQUE_REGISTRATION,
message='یادآوری', dedup_key=key),
1,
)
SMSQueue.objects.filter(dedup_key=key).delete()
# اجرای دوبارهٔ همان اسکنر: چون کلید در دفتر ثبت شده، صفر ساخته میشود.
self.assertEqual(
enqueue_sms(customer=customer, sms_type=SMSType.CHEQUE_REGISTRATION,
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=3),
)
activate_sms()
template = SMSTemplate.objects.get(sms_type=SMSType.CHEQUE_REGISTRATION)
apply_timing(template, 1)
template.save()
self.assertEqual(run_all(timezone.localdate())['cheque_registration'], 1)
item = SMSQueue.objects.get(sms_type=SMSType.CHEQUE_REGISTRATION)
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_registration'], 0)
self.assertEqual(
SMSQueue.objects.filter(sms_type=SMSType.CHEQUE_REGISTRATION).count(), 0,
)
class QueueStatsTests(PanelTestCase):
"""صفحهٔ صف، آمار صف را نشان میدهد؛ ارسالشدهها از صف بیرون میمانند."""
username = 'smsStats'
def _item(self, status):
return SMSQueue.objects.create(
customer=make_customer('مشتری آمار'), sms_type=SMSType.WELCOME,
phone_number='+989120000000', message='x', status=status,
)
def test_page_shows_queue_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'], 2)
self.assertEqual(response.context['pending_count'], 2)
# ارسالشدهها جداگانه شمرده میشوند (کارت گزارش).
self.assertEqual(response.context['sent_count'], 1)
stats = dict((code, n) for code, _label, n in response.context['status_stats'])
self.assertEqual(stats['pending'], 2)
self.assertNotIn('sent', stats)
self.assertEqual(stats['failed'], 0)
def test_sent_rows_are_excluded_from_listing(self):
self._item(SMSQueue.Status.PENDING)
self._item(SMSQueue.Status.SENT)
response = self.client.get(reverse('admin_panel:sms_queue'))
ids = [item.id for item in response.context['queue']]
sent = SMSQueue.objects.get(status=SMSQueue.Status.SENT)
self.assertNotIn(sent.id, ids)
def test_status_filter_does_not_offer_sent(self):
response = self.client.get(reverse('admin_panel:sms_queue'))
codes = [code for code, _label in response.context['status_choices']]
self.assertNotIn('sent', codes)
self.assertIn('pending', codes)
def test_list_is_paginated(self):
for _ in range(55):
self._item(SMSQueue.Status.FAILED)
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.WELCOME,
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.PENDING)
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_queue_rows_but_keeps_sent(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.WELCOME,
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(), 1)
self.assertTrue(
SMSQueue.objects.filter(status=SMSQueue.Status.SENT).exists()
)
def test_emptied_keys_are_ledgered_so_nothing_returns(self):
SMSQueue.objects.create(
customer=make_customer('مشتری'), sms_type=SMSType.WELCOME,
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.WELCOME, 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.WELCOME,
phone_number='+989120000000', message='x',
)
self.client.get(reverse('admin_panel:sms_queue_empty'))
self.assertEqual(SMSQueue.objects.count(), 1)
class SentQueueProtectionTests(PanelTestCase):
"""پیامک ارسالشده از صف حذف نمیشود؛ سابقهاش در «گزارش ارسال» میماند."""
username = 'smsSentProt'
def _sent(self):
return SMSQueue.objects.create(
customer=make_customer('مشتری ارسال'), sms_type=SMSType.WELCOME,
phone_number='+989120000000', message='x', status=SMSQueue.Status.SENT,
)
def test_delete_single_sent_is_blocked(self):
item = self._sent()
response = self.client.post(
reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}),
)
self.assertEqual(response.status_code, 302)
self.assertIn('notice=delete_done_blocked', response.url)
self.assertTrue(SMSQueue.objects.filter(id=item.id).exists())
def test_bulk_delete_skips_sent_rows(self):
sent = self._sent()
pending = SMSQueue.objects.create(
customer=make_customer('مشتری درانتظار'), sms_type=SMSType.WELCOME,
phone_number='+989120000001', message='y', status=SMSQueue.Status.PENDING,
)
self.client.post(
reverse('admin_panel:sms_queue_bulk'),
{'action': 'delete_selected', 'ids': [sent.id, pending.id]},
)
self.assertTrue(SMSQueue.objects.filter(id=sent.id).exists())
self.assertFalse(SMSQueue.objects.filter(id=pending.id).exists())
def test_queue_page_has_no_delete_button_for_sent(self):
item = self._sent()
html = self.client.get(reverse('admin_panel:sms_queue')).content.decode()
self.assertNotIn(
reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}), html,
)
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) # اجرای دوباره تکرار نمیکند
class EventTriggerTests(PanelTestCase):
"""تریگرهای رویدادی پیامک (بدون signal): نظرسنجی، ثبت بازدید و تشکر از پرداخت."""
username = 'smsEvents'
def _customer_with_phone(self, name='مشتری رویداد'):
customer = make_customer(name)
PhoneNumber.objects.create(customer=customer, phone_number='09120000000')
mark_primary(customer, '09120000000')
return customer
# ---------- قرارداد: دقیقاً ۷ نوع ----------
def test_exactly_seven_sms_types(self):
self.assertEqual(len(SMSType.choices), 7)
self.assertEqual(
{code for code, _label in SMSType.choices},
{'welcome', 'survey', 'cheque_registration', 'visit',
'visit_created', 'payment_thanks', 'nesiye_due'},
)
def test_removed_types_have_no_template(self):
self.assertFalse(
SMSTemplate.objects.filter(
sms_type__in=['cheque_due', 'debt_due', 'custom'],
).exists(),
)
def test_every_type_has_a_seeded_template(self):
seeded = set(SMSTemplate.objects.values_list('sms_type', flat=True))
self.assertEqual(seeded, {code for code, _label in SMSType.choices})
# ---------- نظرسنجی + تشکر از پرداخت نقدی روی فاکتور ----------
def _post_factor(self, customer, payments):
store = make_store('انبار رویداد', owner=self.user)
product = make_product('کالای رویداد', price=1000)
stock_in(store, product, 20)
url = reverse('admin_panel:newFactor2', kwargs={'c_id': customer.id})
payload = {
'prdsList': json.dumps([{
'prd_id': product.id, 'prd_count': 2, 'price': 1000,
'garanty_Esh': '', 'product_name': product.product_name,
}]),
'PardakhtHa': json.dumps(payments),
'FactorType': 'Nasb', 'FactorDesc': '', 'sarResidDate': '',
}
return self.client.post(url, payload)
def test_factor_creation_enqueues_survey_and_cash_thanks(self):
customer = self._customer_with_phone()
response = self._post_factor(customer, {'l_naghd': 2000})
self.assertIn(response.status_code, (200, 302))
self.assertTrue(
SMSQueue.objects.filter(customer=customer, sms_type=SMSType.SURVEY).exists(),
)
self.assertTrue(
SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_factor_without_payment_has_survey_but_no_thanks(self):
customer = self._customer_with_phone()
self._post_factor(customer, {})
self.assertTrue(
SMSQueue.objects.filter(customer=customer, sms_type=SMSType.SURVEY).exists(),
)
self.assertFalse(
SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_factor_discount_only_sends_survey_not_thanks(self):
"""تخفیف، پرداختِ واقعی نیست؛ نباید پیامک تشکر بفرستد (فقط نظرسنجی)."""
customer = self._customer_with_phone()
self._post_factor(customer, {'l_takhfif': 2000})
self.assertTrue(
SMSQueue.objects.filter(customer=customer, sms_type=SMSType.SURVEY).exists(),
)
self.assertFalse(
SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.PAYMENT_THANKS).exists(),
)
# ---------- ثبت اقدام از نوع بازدید ----------
def _post_action(self, customer, action_type):
url = reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.id})
return self.client.post(url, {
'actionType': action_type,
'dateTimeAnjam': '1700000000000',
'desc': 'توضیح', 'userID': self.user.id,
})
def test_bazdid_action_enqueues_visit_created(self):
customer = self._customer_with_phone()
response = self._post_action(customer, 'bazdid')
self.assertIn(response.status_code, (200, 302))
item = SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.VISIT_CREATED)
self.assertTrue(item.exists())
# متن شامل تاریخ بازدید است.
self.assertIn('بازدید', item.first().message)
def test_non_bazdid_action_has_no_visit_created(self):
customer = self._customer_with_phone()
self._post_action(customer, 'sayer')
self.assertFalse(
SMSQueue.objects.filter(sms_type=SMSType.VISIT_CREATED).exists(),
)
# ---------- تشکر از پرداخت (وصول چک، نسیه، سند دستی) ----------
def test_pass_check_enqueues_payment_thanks(self):
customer = self._customer_with_phone()
sarresid = Sarresid.objects.create(
customer=customer, price=5000, payStatus='false', type='check',
)
url = reverse('admin_panel:passCheck', kwargs={'articl_id': sarresid.id})
response = self.client.post(url, {'message': 'ok'}, HTTP_REFERER='/panel/')
self.assertEqual(response.status_code, 200)
self.assertTrue(
SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_pardakht_nesiye_enqueues_payment_thanks(self):
customer = self._customer_with_phone()
sarresid = Sarresid.objects.create(
customer=customer, price=5000, payStatus='false', type='nesiye',
)
url = reverse('admin_panel:pardakhtNesiye', kwargs={'cs_id': customer.id})
response = self.client.post(url, {
'next_sarresidDateTime': '1700000000000',
'next_sarresidPrice': '5000',
'sr_articlID': sarresid.id,
'nahvePardakht': 'naghd',
}, HTTP_REFERER='/panel/')
self.assertIn(response.status_code, (200, 302))
self.assertTrue(
SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_pardakht_nesiye_discount_has_no_thanks(self):
"""پرداخت نسیه با نحوهٔ «تخفیف» پرداختِ واقعی نیست ⇒ بدون تشکر."""
customer = self._customer_with_phone()
sarresid = Sarresid.objects.create(
customer=customer, price=5000, payStatus='false', type='nesiye',
)
url = reverse('admin_panel:pardakhtNesiye', kwargs={'cs_id': customer.id})
response = self.client.post(url, {
'next_sarresidDateTime': '1700000000000',
'next_sarresidPrice': '5000',
'sr_articlID': sarresid.id,
'nahvePardakht': 'takhfif',
}, HTTP_REFERER='/panel/')
self.assertIn(response.status_code, (200, 302))
self.assertFalse(
SMSQueue.objects.filter(sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_manual_credit_article_enqueues_payment_thanks(self):
customer = self._customer_with_phone()
url = reverse('admin_panel:customerHesabdariAdd', kwargs={'c_id': customer.id})
response = self.client.post(url, {
'price': '1500', 'type': 'credit', 'typePy': '', 'desc': 'پرداخت',
'nahvePardakhat': 'نقد',
}, HTTP_REFERER='/panel/')
self.assertIn(response.status_code, (200, 302))
self.assertTrue(
SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_manual_credit_article_discount_has_no_thanks(self):
"""سند بستانکار با نحوهٔ «تخفیف» نباید پیامک تشکر بفرستد."""
customer = self._customer_with_phone()
url = reverse('admin_panel:customerHesabdariAdd', kwargs={'c_id': customer.id})
self.client.post(url, {
'price': '1500', 'type': 'credit', 'typePy': '', 'desc': 'تخفیف',
'nahvePardakhat': 'تخفیف',
}, HTTP_REFERER='/panel/')
self.assertFalse(
SMSQueue.objects.filter(sms_type=SMSType.PAYMENT_THANKS).exists(),
)
def test_manual_debit_article_has_no_thanks(self):
customer = self._customer_with_phone()
url = reverse('admin_panel:customerHesabdariAdd', kwargs={'c_id': customer.id})
self.client.post(url, {
'price': '1500', 'type': 'debit', 'typePy': '', 'desc': 'بدهی',
'nahvePardakhat': '',
}, HTTP_REFERER='/panel/')
self.assertFalse(
SMSQueue.objects.filter(sms_type=SMSType.PAYMENT_THANKS).exists(),
)
class CustomerSmsHistoryTests(PanelTestCase):
"""تب «سوابق پیامک» در صفحهٔ مشتری: متن دقیق، تاریخ شمسی، وضعیت تحویل، صفحهبندی."""
username = 'smsHistory'
def setUp(self):
super().setUp()
self.customer = make_customer('مشتری سابقه')
PhoneNumber.objects.create(customer=self.customer, phone_number='09120000000')
def _item(self, **kwargs):
defaults = dict(
customer=self.customer, sms_type=SMSType.WELCOME,
phone_number='+989120000000', message='متن پیامک دقیق',
status=SMSQueue.Status.SENT,
)
defaults.update(kwargs)
return SMSQueue.objects.create(**defaults)
def _url(self):
return reverse('admin_panel:customerSmsHistory', kwargs={'c_id': self.customer.id})
def test_page_shows_exact_message_text(self):
self._item(message='کد تخفیف ویژه شما: ABCD-1234')
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('کد تخفیف ویژه شما: ABCD-1234', html)
def test_page_shows_jalali_date(self):
from admin_panel.templatetags.custom_tags import toJalaliDT
item = self._item()
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn(toJalaliDT(item.created_at), html)
def test_page_shows_delivery_status(self):
self._item(delivery_status=SMSQueue.DeliveryStatus.DELIVERED)
html = self.client.get(self._url()).content.decode('utf-8')
# برچسب خوانا برای «رسیده به گوشی».
self.assertIn('رسیده به گوشی', html)
def test_page_shows_no_tracking_label_when_delivery_empty(self):
self._item(delivery_status='')
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('بدون پیگیری', html)
def test_only_this_customer_messages_are_shown(self):
self._item(message='پیام مشتری الف')
other = make_customer('مشتری دیگر')
SMSQueue.objects.create(
customer=other, sms_type=SMSType.WELCOME,
phone_number='+989120000009', message='پیام مشتری ب',
status=SMSQueue.Status.SENT,
)
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('پیام مشتری الف', html)
self.assertNotIn('پیام مشتری ب', html)
def test_list_is_paginated(self):
for i in range(25):
self._item(message=f'پیام شمارهٔ {i}')
page1 = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('صفحهٔ 1 از 2', page1)
page2 = self.client.get(self._url() + '?page=2').content.decode('utf-8')
self.assertIn('صفحهٔ 2 از 2', page2)
def test_delivery_filter_applies(self):
self._item(message='تحویلشده', delivery_status=SMSQueue.DeliveryStatus.DELIVERED)
self._item(message='درانتظار', delivery_status=SMSQueue.DeliveryStatus.PENDING)
html = self.client.get(
self._url() + '?delivery=' + SMSQueue.DeliveryStatus.DELIVERED,
).content.decode('utf-8')
self.assertIn('تحویلشده', html)
self.assertNotIn('درانتظار', html)
def test_customer_page_has_sms_history_tab(self):
html = self.client.get(
reverse('admin_panel:customer', kwargs={'c_id': self.customer.id}),
).content.decode('utf-8')
self.assertIn('سوابق پیامک', html)
self.assertIn(self._url(), html)
def test_tab_hidden_without_manage_sms_permission(self):
self.client.force_login(make_user('smsNoTab'))
html = self.client.get(
reverse('admin_panel:customer', kwargs={'c_id': self.customer.id}),
).content.decode('utf-8')
self.assertNotIn(self._url(), html)
def test_history_requires_manage_sms_permission(self):
self.client.force_login(make_user('smsNoHist'))
self.assertEqual(self.client.get(self._url()).status_code, 403)
def test_empty_history_shows_placeholder(self):
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('پیامکی برای این مشتری ثبت نشده است', html)
class SmsPartsTests(TestCase):
"""محاسبهٔ «تعداد پیامکِ محاسبهشده» (parts) بر اساس کدگذاری GSM-7 / UCS-2."""
def test_english_text_is_gsm7(self):
from admin_panel.sms.parts import detect_encoding, parts_count
self.assertEqual(detect_encoding('Hello world 123'), 'gsm7')
self.assertEqual(parts_count('Hello world'), 1)
def test_persian_text_is_ucs2(self):
from admin_panel.sms.parts import detect_encoding, parts_count
self.assertEqual(detect_encoding('سلام مشتری'), 'ucs2')
# ۷۰ کاراکتر UCS-2 در یک پیامک جا میشود.
self.assertEqual(parts_count('ب' * 70), 1)
self.assertEqual(parts_count('ب' * 71), 2)
def test_mixed_text_is_ucs2(self):
from admin_panel.sms.parts import detect_encoding
# ترکیب فارسی + انگلیسی + عدد ⇒ کل پیامک UCS-2.
self.assertEqual(detect_encoding('مشتری AB 12'), 'ucs2')
def test_gsm7_boundaries(self):
from admin_panel.sms.parts import parts_count
self.assertEqual(parts_count('a' * 160), 1)
self.assertEqual(parts_count('a' * 161), 2)
self.assertEqual(parts_count('a' * 306), 2) # دو بخش ۱۵۳تایی
self.assertEqual(parts_count('a' * 307), 3)
def test_gsm7_extended_chars_count_double(self):
from admin_panel.sms.parts import encoded_length, parts_count
# `{` و `}` کاراکترهای الحاقیاند: هرکدام ۲ واحد.
self.assertEqual(encoded_length('{}'), 4)
self.assertEqual(parts_count('{}'), 1)
def test_emoji_counts_as_two_units(self):
from admin_panel.sms.parts import encoded_length, parts_count
self.assertEqual(encoded_length('😀'), 2)
self.assertEqual(parts_count('😀' * 35), 1)
self.assertEqual(parts_count('😀' * 36), 2)
def test_empty_message_has_no_parts(self):
from admin_panel.sms.parts import parts_count
self.assertEqual(parts_count(''), 0)
class SmsReportTests(PanelTestCase):
"""گزارش ارسال پیامک: آمار، فیلتر تاریخ شمسی، جستجو، تعداد پیامک، دسترسی."""
username = 'smsReport'
def setUp(self):
super().setUp()
self.customer = make_customer('مشتری گزارش')
PhoneNumber.objects.create(customer=self.customer, phone_number='09120000000')
def _item(self, **kwargs):
defaults = dict(
customer=self.customer, sms_type=SMSType.WELCOME,
phone_number='+989120000000', message='سلام مشتری',
status=SMSQueue.Status.SENT,
)
defaults.update(kwargs)
return SMSQueue.objects.create(**defaults)
def _url(self):
return reverse('admin_panel:sms_report')
def test_report_lists_rows_and_totals(self):
self._item(message='Hello') # ۱ بخش
self._item(message='ب' * 200) # UCS-2، چند بخش
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('Hello', html)
self.assertIn('مشتری گزارش', html)
# مجموع پیامک محاسبهشده باید نمایش داده شود.
self.assertIn('مجموع پیامک محاسبهشده', html)
def test_report_computes_parts_per_message(self):
from admin_panel.sms.parts import parts_count
self._item(message='ب' * 71)
response = self.client.get(self._url())
# تعداد بخش محاسبهشده در context صفحه هست.
parts = [row['parts'] for row in response.context['rows']]
self.assertEqual(parts, [parts_count('ب' * 71)])
self.assertEqual(parts[0], 2)
def test_report_filters_by_jalali_date_range(self):
from persiantools.jdatetime import JalaliDate
self._item(message='قدیمی')
# بازهای که قطعاً بعد از «الان» است ⇒ نتیجه خالی.
future = JalaliDate(timezone.localdate() + timedelta(days=5)).strftime('%Y/%m/%d')
html = self.client.get(self._url() + f'?date_from={future}').content.decode('utf-8')
self.assertIn('پیامکی با این شرایط یافت نشد', html)
def test_report_filters_by_status(self):
self._item(message='متن-یکتای-موفق', status=SMSQueue.Status.SENT)
self._item(message='متن-یکتای-ناموفق', status=SMSQueue.Status.FAILED)
html = self.client.get(self._url() + '?status=failed').content.decode('utf-8')
self.assertIn('متن-یکتای-ناموفق', html)
self.assertNotIn('متن-یکتای-موفق', html)
def test_report_filters_by_type(self):
self._item(message='متن-یکتای-خوشامد', sms_type=SMSType.WELCOME)
self._item(message='متن-یکتای-نظرسنجی', sms_type=SMSType.SURVEY)
html = self.client.get(self._url() + '?sms_type=survey').content.decode('utf-8')
self.assertIn('متن-یکتای-نظرسنجی', html)
self.assertNotIn('متن-یکتای-خوشامد', html)
def test_report_search_matches_message_text(self):
self._item(message='کد تخفیف ABCD')
html = self.client.get(self._url() + '?q=ABCD').content.decode('utf-8')
self.assertIn('ABCD', html)
def test_report_has_link_to_customer_sms_history(self):
item = self._item()
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn(
reverse('admin_panel:customerSmsHistory', kwargs={'c_id': item.customer_id}),
html,
)
def test_report_paginates(self):
for i in range(55):
self._item(message=f'پیامک {i}')
response = self.client.get(self._url())
self.assertEqual(response.context['page'].paginator.num_pages, 2)
def test_report_requires_manage_sms_permission(self):
self.client.force_login(make_user('smsRepNoPerm'))
self.assertEqual(self.client.get(self._url()).status_code, 403)
def test_report_excludes_pending_and_cancelled(self):
"""فقط پیامکهای تأییدشده/ارسالشده باید در گزارش باشند."""
self._item(message='در-انتظار-تأیید', status=SMSQueue.Status.PENDING)
self._item(message='لغو-شده', status=SMSQueue.Status.CANCELLED)
self._item(message='تأیید-شده', status=SMSQueue.Status.APPROVED)
self._item(message='ارسال-شده', status=SMSQueue.Status.SENT)
html = self.client.get(self._url()).content.decode('utf-8')
self.assertNotIn('در-انتظار-تأیید', html)
self.assertNotIn('لغو-شده', html)
self.assertIn('تأیید-شده', html)
self.assertIn('ارسال-شده', html)
def test_report_totals_count_only_sent(self):
self._item(message='در-انتظار', status=SMSQueue.Status.PENDING)
self._item(message='ارسال-شده', status=SMSQueue.Status.SENT)
response = self.client.get(self._url())
self.assertEqual(response.context['total_records'], 1)
self.assertEqual(response.context['page'].paginator.count, 1)
def test_status_dropdown_hides_pending_and_cancelled(self):
response = self.client.get(self._url())
codes = [code for code, _label in response.context['status_choices']]
self.assertNotIn('pending', codes)
self.assertNotIn('cancelled', codes)
self.assertIn('sent', codes)
self.assertIn('approved', codes)