"""تست‌های قابلیت پیامک: نرمال‌سازی، idempotency، opt-out، worker و تریگرها.""" import re from datetime import timedelta from unittest import mock from django.core.management import call_command from django.test import TestCase from django.urls import reverse from django.utils import timezone from admin_panel.models import (Article, Customer, CustomerSMSSetting, PhoneNumber, Sarresid, SMSDedup, SMSLog, SMSProviderConfig, SMSQueue, SMSTemplate, SMSType) from admin_panel.sms.enqueue import enqueue_sms from admin_panel.sms.phones import customer_phone_entries from admin_panel.sms.recipients import resolve_recipients from admin_panel.sms.rules import is_sms_enabled from admin_panel.sms.scanner import run_all from admin_panel.sms.timing import apply_timing, current_timing_value, timing_for from integrations.sms.base import SMSProvider, SMSResult from integrations.sms.normalize import normalize_mobile, to_local from .factories import login_superuser, make_customer, make_user def mark_primary(customer, *numbers): """تعیین شماره‌های اصلی از دید کاربر پنل (بر مبنای مقدارِ خودِ شماره).""" from admin_panel.sms.phones import normalize_primary_values customer.sms_primary_phones = normalize_primary_values(numbers) customer.save(update_fields=['sms_primary_phones']) def activate_sms(*, since=None, allow_backlog=False, backlog_max=0, max_per_run=200, interval=0): """تنظیمات جریان را طوری می‌چیند که گویا قابلیت از مدتی قبل فعال بوده است.""" config = SMSProviderConfig.load() config.scan_since = since if since is not None else (timezone.now() - timedelta(days=365)) config.allow_backlog = allow_backlog config.backlog_max = backlog_max config.max_per_run = max_per_run config.send_interval_seconds = interval config.provider = config.provider or 'console' config.save() return config class NormalizeTests(TestCase): def test_variants_normalize_to_canonical(self): for raw in ('09120000000', '9120000000', '989120000000', '+989120000000', '00989120000000', '۰۹۱۲۰۰۰۰۰۰۰'): self.assertEqual(normalize_mobile(raw), '+989120000000', msg=raw) def test_invalid_returns_empty(self): for raw in ('', None, '123', 'abcd', '0811234567'): self.assertEqual(normalize_mobile(raw), '', msg=repr(raw)) def test_to_local(self): self.assertEqual(to_local('+989120000000'), '09120000000') class RecipientTests(TestCase): """یافتن شماره‌ها از همهٔ روش‌های ذخیره‌سازی + انتخاب شمارهٔ اصلی.""" def test_primary_phones_are_used(self): customer = make_customer() PhoneNumber.objects.create(customer=customer, phone_number='09120000001') PhoneNumber.objects.create(customer=customer, phone_number='09120000002') mark_primary(customer, '09120000002') self.assertEqual(resolve_recipients(customer), ['+989120000002']) def test_fallback_to_first_number_when_no_primary(self): customer = make_customer() PhoneNumber.objects.create(customer=customer, phone_number='09120000001') PhoneNumber.objects.create(customer=customer, phone_number='09120000002') self.assertEqual(resolve_recipients(customer), ['+989120000001']) def test_multiple_primary_numbers(self): customer = make_customer() PhoneNumber.objects.create(customer=customer, phone_number='09120000001') PhoneNumber.objects.create(customer=customer, phone_number='09120000002') mark_primary(customer, '09120000001', '09120000002') self.assertEqual( sorted(resolve_recipients(customer)), ['+989120000001', '+989120000002'], ) def test_legacy_phone_fields_are_discovered(self): """شماره‌های ذخیره‌شده در فیلدهای قدیمی phone1/2/3 هم پیدا می‌شوند.""" customer = make_customer() customer.phone1 = '9133722815' # بدون صفر ابتدایی customer.phone2 = '09121112222' customer.save(update_fields=['phone1', 'phone2']) entries = customer_phone_entries(customer) self.assertEqual( [e.normalized for e in entries], ['+989133722815', '+989121112222'], ) self.assertEqual(resolve_recipients(customer), ['+989133722815']) def test_phone_number_rows_and_legacy_fields_are_merged_without_duplicates(self): customer = make_customer() customer.phone1 = '9133722815' customer.save(update_fields=['phone1']) PhoneNumber.objects.create(customer=customer, phone_number='09133722815') entries = customer_phone_entries(customer) self.assertEqual(len(entries), 1) self.assertEqual(entries[0].normalized, '+989133722815') def test_meaningless_values_are_discarded(self): """مقدارهای بی‌معنی مثل '0' یا شمارهٔ ثابت نباید گیرنده شوند.""" customer = make_customer() customer.phone1 = '0' customer.phone2 = '27' customer.phone3 = '57387608' customer.save(update_fields=['phone1', 'phone2', 'phone3']) self.assertEqual(resolve_recipients(customer), []) self.assertTrue(all(not e.is_mobile for e in customer_phone_entries(customer))) def test_display_adds_leading_zero(self): customer = make_customer() customer.phone1 = '9133722815' customer.save(update_fields=['phone1']) self.assertEqual(customer_phone_entries(customer)[0].display, '09133722815') def test_primary_selection_self_heals_when_number_changes(self): """اگر شمارهٔ اصلی ویرایش/حذف شود، سیستم خودکار به شمارهٔ اول برمی‌گردد.""" customer = make_customer() PhoneNumber.objects.create(customer=customer, phone_number='09120000001') mark_primary(customer, '09120000003') PhoneNumber.objects.create(customer=customer, phone_number='09120000002') # شمارهٔ اصلی دیگر روی مشتری نیست ⇒ اولین شمارهٔ معتبر گیرنده می‌شود. self.assertEqual(resolve_recipients(customer), ['+989120000001']) class RuleTests(TestCase): def test_opt_out_model(self): customer = make_customer() self.assertTrue(is_sms_enabled(customer, SMSType.WELCOME)) CustomerSMSSetting.objects.create( customer=customer, sms_type=SMSType.WELCOME, is_enabled=False, ) self.assertFalse(is_sms_enabled(customer, SMSType.WELCOME)) def test_custom_type_always_enabled(self): customer = make_customer() self.assertTrue(is_sms_enabled(customer, SMSType.CUSTOM)) def test_enqueue_respects_opt_out(self): customer = make_customer() PhoneNumber.objects.create(customer=customer, phone_number='09120000001') CustomerSMSSetting.objects.create( customer=customer, sms_type=SMSType.WELCOME, is_enabled=False, ) created = enqueue_sms( customer=customer, sms_type=SMSType.WELCOME, context={'name': customer.name_family}, dedup_key='welcome:test', ) self.assertEqual(created, 0) self.assertEqual(SMSQueue.objects.count(), 0) class EnqueueIdempotencyTests(TestCase): def setUp(self): self.customer = make_customer() PhoneNumber.objects.create( customer=self.customer, phone_number='09120000001', ) def test_same_dedup_key_creates_one_row(self): for _ in range(2): enqueue_sms( customer=self.customer, sms_type=SMSType.WELCOME, context={'name': self.customer.name_family}, dedup_key='welcome:1', ) self.assertEqual(SMSQueue.objects.count(), 1) def test_multi_recipient_dedup_per_number(self): PhoneNumber.objects.create( customer=self.customer, phone_number='09120000002', ) mark_primary(self.customer, '09120000001', '09120000002') enqueue_sms( customer=self.customer, sms_type=SMSType.WELCOME, context={'name': self.customer.name_family}, dedup_key='welcome:1', ) self.assertEqual(SMSQueue.objects.count(), 2) class ScannerTests(TestCase): def setUp(self): activate_sms() self.customer = make_customer() PhoneNumber.objects.create( customer=self.customer, phone_number='09120000001', ) def test_cheque_due_scan_is_idempotent(self): today = timezone.localdate() Sarresid.objects.create( customer=self.customer, price=5000, payStatus='false', type='check', sarresidDT=timezone.now() + timedelta(days=2), ) first = run_all(today) second = run_all(today) self.assertEqual(first['cheque_due'], 1) self.assertEqual(second['cheque_due'], 0) self.assertEqual(SMSQueue.objects.filter(sms_type=SMSType.CHEQUE_DUE).count(), 1) def test_cheque_registration_rearms_per_cycle(self): Sarresid.objects.create( customer=self.customer, price=5000, payStatus='false', type='check', sarresidDT=timezone.now() + timedelta(days=30), ) # createdDT = today ⇒ days=0 روی چرخه؛ برای روز چرخهٔ بعد آن را جابه‌جا می‌کنیم. sarresid = Sarresid.objects.get() Sarresid.objects.filter(pk=sarresid.pk).update( createdDT=timezone.now() - timedelta(days=3), ) today = timezone.localdate() self.assertEqual(run_all(today)['cheque_registration'], 1) self.assertEqual(run_all(today)['cheque_registration'], 0) Sarresid.objects.filter(pk=sarresid.pk).update( createdDT=timezone.now() - timedelta(days=6), ) self.assertEqual(run_all(today)['cheque_registration'], 1) self.assertEqual( SMSQueue.objects.filter(sms_type=SMSType.CHEQUE_REGISTRATION).count(), 2, ) def test_debt_due_requires_positive_balance(self): today = timezone.localdate() Sarresid.objects.create( customer=self.customer, price=5000, payStatus='false', type='nesiye', sarresidDT=timezone.now(), ) self.assertEqual(run_all(today)['debt_due'], 0) Article.objects.create(customer=self.customer, debit=10000, credit=0) run_all(today) self.assertEqual( SMSQueue.objects.filter(sms_type=SMSType.DEBT_DUE).count(), 1, ) def test_cheque_registration_stops_once_registered(self): """با ثبت شمارهٔ صیادی، یادآوری «ثبت اطلاعات چک» متوقف می‌شود.""" sarresid = Sarresid.objects.create( customer=self.customer, price=5000, payStatus='false', type='check', sarresidDT=timezone.now() + timedelta(days=30), ) Sarresid.objects.filter(pk=sarresid.pk).update( createdDT=timezone.now() - timedelta(days=3), ) today = timezone.localdate() self.assertEqual(run_all(today)['cheque_registration'], 1) Sarresid.objects.filter(pk=sarresid.pk).update(check_sayyad_id='1234567890123456') Sarresid.objects.filter(pk=sarresid.pk).update( createdDT=timezone.now() - timedelta(days=6), ) self.assertEqual(run_all(today)['cheque_registration'], 0) def test_visit_period_comes_from_template(self): """دورهٔ بازدید از پنل قابل تنظیم است (پیش‌فرض ۹ ماه).""" customer = make_customer('مشتری بازدید') PhoneNumber.objects.create(customer=customer, phone_number='09120000005') # نصب ۱۰ ماه پیش، بدون بازدید ⇒ عقب‌افتاده از دورهٔ ۹ ماه. Customer.objects.filter(pk=customer.pk).update( installDate=timezone.localdate() - timedelta(days=300), ) customer.refresh_from_db() config = activate_sms() config.save() template = SMSTemplate.objects.get(sms_type=SMSType.VISIT) apply_timing(template, 9) template.save() self.assertEqual(run_all(timezone.localdate())['visit'], 1) # با دورهٔ ۱۲ ماه، همان مشتری دیگر عقب‌افتاده نیست. SMSQueue.objects.all().delete() apply_timing(template, 12) template.save() self.assertEqual(run_all(timezone.localdate())['visit'], 0) def test_period_start_filters_out_very_old_customers(self): """«تاریخ شروع دوره» مشتریان خیلی قدیمی را از صف بازدید بیرون می‌گذارد.""" template = SMSTemplate.objects.get(sms_type=SMSType.VISIT) apply_timing(template, 9) template.save() old = make_customer('مشتری خیلی قدیمی') PhoneNumber.objects.create(customer=old, phone_number='09120000021') Customer.objects.filter(pk=old.pk).update( installDate=timezone.localdate() - timedelta(days=1500), ) fresh = make_customer('مشتری تازه عقب‌افتاده') PhoneNumber.objects.create(customer=fresh, phone_number='09120000022') Customer.objects.filter(pk=fresh.pk).update( installDate=timezone.localdate() - timedelta(days=290), ) # شروع دوره از ۳۰ روز پیش: پنجرهٔ مشتری قدیمی خیلی قبل‌تر باز شده ⇒ فیلتر می‌شود. activate_sms(since=timezone.now() - timedelta(days=30)) self.assertEqual(run_all(timezone.localdate())['visit'], 1) self.assertEqual(SMSQueue.objects.filter(customer=fresh).count(), 1) self.assertEqual(SMSQueue.objects.filter(customer=old).count(), 0) def test_deleted_visit_reminder_rearms_next_period_only(self): """حذفِ یادآوری بازدید فقط همان دوره را کنسل می‌کند؛ دورهٔ بعد دوباره ساخته می‌شود.""" customer = make_customer('مشتری بازدید دوره‌ای') PhoneNumber.objects.create(customer=customer, phone_number='09120000007') 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() period = timedelta(weeks=int(9 * 4.3)) days_to_next = period.days - (300 % period.days) # تا شروع دورهٔ بعد today = timezone.localdate() def keys(): return list( SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True) ) run_all(today) first = keys() self.assertEqual(len(first), 1) # مدیر مورد را حذف می‌کند ⇒ در همان دوره دوباره ساخته نمی‌شود. SMSQueue.objects.filter(customer=customer).delete() run_all(today) self.assertEqual(keys(), []) # حتی نزدیک پایان دوره هم برنمی‌گردد. run_all(today + timedelta(days=days_to_next - 1)) self.assertEqual(keys(), []) # در دورهٔ بعد کلیدِ جدید ساخته می‌شود (دوباره ارسال می‌شود، نه خاموشِ دائمی). run_all(today + timedelta(days=days_to_next)) self.assertEqual(len(keys()), 1) self.assertNotEqual(keys(), first) def test_disabled_template_is_skipped(self): today = timezone.localdate() Sarresid.objects.create( customer=self.customer, price=5000, payStatus='false', type='check', sarresidDT=timezone.now() + timedelta(days=2), ) SMSTemplate.objects.filter(sms_type=SMSType.CHEQUE_DUE).update(is_enabled=False) self.assertEqual(run_all(today)['cheque_due'], 0) class ColdStartTests(TestCase): """اجرای نخست پس از نصب نباید موارد قدیمی موجود را یک‌باره ارسال کند.""" def setUp(self): self.customer = make_customer() PhoneNumber.objects.create( customer=self.customer, phone_number='09120000001', ) self.today = timezone.localdate() def _make_due_cheque(self): return Sarresid.objects.create( customer=self.customer, price=5000, payStatus='false', type='check', sarresidDT=timezone.now() + timedelta(days=1), ) def test_first_run_sends_nothing_for_existing_records(self): # مورد پیش از فعال‌سازی وجود داشته است (خط شروع = همین حالا). self._make_due_cheque() activate_sms(since=timezone.now()) self.assertEqual(run_all(self.today)['cheque_due'], 0) self.assertEqual(SMSQueue.objects.count(), 0) def test_scan_since_defaults_to_now_on_first_load(self): SMSProviderConfig.load() # حالت رکورد مهاجرت‌نشده: مقدار NULL در دیتابیس. SMSProviderConfig.objects.filter(pk=1).update(scan_since=None) self.assertIsNotNone(SMSProviderConfig.load().scan_since) def test_new_record_after_scan_since_is_sent(self): activate_sms(since=timezone.now() - timedelta(days=3)) self._make_due_cheque() self.assertEqual(run_all(self.today)['cheque_due'], 1) def test_backlog_blocked_until_admin_allows_it(self): for _ in range(3): self._make_due_cheque() activate_sms(since=timezone.now() + timedelta(days=1), allow_backlog=False) self.assertEqual(run_all(self.today)['cheque_due'], 0) def test_backlog_respects_admin_cap(self): for _ in range(5): self._make_due_cheque() activate_sms(since=timezone.now() + timedelta(days=1), allow_backlog=True, backlog_max=2) self.assertEqual(run_all(self.today)['cheque_due'], 2) # اجرای بعدی سهم بعدی را می‌فرستد و هرگز از سقف عبور نمی‌کند. self.assertEqual(run_all(self.today)['cheque_due'], 2) self.assertEqual(SMSQueue.objects.count(), 4) def test_max_per_run_caps_each_scanner(self): for _ in range(5): self._make_due_cheque() activate_sms(since=timezone.now() - timedelta(days=3), max_per_run=2) self.assertEqual(run_all(self.today)['cheque_due'], 2) class _FailingProvider(SMSProvider): def send_otp(self, mobile, code): return SMSResult(False, error='otp unsupported') def send_message(self, mobile, message): return SMSResult(False, error='boom') class WorkerTests(TestCase): def setUp(self): self.customer = make_customer() self.queue_item = SMSQueue.objects.create( customer=self.customer, sms_type=SMSType.CUSTOM, phone_number='+989120000000', message='سلام', status=SMSQueue.Status.APPROVED, ) def _config(self, provider, *, interval=0, batch=200): config = activate_sms(interval=interval, max_per_run=batch) config.provider = provider config.is_enabled = True config.save() return config def test_console_provider_marks_sent(self): self._config('console') call_command('sms_worker', '--once') self.queue_item.refresh_from_db() self.assertEqual(self.queue_item.status, SMSQueue.Status.SENT) self.assertTrue(SMSLog.objects.filter(queue=self.queue_item, success=True).exists()) def test_failing_provider_backs_off_then_fails(self): self._config('console') from unittest import mock with mock.patch( 'admin_panel.management.commands.sms_worker.get_sms_provider', return_value=_FailingProvider(), ): for _ in range(5): # شبیه‌سازی گذر زمان: پاک‌کردن backoff تا تلاش بعدی مجاز شود. SMSQueue.objects.filter(pk=self.queue_item.pk).update(next_retry_at=None) call_command('sms_worker', '--once') self.queue_item.refresh_from_db() self.assertEqual(self.queue_item.status, SMSQueue.Status.FAILED) self.assertEqual(self.queue_item.attempts, 5) self.assertEqual(SMSLog.objects.filter(queue=self.queue_item, success=False).count(), 5) def test_disabled_config_leaves_queue_untouched(self): config = self._config('console') config.is_enabled = False config.save() call_command('sms_worker', '--once') self.queue_item.refresh_from_db() self.assertEqual(self.queue_item.status, SMSQueue.Status.APPROVED) def test_pending_item_is_not_sent_until_approved(self): """بدون تأیید مدیر، worker نباید پیامکِ «در انتظار تأیید» را بفرستد.""" self._config('console') self.queue_item.status = SMSQueue.Status.PENDING self.queue_item.save(update_fields=['status']) call_command('sms_worker', '--once') self.queue_item.refresh_from_db() self.assertEqual(self.queue_item.status, SMSQueue.Status.PENDING) self.assertFalse(SMSLog.objects.filter(queue=self.queue_item).exists()) def test_send_interval_is_respected_between_messages(self): for i in range(2): SMSQueue.objects.create( customer=self.customer, sms_type=SMSType.CUSTOM, phone_number=f'+98912000001{i}', message='سلام', status=SMSQueue.Status.APPROVED, ) self._config('console', interval=1) from unittest import mock with mock.patch('time.sleep') as sleeper: call_command('sms_worker', '--once') # سه پیام ⇒ دقیقاً دو فاصله (نه پس از پیام آخر). total = sum(call.args[0] for call in sleeper.call_args_list) self.assertAlmostEqual(total, 2.0, places=2) self.assertEqual( SMSQueue.objects.filter(status=SMSQueue.Status.SENT).count(), 3, ) class ConsoleEncodingTests(TestCase): """رگرسیون خطای 'charmap': کنسول محدود ویندوز نباید ارسال را «ناموفق» کند.""" def setUp(self): self.customer = make_customer() self.queue_item = SMSQueue.objects.create( customer=self.customer, sms_type=SMSType.CUSTOM, phone_number='+989120000000', message='سلام مشتری عزیز', status=SMSQueue.Status.APPROVED, ) @staticmethod def _cp1252_stdout(): """استریم stdout با کدپیج ویندوز؛ نوشتن فارسی روی آن خطا می‌دهد.""" import io return io.TextIOWrapper(io.BytesIO(), encoding='cp1252') def test_console_provider_never_raises_on_unencodable_text(self): import sys from integrations.sms.console import ConsoleSMSProvider original = sys.stdout sys.stdout = self._cp1252_stdout() try: result = ConsoleSMSProvider(SMSProviderConfig.load()).send_message( '+989120000000', 'سلام مشتری عزیز', ) finally: sys.stdout = original self.assertTrue(result.success) def test_worker_marks_sent_when_console_cannot_encode(self): import sys config = activate_sms(interval=0) config.provider = 'console' config.is_enabled = True config.save() original = sys.stdout sys.stdout = self._cp1252_stdout() try: call_command('sms_worker', '--once') finally: sys.stdout = original self.queue_item.refresh_from_db() self.assertEqual(self.queue_item.status, SMSQueue.Status.SENT) self.assertEqual(self.queue_item.last_error, '') def test_worker_survives_a_failing_drain_batch(self): """یک دور خراب نباید worker را از پا دربیاورد (وگرنه صف راکد می‌ماند).""" from unittest import mock config = activate_sms(interval=0) config.provider = 'console' config.is_enabled = True config.save() with mock.patch( 'admin_panel.management.commands.sms_worker.Command._drain_batch', side_effect=RuntimeError('db hiccup'), ): # نباید استثنا به بیرون درز کند. call_command('sms_worker', '--once') self.queue_item.refresh_from_db() self.assertEqual(self.queue_item.status, SMSQueue.Status.APPROVED) def test_std_streams_helper_is_safe_on_non_reconfigurable_streams(self): import sys from admin_panel.apps import make_std_streams_unicode_safe original = sys.stdout sys.stdout = object() # جریانی که reconfigure ندارد try: make_std_streams_unicode_safe() # نباید استثنا بدهد finally: sys.stdout = original class HttpProviderRobustnessTests(TestCase): """provider عمومی HTTP باید پاسخ غیر-JSON را به خطای خوانا تبدیل کند، نه استثنا.""" def _provider(self, status=200, body=b'oops', content_type='text/html'): from unittest import mock from integrations.sms.http import HTTPSMSProvider config = SMSProviderConfig.load() config.api_url = 'https://sms.example.test/send' provider = HTTPSMSProvider(config) response = mock.Mock() response.status_code = status response.content = body response.headers = {'Content-Type': content_type} response.json.side_effect = ValueError('Expecting value: line 1 column 1') response.raise_for_status.return_value = None return provider, mock.patch('integrations.sms.http.requests.post', return_value=response) def test_non_json_response_becomes_a_clean_error(self): provider, patched = self._provider() with patched: result = provider.send_message('+989120000000', 'سلام') self.assertFalse(result.success) self.assertIn('JSON', result.error) self.assertEqual(result.provider_code, 'BAD_RESPONSE') def test_invalid_recipient_is_rejected_without_any_request(self): """شمارهٔ نامعتبر نباید به سرویس‌دهنده برود (هزینه/خطای گنگ).""" provider, patched = self._provider(body=b'{}', content_type='application/json') with patched as post: result = provider.send_message('000', 'سلام') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'INVALID_NUMBER') post.assert_not_called() class ZeroZeroNinetyEightLinkTests(TestCase): """سرویس لینکی 0098SMS: GET با پارامترهای vendor، شمارهٔ محلی، و تفکیک پاسخ.""" def _provider(self, **overrides): from integrations.sms.sms0098 import ZeroZeroNinetyEightSMSProvider config = SMSProviderConfig.load() config.username = 'vsms7937' config.password = '335577775533' config.sender = '30005367' config.api_url = 'http://www.0098sms.com/sendsmslink.aspx' config.timeout_seconds = 10 for field, value in overrides.items(): setattr(config, field, value) return ZeroZeroNinetyEightSMSProvider(config) def _mock_get(self, text='0', status=200): response = mock.Mock() response.status_code = status response.text = text response.raise_for_status.return_value = None return mock.patch('integrations.sms.sms0098.requests.get', return_value=response) def test_request_matches_vendor_contract_and_uses_local_number(self): """پارامترها باید همان نام‌های vendor و شماره با صفر ابتدایی باشند.""" provider = self._provider() with self._mock_get('0') as get: result = provider.send_message('+989384999655', 'سلام مشتری') self.assertTrue(result.success) self.assertEqual(result.provider_code, '0') args, kwargs = get.call_args self.assertEqual(args[0], 'http://www.0098sms.com/sendsmslink.aspx') self.assertEqual(kwargs['params'], { 'USERNAME': 'vsms7937', 'PASSWORD': '335577775533', 'FROM': '30005367', 'TO': '09384999655', 'TEXT': 'سلام مشتری', }) self.assertEqual(kwargs['timeout'], 10) def test_credentials_come_from_the_database_config(self): """هیچ مقدار ثابتی در کد نیست؛ همه از SMSProviderConfig خوانده می‌شود.""" provider = self._provider(username='other', password='pw2', sender='30009999') with self._mock_get('0') as get: provider.send_message('09202202129', 'متن') self.assertEqual(get.call_args.kwargs['params']['USERNAME'], 'other') self.assertEqual(get.call_args.kwargs['params']['PASSWORD'], 'pw2') self.assertEqual(get.call_args.kwargs['params']['FROM'], '30009999') def test_explicit_error_body_is_a_clean_failure(self): provider = self._provider() with self._mock_get('error'): result = provider.send_message('09384999655', 'سلام') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'REJECTED') def test_timeout_is_unconfirmed_not_silent_success(self): """مهلت تمام‌شده «موفق» ثبت نمی‌شود؛ اما دور انداخته هم نمی‌شود (retry).""" import requests provider = self._provider() with mock.patch('integrations.sms.sms0098.requests.get', side_effect=requests.Timeout('timed out')): result = provider.send_message('09384999655', 'سلام') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'UNCONFIRMED') def test_invalid_number_never_reaches_the_provider(self): provider = self._provider() with self._mock_get('0') as get: result = provider.send_message('000', 'سلام') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'INVALID_NUMBER') get.assert_not_called() def test_missing_credentials_is_a_config_error(self): provider = self._provider(username='') with self._mock_get('0') as get: result = provider.send_message('09384999655', 'سلام') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'CONFIG') get.assert_not_called() class ZeroZeroNinetyEightWebServiceTests(TestCase): """وب‌سرویس SOAP 0098SMS: ارسال با smsid + استعلام دلیوری (zeep ماک‌شده).""" def _provider(self, **overrides): from integrations.sms.sms0098ws import ZeroZeroNinetyEightWebServiceProvider config = SMSProviderConfig.load() config.username = 'vsms7937' config.password = '335577775533' config.panel_no = '30005367' config.webservice_url = 'https://webservice.0098sms.com/service.asmx' config.timeout_seconds = 10 for field, value in overrides.items(): setattr(config, field, value) return ZeroZeroNinetyEightWebServiceProvider(config) def _patched_client(self): """zeep.Client را با یک client جعلی جایگزین می‌کند و service آن را برمی‌گرداند.""" fake = mock.Mock() return fake, mock.patch( 'integrations.sms.sms0098ws.zeep.Client', return_value=fake, ) def test_send_returns_smsid_and_uses_vendor_params(self): provider = self._provider() fake, patched = self._patched_client() fake.service.SendSMSWithID.return_value = '123456789' with patched: result = provider.send_message('+989384999655', 'سلام مشتری') self.assertTrue(result.success) self.assertEqual(result.provider_id, '123456789') # نام پارامترها باید دقیقاً مطابق WSDL (تماماً کوچک) باشد. kwargs = fake.service.SendSMSWithID.call_args.kwargs self.assertEqual(kwargs['username'], 'vsms7937') self.assertEqual(kwargs['password'], '335577775533') self.assertEqual(kwargs['text'], 'سلام مشتری') self.assertEqual(kwargs['mobileno'], '09384999655') # شمارهٔ محلی self.assertEqual(kwargs['pnlno'], '30005367') def test_send_uses_exact_wsdl_kwarg_names(self): """رگرسیون: راهنمای vendor نام‌ها را با حرف بزرگ می‌نویسد، ولی WSDL سرویس تماماً کوچک است؛ نام بزرگ ⇒ zeep «unexpected keyword argument».""" provider = self._provider() fake, patched = self._patched_client() fake.service.SendSMSWithID.return_value = '123456789' with patched: provider.send_message('09384999655', 'متن') kwargs = fake.service.SendSMSWithID.call_args.kwargs self.assertEqual( set(kwargs), {'username', 'password', 'text', 'mobileno', 'pnlno'}, ) for wrong in ('Username', 'Password', 'MobileNo', 'PnlNo'): self.assertNotIn(wrong, kwargs) def test_send_zero_is_accepted_without_id(self): provider = self._provider() fake, patched = self._patched_client() fake.service.SendSMSWithID.return_value = 0 with patched: result = provider.send_message('09384999655', 'متن') self.assertTrue(result.success) self.assertEqual(result.provider_id, '') self.assertEqual(result.provider_code, '0') def test_error_code_is_translated_to_persian(self): provider = self._provider() fake, patched = self._patched_client() fake.service.SendSMSWithID.return_value = '66' with patched: result = provider.send_message('09384999655', 'متن') self.assertFalse(result.success) self.assertEqual(result.provider_code, '66') self.assertIn('شارژ', result.error) def test_invalid_number_never_reaches_the_service(self): provider = self._provider() fake, patched = self._patched_client() with patched: result = provider.send_message('000', 'متن') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'INVALID_NUMBER') fake.service.SendSMSWithID.assert_not_called() def test_missing_panel_uses_sender_as_fallback(self): provider = self._provider(panel_no='', sender='30009999') fake, patched = self._patched_client() fake.service.SendSMSWithID.return_value = '123456789' with patched: provider.send_message('09384999655', 'متن') self.assertEqual(fake.service.SendSMSWithID.call_args.kwargs['pnlno'], '30009999') def test_missing_credentials_is_a_config_error(self): provider = self._provider(username='') fake, patched = self._patched_client() with patched: result = provider.send_message('09384999655', 'متن') self.assertFalse(result.success) self.assertEqual(result.provider_code, 'CONFIG') fake.service.SendSMSWithID.assert_not_called() def test_delivery_state_is_classified(self): provider = self._provider() fake, patched = self._patched_client() for raw, status, is_final in ( ('5', 'delivered', True), ('6', 'not_delivered', True), ('7', 'expired', True), ('8', 'unknown', False), ): fake.service.smsdeliveryState.return_value = raw with patched: result = provider.get_delivery_status('123456789') self.assertEqual(result.status, status) self.assertEqual(result.is_final, is_final) self.assertEqual( fake.service.smsdeliveryState.call_args.kwargs['smsid'], '123456789', ) self.assertEqual( fake.service.smsdeliveryState.call_args.kwargs['username'], 'vsms7937', ) def test_delivery_query_error_returns_none(self): provider = self._provider() fake, patched = self._patched_client() fake.service.smsdeliveryState.side_effect = RuntimeError('network') with patched: self.assertIsNone(provider.get_delivery_status('123456789')) def test_provider_is_registered_in_factory(self): from integrations.sms.factory import PROVIDER_CLASSES self.assertIn('sms0098ws', PROVIDER_CLASSES) class SMSDeliveryCommandTests(TestCase): """دستور sms_delivery: استعلام smsid و به‌روزرسانی وضعیت تحویل.""" def setUp(self): from admin_panel.models import SMSQueue self.SMSQueue = SMSQueue config = SMSProviderConfig.load() config.provider = 'sms0098ws' config.username = 'u' config.password = 'p' config.panel_no = '3000' config.webservice_url = 'https://webservice.0098sms.com/service.asmx' config.save() self.old = timezone.now() - timedelta(minutes=10) def _row(self, provider_id='123456789', delivery_status='pending'): return self.SMSQueue.objects.create( phone_number='09120000000', message='متن', status=self.SMSQueue.Status.SENT, provider_id=provider_id, delivery_status=delivery_status, sent_at=self.old, ) def _run(self, result): fake = mock.Mock() fake.get_delivery_status.return_value = result with mock.patch( 'admin_panel.management.commands.sms_delivery.get_sms_provider', return_value=fake, ): call_command('sms_delivery', '--once') return fake def test_delivered_row_is_updated_and_becomes_final(self): from integrations.sms.base import SMSDeliveryResult row = self._row() self._run(SMSDeliveryResult('delivered', '5', 'رسیده به گوشی.', True)) row.refresh_from_db() self.assertEqual(row.delivery_status, 'delivered') self.assertIsNotNone(row.delivery_checked_at) def test_final_row_is_not_queried_again(self): from integrations.sms.base import SMSDeliveryResult row = self._row(delivery_status='delivered') row.delivery_checked_at = self.old row.save() fake = self._run(SMSDeliveryResult('delivered', '5', 'رسیده به گوشی.', True)) fake.get_delivery_status.assert_not_called() def test_row_without_provider_id_is_skipped(self): from integrations.sms.base import SMSDeliveryResult self._row(provider_id='') fake = self._run(SMSDeliveryResult('delivered', '5', 'x', True)) fake.get_delivery_status.assert_not_called() def test_none_result_leaves_row_untouched_for_next_round(self): row = self._row() self._run(None) row.refresh_from_db() self.assertEqual(row.delivery_status, 'pending') self.assertIsNone(row.delivery_checked_at) def test_non_webservice_provider_is_a_noop(self): from integrations.sms.base import SMSDeliveryResult SMSProviderConfig.objects.filter(pk=1).update(provider='sms0098') row = self._row() fake = self._run(SMSDeliveryResult('delivered', '5', 'x', True)) fake.get_delivery_status.assert_not_called() row.refresh_from_db() self.assertEqual(row.delivery_status, 'pending') class PanelTestCase(TestCase): """پایهٔ تست‌های پنل پیامک: یک کاربر سوپریوزر وارد‌شده می‌سازد.""" username = 'smsPanel' def setUp(self): self.user = login_superuser(self.client, self.username) class TimingsTests(PanelTestCase): """هر نوع پیامک زمان‌بندی خودش را دارد؛ فقط یک فیلد زمانی معنا دارد.""" username = 'smsTiming' def test_every_sms_type_has_timing_metadata(self): from admin_panel.sms.timing import TIMING for code, _label in SMSType.choices: self.assertIn(code, TIMING, msg=code) def test_apply_timing_pins_only_the_relevant_field(self): template = SMSTemplate.objects.get(sms_type=SMSType.CHEQUE_DUE) apply_timing(template, 5) self.assertEqual(template.offset_days, 5) self.assertIsNone(template.repeat_interval_days) self.assertIsNone(template.repeat_interval_months) visit = SMSTemplate.objects.get(sms_type=SMSType.VISIT) apply_timing(visit, 6) self.assertEqual(visit.repeat_interval_months, 6) self.assertEqual(visit.offset_days, 0) self.assertIsNone(visit.repeat_interval_days) def test_types_without_timing_have_no_fields(self): for sms_type in (SMSType.WELCOME, SMSType.DEBT_DUE, SMSType.CUSTOM): self.assertIsNone(timing_for(sms_type)['field']) def test_visit_default_is_nine_months(self): template = SMSTemplate.objects.get(sms_type=SMSType.VISIT) SMSTemplate.objects.filter(pk=template.pk).update(repeat_interval_months=None) template.refresh_from_db() self.assertEqual(current_timing_value(template), 9) def test_edit_view_saves_the_relevant_timing_field(self): template = SMSTemplate.objects.get(sms_type=SMSType.SURVEY) 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': '4'}, HTTP_REFERER='/panel/', ) template.refresh_from_db() self.assertEqual(template.offset_days, 4) self.assertIsNone(template.repeat_interval_days) def test_edit_view_rejects_non_numeric_timing(self): template = SMSTemplate.objects.get(sms_type=SMSType.SURVEY) before = template.offset_days 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': 'چند روز'}, HTTP_REFERER='/panel/', ) template.refresh_from_db() self.assertEqual(template.offset_days, before) class TemplatesPageUITests(PanelTestCase): """قرارداد صفحهٔ قالب‌ها: کارت هر نوع، کادر متن، پیش‌نمایش و بازگشت به همان کارت.""" username = 'smsUI' def _get(self): return self.client.get(reverse('admin_panel:sms_templates')) def test_page_renders_a_card_per_sms_type(self): html = self._get().content.decode('utf-8') for code, _label in SMSType.choices: self.assertIn(f'id="type-{code}"', html, msg=code) def test_editor_uses_a_dedicated_text_class_per_type(self): html = self._get().content.decode('utf-8') self.assertIn('smsx-textarea', html) for code, _label in SMSType.choices: self.assertIn(f'id="text-{code}"', html, msg=code) def test_editor_config_is_valid_json(self): import json as _json import re html = self._get().content.decode('utf-8') match = re.search( r'', html, re.S, ) self.assertIsNotNone(match) config = _json.loads(match.group(1)) self.assertIn('{name}', config['tokens']) self.assertEqual(config['sample']['name'], 'مشتری نمونه') def test_preview_substitutes_sample_values(self): html = self._get().content.decode('utf-8') # متن خوش‌آمدگویی شامل {name} است و پیش‌نمایش باید نام نمونه را نشان دهد. self.assertIn('مشتری نمونه', html) def test_only_one_card_is_open_initially(self): import re html = self._get().content.decode('utf-8') opened = re.findall(r'
]*>(.*?)', html, re.S) css = '\n'.join(styles) # box-sizing سراسری داخل صفحه، گرید فرم، و قاعدهٔ خود textarea self.assertIn('.smsx, .smsx *', css) self.assertIn('box-sizing: border-box', css) self.assertIn('.smsx-grid', css) self.assertIn('textarea.smsx-textarea', css) def test_every_field_cell_starts_with_its_own_label(self): """رگرسیون: هر سلول فرم باید برچسب مستقل داشته باشد تا برچسب/کنترل روی هم نیفتد.""" html = self._get().content.decode('utf-8') cells = re.findall(r'
', 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) # اجرای دوباره تکرار نمی‌کند