"""تست‌های «آخرین بازدید» (visit_policy) و دستور ترمیم. این ماژول قواعد طلایی زیر را قفل می‌کند: * فقط اقدام ``bazdid + finish`` در محاسبهٔ آخرین بازدید دیده می‌شود؛ «هماهنگی» هرگز. * تاریخ مؤثر = ``actionDateTime`` اگر از حالا جلوتر نباشد، وگرنه ``finishDateTime``. * بازمحاسبه و ترمیم، هیچ رکورد اقدامی را تغییر نمی‌دهند. * بازدید انجام‌شده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد می‌شود. """ import datetime import re import io import os from datetime import timedelta from pathlib import Path from django.core.exceptions import ValidationError from django.contrib.auth.models import Permission from django.core.management import CommandError, call_command from django.db import connection from django.test import TestCase from django.urls import reverse from django.utils import timezone from admin_panel.models import Action, Customer, PhoneNumber from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt, last_visit_map, normalize_hold_until, refresh_last_bazdid, set_visit_hold, validate_action_payload, visit_cycle_index, visit_due_date) from .factories import make_customer, make_user _BASE_NOW = timezone.now() def at(days=0, hours=0): """لحظهٔ aware نسبت به «حالا» (ثابت در طول یک ماژول، بدون drift میکروثانیه).""" return _BASE_NOW + timedelta(days=days, hours=hours) def make_visit(customer, *, action_dt=None, finish_dt=None, status='finish', action_type='bazdid'): return Action.objects.create( customer=customer, actionType=action_type, status=status, actionDateTime=action_dt, finishDateTime=finish_dt, desc='تست', result='نتیجه', ) def action_snapshot(action): """همهٔ فیلدهای یک اقدام، برای اثبات دست‌نخورده ماندن آن.""" action.refresh_from_db() return {field.name: getattr(action, field.name) for field in action._meta.concrete_fields} class EffectiveVisitDateTests(TestCase): """قاعدهٔ «تاریخ مؤثر» و انتخاب بزرگ‌ترین مقدار.""" def setUp(self): self.customer = make_customer('مشتری تاریخ مؤثر') def test_finish_before_action_uses_action_when_action_is_past(self): # هر دو در گذشته‌اند ⇒ قاعدهٔ جدید تاریخ واقعی بازدید (action) را می‌گیرد. make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31)) self.assertEqual(last_visit_dt(self.customer), as_aware(at(-30))) def test_future_action_falls_back_to_finish(self): make_visit(self.customer, action_dt=at(+10), finish_dt=at(-2)) self.assertLessEqual(last_visit_dt(self.customer), timezone.now()) def test_future_action_and_future_finish_yields_nothing(self): make_visit(self.customer, action_dt=at(+10), finish_dt=at(+11)) self.assertIsNone(last_visit_dt(self.customer)) def test_only_action(self): make_visit(self.customer, action_dt=at(-5)) self.assertEqual(last_visit_dt(self.customer), as_aware(at(-5))) def test_only_finish(self): make_visit(self.customer, finish_dt=at(-4)) self.assertEqual(last_visit_dt(self.customer), as_aware(at(-4))) def test_no_dates(self): make_visit(self.customer) self.assertIsNone(last_visit_dt(self.customer)) def test_latest_finish_wins_over_future_planned_visit(self): make_visit(self.customer, action_dt=at(-90), finish_dt=at(-90)) make_visit(self.customer, action_dt=at(+20), finish_dt=at(-3)) self.assertEqual(last_visit_dt(self.customer), as_aware(at(-3))) def test_pending_visit_is_ignored(self): make_visit(self.customer, action_dt=at(-200), finish_dt=at(-200)) make_visit(self.customer, action_dt=at(-1), status='waite') self.assertEqual(last_visit_dt(self.customer), as_aware(at(-200))) class HamahangiIsolationTests(TestCase): """هماهنگی هرگز نباید در «آخرین بازدید» دیده شود.""" def setUp(self): self.customer = make_customer('مشتری هماهنگی') def test_only_hamahangi_gives_no_visit(self): make_visit(self.customer, action_dt=at(-1), finish_dt=at(-1), action_type='hamahangi') self.assertIsNone(last_visit_dt(self.customer)) def test_newer_hamahangi_does_not_override_visit(self): make_visit(self.customer, action_dt=at(-60), finish_dt=at(-60)) make_visit(self.customer, action_dt=at(-2), finish_dt=at(-2), action_type='hamahangi') self.assertEqual(last_visit_dt(self.customer), as_aware(at(-60))) def test_refresh_does_not_clear_value_without_visit(self): Customer.objects.filter(pk=self.customer.pk).update(last_bazdid_dt=at(-10)) self.customer.refresh_from_db() make_visit(self.customer, action_dt=at(-1), action_type='hamahangi') changed = refresh_last_bazdid(self.customer) self.customer.refresh_from_db() self.assertFalse(changed) self.assertEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-10))) def test_waiting_hamahangi_is_not_a_visit_but_is_a_deferral(self): make_visit(self.customer, action_dt=at(+5), status='waite', action_type='hamahangi') self.assertIsNone(last_visit_dt(self.customer)) self.assertIsNotNone(active_deferral(self.customer)) def test_refresh_keeps_action_untouched(self): action = make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31)) before = action_snapshot(action) refresh_last_bazdid(self.customer) self.assertEqual(action_snapshot(action), before) class RefreshLastBazdidTests(TestCase): """نوشتن مقدار مشتری: فقط وقتی تغییر کند، و فقط همان ستون.""" def setUp(self): self.customer = make_customer('مشتری بازمحاسبه') def test_writes_action_date_not_finish_date(self): make_visit(self.customer, action_dt=at(-20), finish_dt=at(-1)) self.assertTrue(refresh_last_bazdid(self.customer)) self.customer.refresh_from_db() self.assertLessEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-20))) def test_is_idempotent(self): make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20)) self.assertTrue(refresh_last_bazdid(self.customer)) self.assertFalse(refresh_last_bazdid(self.customer)) def test_other_customer_fields_untouched(self): make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20)) before = Customer.objects.values().get(pk=self.customer.pk) refresh_last_bazdid(self.customer) after = Customer.objects.values().get(pk=self.customer.pk) changed = {key for key in before if before[key] != after[key]} self.assertEqual(changed, {'last_bazdid_dt'}) class ValidationTests(TestCase): """اعتبارسنجی لایهٔ HTTP (قاعدهٔ ۳۰ روز فقط برای بازدید انجام‌شده).""" def payload(self, **overrides): base = {'actionType': 'bazdid', 'status': 'finish', 'actionDateTime': at(-1)} base.update(overrides) return base def test_finished_visit_far_in_future_is_rejected(self): with self.assertRaises(ValidationError): validate_action_payload(self.payload(actionDateTime=at(+60))) def test_finished_visit_today_is_accepted(self): self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(0)))) def test_finished_visit_in_past_is_accepted(self): self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(-90)))) def test_hamahangi_in_future_is_accepted(self): self.assertTrue(validate_action_payload( self.payload(actionType='hamahangi', status='waite', actionDateTime=at(+40)) )) def test_invalid_action_type_is_rejected(self): with self.assertRaises(ValidationError): validate_action_payload(self.payload(actionType='انتخاب کنید')) def test_invalid_status_is_rejected(self): with self.assertRaises(ValidationError): validate_action_payload(self.payload(status='done')) def test_end_of_day_tehran_is_accepted(self): # ۲۳:۵۹ امروز به وقت تهران (= ۲۰:۲۹ UTC) نباید رد شود. tz = timezone.get_current_timezone() today = timezone.localdate() moment = datetime.datetime.combine( today, datetime.time(23, 59), tzinfo=tz ) self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment))) def test_start_of_day_tehran_is_accepted(self): # ۰۰:۳۰ امروز تهران در UTC روز قبل است؛ نباید به‌اشتباه رد یا آینده حساب شود. tz = timezone.get_current_timezone() today = timezone.localdate() moment = datetime.datetime.combine(today, datetime.time(0, 30), tzinfo=tz) self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment))) def test_naive_payload_is_made_aware(self): # ورودی naive (بقایای کد قدیمی) باید با تایم‌زون پروژه aware شود. naive = datetime.datetime.now() - timedelta(days=1) self.assertTrue(validate_action_payload(self.payload(actionDateTime=naive))) self.assertFalse(timezone.is_naive(as_aware(naive))) def run_command(*args): out = io.StringIO() call_command('recalc_last_bazdid', *args, stdout=out) return out.getvalue() def plan_command(*args): """برنامهٔ تغییرات را از همان مسیر ``handle`` می‌گیرد، بدون نوشتن/بکاپ.""" from django.core.management.base import CommandParser from admin_panel.management.commands.recalc_last_bazdid import Command command = Command() parser = CommandParser(add_help=False) command.add_arguments(parser) options = parser.parse_args(list(args)) defaults = {'no_color': True, 'force_color': False, 'skip_checks': False, 'traceback': False, 'verbosity': 0, 'pythonpath': None, 'settings': None, 'stdout': io.StringIO(), 'stderr': io.StringIO()} defaults.update(vars(options)) return command._plan( defaults['exact'], defaults['clear_orphans'], defaults['batch_size'], ) class RecalcCommandTests(TestCase): """دستور ترمیم: فقط Customer را می‌نویسد، هماهنگی را دست نمی‌زند.""" def setUp(self): self.forward = make_customer('مشتری رو به جلو') self.backward = make_customer('مشتری رو به عقب') self.ham = make_customer('مشتری هماهنگی') # رو به جلو: مقدار فعلی قدیمی‌تر از واقعیت است. make_visit(self.forward, action_dt=at(-30), finish_dt=at(-30)) Customer.objects.filter(pk=self.forward.pk).update(last_bazdid_dt=at(-400)) # رو به عقب: مقدار فعلی جلوتر از واقعیت است (drift ویرایش). make_visit(self.backward, action_dt=at(-100), finish_dt=at(-100)) Customer.objects.filter(pk=self.backward.pk).update(last_bazdid_dt=at(-2)) # هماهنگی: مقدارش دقیقاً از تاریخ هماهنگی است. self.ham_action = make_visit( self.ham, action_dt=at(-5), finish_dt=at(-5), action_type='hamahangi', ) Customer.objects.filter(pk=self.ham.pk).update(last_bazdid_dt=at(-5)) def _values(self): return dict(Customer.objects.values_list('id', 'last_bazdid_dt')) def test_dry_run_writes_nothing(self): before = self._values() run_command('--exact', '--dry-run') self.assertEqual(self._values(), before) def test_exact_requires_confirm(self): with self.assertRaises(CommandError): run_command('--exact') def test_plan_default_mode_ignores_backward_drift(self): stats, _, pending, _ = plan_command() self.assertEqual(stats['forward'], 1) self.assertEqual(stats['backward'], 0) self.assertNotIn(self.backward.pk, [customer_id for customer_id, _ in pending]) def test_plan_exact_mode_fixes_both_directions(self): stats, _, pending, _ = plan_command('--exact') targets = dict(pending) self.assertEqual(stats['forward'], 1) self.assertEqual(stats['backward'], 1) self.assertEqual(as_aware(targets[self.forward.pk]), as_aware(at(-30))) self.assertEqual(as_aware(targets[self.backward.pk]), as_aware(at(-100))) def test_apply_writes_only_customer_column(self): from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd _, _, pending, _ = plan_command('--exact') before = action_snapshot(self.ham_action) Cmd()._apply(pending) values = self._values() self.assertEqual(as_aware(values[self.forward.pk]), as_aware(at(-30))) self.assertEqual(as_aware(values[self.backward.pk]), as_aware(at(-100))) self.assertEqual(action_snapshot(self.ham_action), before) def test_plan_is_idempotent_after_apply(self): from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd _, _, pending, _ = plan_command('--exact') Cmd()._apply(pending) stats, _, pending_again, _ = plan_command('--exact') self.assertEqual(pending_again, []) self.assertEqual(stats['no_change'], stats['scanned']) def test_hamahangi_value_is_untouched_without_clear_orphans(self): plan_command('--exact') self.assertEqual(as_aware(self._values()[self.ham.pk]), as_aware(at(-5))) def test_hamahangi_value_is_cleared_with_clear_orphans(self): _, _, pending, _ = plan_command('--exact', '--clear-orphans') self.assertIn((self.ham.pk, None), pending) def test_hamahangi_suspect_heuristic(self): _, _, _, suspects = plan_command('--exact') self.assertEqual(suspects, 1) # فقط مشتری هماهنگی class EntezarScannerConsistencyTests(TestCase): """صفحهٔ «انتظار» و اسکنر پیامک باید یک جواب بدهند.""" def setUp(self): self.user = make_user('entezarAdmin', superuser=True) def _entezar_ids(self): self.client.force_login(self.user) response = self.client.get('/panel/entezar/') self.assertEqual(response.status_code, 200) return {c.id for c in response.context['customers']} def test_same_customer_state_on_both_sides(self): from admin_panel.sms.scanner import scan_visit from admin_panel.sms.timing import DEFAULT_VISIT_MONTHS months = DEFAULT_VISIT_MONTHS stale = make_customer('مشتری معوق مشترک') fresh = make_customer('مشتری تازه بازدیدشده') Customer.objects.filter(pk=stale.pk).update( installDate=timezone.localdate() - timedelta(days=400) ) Customer.objects.filter(pk=fresh.pk).update( installDate=timezone.localdate() - timedelta(days=400) ) make_visit(fresh, action_dt=timezone.now() - timedelta(days=5), finish_dt=timezone.now() - timedelta(days=5)) ids = self._entezar_ids() self.assertIn(stale.pk, ids) self.assertNotIn(fresh.pk, ids) # اسکنر هم باید فقط مشتری معوق را عقب‌افتاده بداند. from admin_panel.visit_policy import active_deferrals, last_visit_map, visit_period period = visit_period(months) now = timezone.now() visits = last_visit_map(now=now) deferrals = active_deferrals(now=now) def is_stale(customer): anchor = visits.get(customer.pk) or as_aware(customer.installDate) return (customer.pk not in deferrals) and (anchor + period <= now) self.assertTrue(is_stale(Customer.objects.get(pk=stale.pk))) self.assertFalse(is_stale(Customer.objects.get(pk=fresh.pk))) def test_active_deferral_hides_customer(self): deferred = make_customer('مشتری تعویق‌دار') Customer.objects.filter(pk=deferred.pk).update( installDate=timezone.localdate() - timedelta(days=400) ) make_visit(deferred, action_dt=at(+3), status='waite', action_type='hamahangi') ids = self._entezar_ids() self.assertNotIn(deferred.pk, ids) class VisitCadenceTests(TestCase): """قاعدهٔ «هر ۹ ماه یک‌بار»: موعد و چرخه از یک تقویم می‌آیند.""" MONTHS = 9 def test_due_date_is_calendar_nine_months(self): anchor = as_aware(datetime.date(2024, 3, 4)) self.assertEqual(visit_due_date(anchor, months=self.MONTHS).date(), datetime.date(2024, 12, 4)) def test_cycle_increments_exactly_on_each_due_date(self): anchor = as_aware(datetime.date(2024, 3, 4)) self.assertEqual(visit_cycle_index(anchor, anchor, months=self.MONTHS), 0) for k in range(1, 6): due = visit_due_date(anchor, months=self.MONTHS * k) # در روز موعدِ k-ام، «دورهٔ جاری» همان k است. self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), k, msg=f'چرخه در موعد {k} باید {k} باشد') def test_cycle_does_not_advance_before_due_date(self): anchor = as_aware(datetime.date(2024, 3, 4)) due = visit_due_date(anchor, months=self.MONTHS) before = due - timedelta(days=1) self.assertEqual(visit_cycle_index(anchor, before, months=self.MONTHS), 0) self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), 1) def test_each_period_has_a_distinct_key(self): """بازگشت باگ اصلی: کلید هر دوره باید یکتا باشد (نه تکرار، نه پرش).""" anchor = as_aware(datetime.date(2024, 1, 1)) keys = [] for k in range(1, 7): due = visit_due_date(anchor, months=self.MONTHS * k) keys.append((visit_cycle_index(anchor, due, months=self.MONTHS), due.strftime('%Y-%m'))) self.assertEqual(len({key for _cycle, key in keys}), len(keys)) self.assertEqual([c for c, _k in keys], [1, 2, 3, 4, 5, 6]) def test_customer_without_any_visit_gets_every_period(self): """مشتری که هرگز بازدید نشده، برای هر ۹ ماه یک کلید تازه می‌گیرد.""" from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue from admin_panel.sms.scanner import scan_visit customer = make_customer('مشتری بدون بازدید') PhoneNumber.objects.create(customer=customer, phone_number='09120000123') Customer.objects.filter(pk=customer.pk).update( installDate=datetime.date(2023, 1, 1) ) customer.refresh_from_db() config = SMSProviderConfig.load() # «اکنون» این تست در سال ۲۰۲۳–۲۰۲۵ است، پس پنجرهٔ یادآوری هم همان‌جا باز می‌شود. config.allow_backlog = True config.backlog_max = 100 config.scan_since = timezone.make_aware( datetime.datetime(2023, 1, 2, 0, 0), timezone.get_current_timezone(), ) config.save() seen_keys = set() cursor = datetime.date(2023, 1, 2) while cursor <= datetime.date(2026, 1, 1): moment = timezone.make_aware( datetime.datetime.combine(cursor, datetime.time(12, 0)), timezone.get_current_timezone(), ) scan_visit(cursor, now=moment) seen_keys.update( SMSQueue.objects.filter(customer=customer) .values_list('dedup_key', flat=True) ) cursor += timedelta(days=14) # ۹ ماه اول (۲۰۲۳-۱۰) ، ۱۸ ماه (۲۰۲۴-۰۷) و ۲۷ ماه (۲۰۲۵-۰۴) ⇒ سه دورهٔ یکتا. # نکته: اسکنر همیشه «تازه‌ترین موعد رسیده» را هدف می‌گیرد؛ پس اگر بین دو اجرا # بیش از یک دوره گذشته باشد، فقط آخرین دوره صف می‌شود (نه ارسال انبوه). due_months = {key.rsplit(':', 1)[1] for key in seen_keys} self.assertEqual( due_months, {'2023-10', '2024-07', '2025-04'}, msg=f'کلیدهای ساخته‌شده: {sorted(seen_keys)}', ) class VisitDecisionTests(TestCase): """تصمیم‌های محصولی: یک پیامک برای جدیدترین دوره، بازگشت پس از حذف، ریست با نصب.""" MONTHS = 9 def _scan_at(self, day): from admin_panel.sms.scanner import scan_visit moment = timezone.make_aware( datetime.datetime.combine(day, datetime.time(12, 0)), timezone.get_current_timezone(), ) return scan_visit(day, now=moment) def _prep(self, install_date): from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue customer = make_customer('مشتری تصمیم') PhoneNumber.objects.create(customer=customer, phone_number='09120000777') # ثبت مستقیم: `installDate` با auto_now_add پر می‌شود، پس با update() بازنویسی می‌کنیم. Customer.objects.filter(pk=customer.pk).update(installDate=install_date) customer.refresh_from_db() self.assertEqual(customer.installDate, install_date) config = SMSProviderConfig.load() config.allow_backlog = True config.backlog_max = 100 config.scan_since = timezone.make_aware( datetime.datetime(2020, 1, 1, 0, 0), timezone.get_current_timezone(), ) config.save() return customer, SMSQueue # --- تصمیم ۱: فقط یک پیامک برای جدیدترین دوره --- def test_absent_25_months_gets_exactly_one_reminder(self): customer, SMSQueue = self._prep(datetime.date(2023, 1, 1)) self._scan_at(datetime.date(2025, 2, 10)) # ۲۵ ماه بعد keys = list(SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True)) self.assertEqual(len(keys), 1, msg=f'کلیدها: {keys}') # جدیدترین دورهٔ رسیده: ۱۸ ماهگی (۲۰۲۴-۰۷) — نه ۹ ماهگی. self.assertEqual(keys[0].rsplit(':', 1)[1], '2024-07') # --- تصمیم ۲: حذف یادآوری = کنسل همان دوره تا ۹ ماه --- def test_deleted_reminder_stays_cancelled_until_next_period(self): customer, SMSQueue = self._prep(datetime.date(2023, 1, 1)) self._scan_at(datetime.date(2024, 1, 10)) # دورهٔ ۹ ماهگی self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1) # مدیر از پنل صف، پیامک را حذف می‌کند (مسیر واقعی «کنسل دوره»). from django.urls import reverse from tests.factories import make_user manager = make_user('smsQueueManager', superuser=True) self.client.force_login(manager) item = SMSQueue.objects.get(customer=customer) response = self.client.post( reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}), ) self.assertEqual(response.status_code, 302) self.assertFalse(SMSQueue.objects.filter(customer=customer).exists()) # تا پایان دورهٔ اول (موعد بعدی = ۲۰۲۴-۰۷-۰۱) هیچ یادآوری تازه‌ای ساخته نمی‌شود. for day in (datetime.date(2024, 2, 10), datetime.date(2024, 6, 10)): self._scan_at(day) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0, msg='حذف باید همان دوره را کنسل نگه دارد') # دورهٔ دوم (۱۸ ماهگی) ⇒ یادآوری تازه با کلید دیگر. self._scan_at(datetime.date(2024, 7, 1)) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1) self.assertEqual( SMSQueue.objects.get(customer=customer).dedup_key.rsplit(':', 1)[1], '2024-07', ) # --- تصمیم ۳: نصب چرخه را ریست می‌کند --- def test_nasb_action_resets_the_cycle(self): customer, SMSQueue = self._prep(datetime.date(2023, 1, 1)) from admin_panel.models import Action Action.objects.create( customer=customer, actionType='nasb', status='finish', actionDateTime=timezone.make_aware( datetime.datetime(2024, 1, 1, 10, 0), timezone.get_current_timezone(), ), ) # ۸ ماه بعد از نصب ⇒ هنوز موعد نرسیده self._scan_at(datetime.date(2024, 9, 1)) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0) # ۹ ماه کامل بعد از نصب ⇒ یادآوری self._scan_at(datetime.date(2024, 10, 5)) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1) def test_phone_call_does_not_reset_the_cycle(self): customer, SMSQueue = self._prep(datetime.date(2023, 1, 1)) from admin_panel.models import Action Action.objects.create( customer=customer, actionType='outCall', status='finish', actionDateTime=timezone.make_aware( datetime.datetime(2024, 5, 1, 10, 0), timezone.get_current_timezone(), ), ) # ۹ ماه از نصب گذشته ⇒ باید معوق بماند، چون تماس تلفنی ریست نمی‌کند self._scan_at(datetime.date(2024, 10, 5)) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1) # --- تصمیم ۴: پایان تعویق ⇒ اگر بازدید قدیمی است، بلافاصله معوق --- def test_after_deferral_ends_old_visit_makes_customer_due_immediately(self): customer, SMSQueue = self._prep(datetime.date(2023, 1, 1)) from admin_panel.models import Action # آخرین بازدید واقعی: ۱۰ ماه پیش از پایان تعویق (۲۰۲۵-۰۳-۱۵) Action.objects.create( customer=customer, actionType='bazdid', status='finish', actionDateTime=timezone.make_aware( datetime.datetime(2025, 3, 15, 10, 0), timezone.get_current_timezone(), ), ) # تعویق فعال تا ۲۰۲۶-۰۱-۱۵ ⇒ در این بازه یادآوری نمی‌گیرد Action.objects.create( customer=customer, actionType='hamahangi', status='waite', actionDateTime=timezone.make_aware( datetime.datetime(2026, 1, 15, 10, 0), timezone.get_current_timezone(), ), ) self._scan_at(datetime.date(2026, 1, 10)) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0) # تعویق تمام شد؛ ۱۰ ماه از بازدید گذشته ⇒ همان روز یادآوری می‌گیرد self._scan_at(datetime.date(2026, 1, 20)) self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1) class NextAppointmentTests(TestCase): """تصمیم: هماهنگیِ «انجام‌شده» و بازدیدِ برنامه‌ریزی‌شده هم مشتری را از فهرست خارج می‌کنند.""" def setUp(self): from tests.factories import make_user self.user = make_user('apptExpert', superuser=True) self.client.force_login(self.user) def _customer(self, name, install_days=400): customer = make_customer(name) Customer.objects.filter(pk=customer.pk).update( installDate=timezone.localdate() - timedelta(days=install_days) ) customer.refresh_from_db() return customer def _entezar_ids(self): response = self.client.get('/panel/entezar/') self.assertEqual(response.status_code, 200) return {c.id for c in response.context['customers']} def _add(self, customer, action_type, status, when, **extra): return Action.objects.create( customer=customer, actionType=action_type, status=status, actionDateTime=when, **extra, ) # --- خواستهٔ ۱: هماهنگیِ finish --- def test_finished_hamahangi_hides_customer_until_appointment(self): customer = self._customer('هماهنگی انجام‌شده') # همین حالا تماس گرفتیم و برای هفتهٔ بعد توافق شد؛ هماهنگی «انجام شد». self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0)) self.assertNotIn(customer.pk, self._entezar_ids()) def test_finished_hamahangi_with_past_date_does_not_hide(self): customer = self._customer('هماهنگی گذشته') self._add(customer, 'hamahangi', 'finish', at(-2), finishDateTime=at(-2)) self.assertIn(customer.pk, self._entezar_ids()) def test_finished_hamahangi_falls_back_to_finish_datetime(self): customer = self._customer('هماهنگی بدون تاریخ اقدام') self._add(customer, 'hamahangi', 'finish', None, finishDateTime=at(+5)) self.assertNotIn(customer.pk, self._entezar_ids()) # --- خواستهٔ ۲: بازدید waite با تاریخ آینده --- def test_scheduled_visit_hides_customer(self): customer = self._customer('بازدید برنامه‌ریزی‌شده') self._add(customer, 'bazdid', 'waite', at(+5)) self.assertNotIn(customer.pk, self._entezar_ids()) def test_scheduled_visit_in_the_past_does_not_hide(self): customer = self._customer('بازدید عقب‌افتاده') self._add(customer, 'bazdid', 'waite', at(-5)) self.assertIn(customer.pk, self._entezar_ids()) # --- پس از تاریخ قرار، مشتری برمی‌گردد --- def test_customer_returns_after_appointment_date(self): from admin_panel.visit_policy import next_appointment_map customer = self._customer('قرار منقضی') self._add(customer, 'bazdid', 'waite', at(+3)) self.assertIn(customer.pk, next_appointment_map(now=at(0))) self.assertNotIn(customer.pk, next_appointment_map(now=at(+4))) # --- هماهنگی بازدید نیست --- def test_hamahangi_never_changes_last_visit(self): customer = self._customer('هماهنگی بر بازدید بی‌اثر') self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0)) self.assertIsNone(last_visit_dt(customer)) # --- هم‌ترازی صفحه و اسکنر --- def test_scanner_also_skips_customer_with_appointment(self): from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue from admin_panel.sms.scanner import scan_visit customer = self._customer('قرار + اسکنر') PhoneNumber.objects.create(customer=customer, phone_number='09120000999') config = SMSProviderConfig.load() config.allow_backlog = True config.backlog_max = 50 config.scan_since = at(-2000) config.save() self._add(customer, 'bazdid', 'waite', at(+5)) now = at(0) scan_visit(timezone.localdate(), now=now) self.assertFalse(SMSQueue.objects.filter(customer=customer).exists()) class LastVisitMapEquivalenceTests(TestCase): """نسخهٔ SQL ``last_visit_map`` باید دقیقاً همان خروجی نسخهٔ پایتونی را بدهد.""" NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc) def setUp(self): # رکوردهای مرزی که قاعده را به چالش می‌کشند. cases = [ # (action, finish) هر دو در گذشته ⇒ action ملاک است. (self.NOW - timedelta(days=30), self.NOW - timedelta(days=31)), # action در آینده ⇒ finish ملاک است. (self.NOW + timedelta(days=10), self.NOW - timedelta(days=2)), # هر دو در آینده ⇒ هیچ تاریخ معتبری ندارد. (self.NOW + timedelta(days=10), self.NOW + timedelta(days=11)), # فقط action. (self.NOW - timedelta(days=5), None), # فقط finish. (None, self.NOW - timedelta(days=4)), # هیچ‌کدام. (None, None), # دقیقاً برابر «حالا». (self.NOW, self.NOW), ] for index, (action_dt, finish_dt) in enumerate(cases): customer = make_customer(f'همسانی {index}') Action.objects.create( customer=customer, actionType='bazdid', status='finish', actionDateTime=action_dt, finishDateTime=finish_dt, ) def test_sql_matches_python_reference(self): from admin_panel.visit_policy import _last_visit_map_python py = _last_visit_map_python(now=self.NOW) sql = last_visit_map(now=self.NOW) self.assertEqual(py, sql) def test_sql_matches_reference_for_other_clocks(self): from admin_panel.visit_policy import _last_visit_map_python for delta in (timedelta(days=-365), timedelta(days=1), timedelta(days=400)): now = self.NOW + delta self.assertEqual( _last_visit_map_python(now=now), last_visit_map(now=now), msg=f'clock shift {delta}', ) def test_timezone_cutoff_is_utc_not_local(self): """مرز زمانی نباید با منطقهٔ محلی جابه‌جا شود (باگ ۳:۳۰ ساعتهٔ تهران).""" customer = make_customer('مرز زمانی') # رکوردی که ۵ دقیقه بعد از «حالا» است ⇒ نباید حساب شود. Action.objects.create( customer=customer, actionType='bazdid', status='finish', actionDateTime=self.NOW + timedelta(minutes=5), finishDateTime=self.NOW + timedelta(minutes=5), ) self.assertNotIn(customer.pk, last_visit_map(now=self.NOW)) class NextAppointmentEquivalenceTests(TestCase): """نسخهٔ SQL ``next_appointment_map`` باید همان نگاشت مرجع باشد.""" NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc) def test_matches_reference(self): from admin_panel.visit_policy import next_appointment_map customer_a = make_customer('قرار الف') customer_b = make_customer('قرار ب') customer_c = make_customer('قرار ج') Action.objects.create(customer=customer_a, actionType='bazdid', status='waite', actionDateTime=self.NOW + timedelta(days=5)) Action.objects.create(customer=customer_a, actionType='bazdid', status='waite', actionDateTime=self.NOW + timedelta(days=9)) Action.objects.create(customer=customer_b, actionType='hamahangi', status='finish', actionDateTime=self.NOW + timedelta(days=3), finishDateTime=self.NOW) Action.objects.create(customer=customer_c, actionType='bazdid', status='waite', actionDateTime=self.NOW - timedelta(days=5)) result = next_appointment_map(now=self.NOW) self.assertEqual(result[customer_a.pk], self.NOW + timedelta(days=5)) self.assertEqual(result[customer_b.pk], self.NOW + timedelta(days=3)) self.assertNotIn(customer_c.pk, result) class VisitHoldTests(TestCase): """«قرار بعدی» (تعویق از فرم هماهنگی): هستهٔ رفع باگ + ریسک‌های شناسایی‌شده.""" def setUp(self): from tests.factories import make_user self.user = make_user('holdExpert', superuser=True) self.client.force_login(self.user) # ---------- helpers ---------- def _customer(self, name): customer = make_customer(name) Customer.objects.filter(pk=customer.pk).update( installDate=timezone.localdate() - timedelta(days=400) ) customer.refresh_from_db() return customer def _entezar(self, view=''): url = '/panel/entezar/' + (f'?view={view}' if view else '') response = self.client.get(url) self.assertEqual(response.status_code, 200) return response def _ids(self, view=''): return {c.id for c in self._entezar(view).context['customers']} def _post_action(self, customer, **payload): base = {'desc': 'تست', 'userID': self.user.id} base.update(payload) for key in [k for k, v in list(base.items()) if v is None]: base.pop(key) return self.client.post( reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.pk}), base, ) def _ms(self, moment): return str(int(moment.timestamp() * 1000)) # ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ---------- def test_hold_until_today_hides_customer_until_end_of_day(self): customer = self._customer('قرار امروز') self._post_action( customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()), is_done='on', visitHoldUntil=self._ms(timezone.now()), ) customer.refresh_from_db() self.assertIsNotNone(customer.visit_hold_until) local = customer.visit_hold_until.astimezone(timezone.get_current_timezone()) self.assertEqual((local.hour, local.minute), (23, 59)) self.assertNotIn(customer.pk, self._ids()) # ---------- ریسک ۲: finish بدون تاریخ قرار ⇒ پیش‌فرض ۱۴ روز ---------- def test_finished_coordination_without_hold_defaults_to_two_weeks(self): customer = self._customer('هماهنگی بدون تاریخ قرار') self._post_action( customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()), is_done='on', ) customer.refresh_from_db() self.assertIsNotNone(customer.visit_hold_until) days = (customer.visit_hold_until.date() - timezone.localdate()).days self.assertEqual(days, 14) self.assertNotIn(customer.pk, self._ids()) # ---------- ریسک ۳: حذف/لغو هماهنگی ⇒ قرار بی‌اثر ---------- def test_hold_becomes_inert_when_coordination_is_deleted(self): customer = self._customer('حذف هماهنگی') self._post_action( customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now() + timedelta(days=10)), is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=10)), ) self.assertNotIn(customer.pk, self._ids()) Action.objects.filter(customer=customer).delete() customer.refresh_from_db() self.assertIsNotNone(customer.visit_hold_until) # ستون باقی است… self.assertIn(customer.pk, self._ids()) # …ولی بی‌اثر شده است def test_hold_becomes_inert_when_coordination_is_cancelled(self): customer = self._customer('لغو هماهنگی') action = Action.objects.create( customer=customer, actionType='hamahangi', status='finish', actionDateTime=timezone.now() + timedelta(days=10), finishDateTime=timezone.now(), ) set_visit_hold(customer, timezone.now() + timedelta(days=10)) self.assertNotIn(customer.pk, self._ids()) Action.objects.filter(pk=action.pk).update(status='cancell') self.assertIn(customer.pk, self._ids()) # ---------- ریسک ۴: قاعدهٔ «فقط رو به جلو» ---------- def test_second_coordination_does_not_pull_hold_backwards(self): customer = self._customer('قرار قبلی دورتر') set_visit_hold(customer, timezone.now() + timedelta(days=30)) written = set_visit_hold(customer, timezone.now() + timedelta(days=5)) self.assertFalse(written) customer.refresh_from_db() self.assertEqual( customer.visit_hold_until.date(), (timezone.now() + timedelta(days=30)).date(), ) def test_second_coordination_can_extend_hold(self): customer = self._customer('تمدید قرار') set_visit_hold(customer, timezone.now() + timedelta(days=5)) written = set_visit_hold(customer, timezone.now() + timedelta(days=20)) self.assertTrue(written) customer.refresh_from_db() self.assertEqual( customer.visit_hold_until.date(), (timezone.now() + timedelta(days=20)).date(), ) # ---------- سقف ۶ ماه و تاریخ گذشته (اعتبارسنجی سرور) ---------- def test_hold_beyond_six_months_is_rejected(self): customer = self._customer('تعویق بلند') response = self._post_action( customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()), is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=200)), ) self.assertEqual(response.status_code, 302) self.assertFalse(Action.objects.filter(customer=customer).exists()) customer.refresh_from_db() self.assertIsNone(customer.visit_hold_until) def test_hold_in_the_past_is_rejected(self): with self.assertRaises(ValidationError): normalize_hold_until(timezone.now() - timedelta(days=3)) def test_hold_none_returns_none(self): self.assertIsNone(normalize_hold_until(None)) # ---------- بازدید واقعی ⇒ مشتری از فهرست خارج می‌شود ---------- def test_real_visit_clears_hold_and_leaves_list(self): customer = self._customer('بازدید واقعی') Action.objects.create(customer=customer, actionType='hamahangi', status='finish', actionDateTime=timezone.now() + timedelta(days=10), finishDateTime=timezone.now()) set_visit_hold(customer, timezone.now() + timedelta(days=10)) self.assertNotIn(customer.pk, self._ids()) self._post_action( customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now()), is_done='on', FinishDateTime=self._ms(timezone.now()), result='انجام شد', ) customer.refresh_from_db() self.assertIsNotNone(customer.last_bazdid_dt) self.assertNotIn(customer.pk, self._ids('all')) # ---------- هماهنگیِ finish با تاریخ آینده حتی بدون ستون، قرار می‌سازد ---------- def test_coordination_without_hold_still_hides_via_action(self): customer = self._customer('هماهنگی بدون ستون') Action.objects.create( customer=customer, actionType='hamahangi', status='finish', actionDateTime=timezone.now() + timedelta(days=6), finishDateTime=timezone.now(), ) self.assertNotIn(customer.pk, self._ids()) # ---------- ثبت بازدید پس از هماهنگی ⇒ قرار هماهنگی منتفی می‌شود ---------- def test_visit_created_after_coordination_clears_manual_hold(self): """سناریوی گزارش‌شده: هماهنگی + قرار، سپس ثبت «بازدید». انتظار: مشتری از `view=manual` خارج شود و در `view=scheduled` دیده شود. """ customer = self._customer('بازدید بعد از هماهنگی') Action.objects.create( customer=customer, actionType='hamahangi', status='finish', # «چند دقیقه پیش تماس گرفتم» — از مرز لحظه‌ای فاصله دارد. actionDateTime=timezone.now() - timedelta(minutes=5), finishDateTime=timezone.now() - timedelta(minutes=5), ) set_visit_hold(customer, timezone.now() + timedelta(days=2)) self.assertIn(customer.pk, self._ids('manual')) # بازدید برنامه‌ریزی‌شده (بدون تیک «انجام شد»). self._post_action( customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now() + timedelta(hours=2)), desc='بازدید برنامه‌ریزی شد', ) customer.refresh_from_db() self.assertIsNone(customer.visit_hold_until, 'قرار دستی باید پاک شود') self.assertNotIn(customer.pk, self._ids('manual')) self.assertIn(customer.pk, self._ids('scheduled')) def test_visit_created_after_coordination_without_hold_still_schedules(self): customer = self._customer('بدون قرار قبلی') self._post_action( customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now() + timedelta(days=3)), desc='بازدید', ) self.assertIn(customer.pk, self._ids('scheduled')) def test_old_visit_does_not_void_newer_coordination(self): """بازدید قدیمی‌تر نباید قرار هماهنگیِ جدیدتر را باطل کند.""" customer = self._customer('هماهنگی جدیدتر') Action.objects.create( customer=customer, actionType='bazdid', status='finish', actionDateTime=timezone.now() - timedelta(days=400), finishDateTime=timezone.now() - timedelta(days=400), ) Action.objects.create( customer=customer, actionType='hamahangi', status='finish', actionDateTime=timezone.now() - timedelta(minutes=5), finishDateTime=timezone.now() - timedelta(minutes=5), ) set_visit_hold(customer, timezone.now() + timedelta(days=20)) self.assertIn(customer.pk, self._ids('manual')) def test_scheduled_visit_earlier_today_still_counts(self): """بازدیدِ برنامه‌ریزی‌شده برای «امروز» تا پایان امروز معتبر است. رگرسیون: فیلتر قبلی لحظه‌ای بود و بازدیدی که ساعتش چند دقیقه پیش گذشته بود از فهرست ناپدید می‌شد. """ customer = self._customer('بازدید امروز') Action.objects.create( customer=customer, actionType='bazdid', status='waite', actionDateTime=timezone.now() - timedelta(minutes=30), ) self.assertIn(customer.pk, self._ids('scheduled')) def test_scheduled_visit_yesterday_is_not_upcoming(self): customer = self._customer('بازدید دیروز') Action.objects.create( customer=customer, actionType='bazdid', status='waite', actionDateTime=timezone.now() - timedelta(days=1), ) self.assertNotIn(customer.pk, self._ids('scheduled')) # ---------- تفکیک چهارگروهی و فیلترها ---------- def test_groups_and_filters(self): a = self._customer('الف بدون قرار') b = self._customer('ب قرار دستی') c = self._customer('ج بازدید برنامه') d = self._customer('د هر دو') # b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد). Action.objects.create(customer=b, actionType='hamahangi', status='finish', actionDateTime=timezone.now() - timedelta(minutes=5), finishDateTime=timezone.now() - timedelta(minutes=5)) set_visit_hold(b, timezone.now() + timedelta(days=20)) Action.objects.create(customer=c, actionType='bazdid', status='waite', actionDateTime=timezone.now() + timedelta(days=7)) # d: بازدید برنامه‌ریزی‌شده (قدیمی‌تر) + قرار دستی (جدیدتر). # ترتیب مهم است: اگر بازدید *پس از* هماهنگی ثبت شود، هماهنگی منتفی # محسوب می‌شود (تست جداگانهٔ زیر). اینجا برعکس است تا گروه D پوشش داده شود. Action.objects.create(customer=d, actionType='bazdid', status='waite', actionDateTime=timezone.now() + timedelta(days=9)) Action.objects.create(customer=d, actionType='hamahangi', status='finish', actionDateTime=timezone.now() - timedelta(minutes=5), finishDateTime=timezone.now() - timedelta(minutes=5)) set_visit_hold(d, timezone.now() + timedelta(days=25)) counts = self._entezar().context['counts'] self.assertEqual(counts['uncoordinated'], 1) # a # عددِ کارت باید با فهرستی که با کلیک باز می‌شود بخواند: # view=manual گروه‌های B و D را نشان می‌دهد. self.assertEqual(counts['manual'], 2) # b + d self.assertEqual(counts['scheduled'], 2) # c + d self.assertEqual(counts['manual_only'], 1) # b self.assertEqual(counts['scheduled_only'], 1) # c self.assertEqual(counts['both'], 1) # d self.assertEqual(counts['total'], 4) self.assertEqual(counts['coordinated'], 3) self.assertEqual(self._ids(), {a.pk}) self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk}) self.assertEqual(self._ids('manual'), {b.pk, d.pk}) self.assertEqual(self._ids('scheduled'), {c.pk, d.pk}) self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk}) def test_invalid_view_falls_back_to_default(self): a = self._customer('فقط الف') self.assertEqual(self._ids('nonsense'), {a.pk}) self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated') def test_card_number_matches_filtered_list(self): """عدد هر کارت باید با تعداد ردیف‌های همان فیلتر یکی باشد. رگرسیون: کارت «قرار هماهنگی» فقط گروه B را می‌شمرد، ولی فیلتر ``view=manual`` گروه D را هم نشان می‌داد؛ کاربر عدد ۰ می‌دید و با کلیک ردیف پیدا می‌کرد. """ b = self._customer('قرار دستی تنها') Action.objects.create(customer=b, actionType='hamahangi', status='finish', actionDateTime=timezone.now() - timedelta(minutes=5), finishDateTime=timezone.now() - timedelta(minutes=5)) set_visit_hold(b, timezone.now() + timedelta(days=20)) d = self._customer('هم قرار و هم بازدید') Action.objects.create(customer=d, actionType='bazdid', status='waite', actionDateTime=timezone.now() + timedelta(days=9)) Action.objects.create(customer=d, actionType='hamahangi', status='finish', actionDateTime=timezone.now() - timedelta(minutes=5), finishDateTime=timezone.now() - timedelta(minutes=5)) set_visit_hold(d, timezone.now() + timedelta(days=25)) counts = self._entezar().context['counts'] self.assertEqual(counts['manual'], len(self._ids('manual'))) self.assertEqual(counts['scheduled'], len(self._ids('scheduled'))) self.assertEqual(counts['coordinated'], len(self._ids('coordinated'))) self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated'))) self.assertEqual(counts['total'], len(self._ids('all'))) # وقتی مشتری‌ای هر دو نوع قرار را دارد، راهنمای بالای صفحه باید # توضیح بدهد که چرا جمع دو کارت با «با قرار» برابر نیست. self.assertGreaterEqual(counts['both'], 1) self.assertIn('پس در هر دو کارت شمرده می‌شوند', self._entezar().content.decode()) def test_stats_cards_have_minimal_descriptions(self): """هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنی‌اش روشن باشد.""" html = self._entezar().content.decode() # سرِ نوار آمار + توضیح «عقب‌افتاده» self.assertIn('ez-stats-title', html) self.assertIn('ez-stats-hint', html) # پنج توضیح، یکی برای هر کارت self.assertEqual(html.count('class="ez-desc"'), 5) for phrase in ('هنوز وقت بازدید تعیین نشده', 'وقت بازدیدشان مشخص است', 'تعویق دستی از فرم هماهنگی', 'اقدام «بازدید» با تاریخ آینده'): self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست') # ---------- اسکنر پیامک هم قرار دستی را می‌بیند ---------- def test_scanner_skips_customer_with_manual_hold(self): from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue from admin_panel.sms.scanner import scan_visit customer = self._customer('قرار + اسکنر') PhoneNumber.objects.create(customer=customer, phone_number='09120000555') config = SMSProviderConfig.load() config.allow_backlog = True config.backlog_max = 50 config.scan_since = timezone.now() - timedelta(days=2000) config.save() Action.objects.create(customer=customer, actionType='hamahangi', status='finish', actionDateTime=timezone.now() + timedelta(days=15), finishDateTime=timezone.now()) set_visit_hold(customer, timezone.now() + timedelta(days=15)) scan_visit(timezone.localdate()) self.assertFalse(SMSQueue.objects.filter(customer=customer).exists()) # ---------- اجتماع منابع ---------- def test_next_appointment_is_union_of_sources(self): from admin_panel.visit_policy import next_appointment_map customer = self._customer('اجتماع منابع') now = timezone.now() Action.objects.create(customer=customer, actionType='hamahangi', status='finish', actionDateTime=now + timedelta(days=30), finishDateTime=now) set_visit_hold(customer, now + timedelta(days=20)) merged = next_appointment_map(now=now) self.assertIn(customer.pk, merged) # قرار دستی «پایان روز» است، پس مقایسه در سطح تاریخ انجام می‌شود. self.assertLessEqual(merged[customer.pk].date(), (now + timedelta(days=20)).date()) class VisitDedupCleanupTests(TestCase): """پاک‌سازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری).""" class ActionModalTemplateTests(TestCase): """قرارداد فرم «ایجاد وظیفه» (`customerActionInc.html`). این تست‌ها تضمین می‌کنند بازطراحی UI، نام فیلدها/شناسه‌هایی را که ویو انتظار دارد تغییر ندهد (چون ویو با ``request.POST.get(name)`` می‌خواند) و مودال با همان تقویم شمسی موجود کار کند. """ def setUp(self): self.user = make_user('modal_viewer') self.user.user_permissions.add(*Permission.objects.filter( content_type__app_label='admin_panel')) self.client.force_login(self.user) self.customer = make_customer('مشتری مودال') self.html = self.client.get( reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk}) ).content.decode() def test_expected_form_fields_exist(self): # این‌ها همان کلیدهایی هستند که ویو از request.POST می‌خواند. for name in ('actionType', 'dateTimeAnjam', 'visitHoldUntil', 'desc', 'userID', 'is_done', 'FinishDateTime', 'result'): self.assertIn(f'name="{name}"', self.html, f'فیلد {name} گم شده است') def test_javascript_hooks_exist(self): # JS داخل قالب و ویوهای دیگر به این شناسه‌ها و کلاس‌ها وابسته‌اند. for element_id in ('actionType', 'userID', 'checkIsDone', 'sabtBtn05', 'ActionBox', 'holdHelpToggle', 'holdHelpPanel'): self.assertIn(f'id="{element_id}"', self.html, f'شناسه {element_id} گم شده است') for cls in ('closeActionBox', 'showWhenDone', 'showWhenCoordination'): self.assertIn(cls, self.html, f'کلاس {cls} گم شده است') def test_persian_datepickers_are_used(self): for cls in ('dateTimePickerAnjam', 'dateTimePickerFinish', 'dateTimePickerHoldUntil'): self.assertIn(cls, self.html, f'تقویم {cls} گم شده است') def test_help_panel_is_collapsed_by_default(self): # پنل راهنما باید بسته باشد و با aria-expanded مدیریت شود. self.assertIn('aria-expanded="false"', self.html) self.assertIn('ax-help-panel is-hidden', self.html) self.assertIn('aria-controls="holdHelpPanel"', self.html) def test_help_is_scoped_to_coordination(self): # راهنما و فیلد تاریخ پیگیری فقط داخل بلوک «هماهنگی» هستند. for marker in ('ax-help-wrap showWhenCoordination is-hidden', 'ax-field showWhenCoordination is-hidden'): self.assertIn(marker, self.html, f'دامنهٔ نمایش {marker} اشتباه است') def test_form_still_posts_to_new_action(self): self.assertIn( reverse('admin_panel:customerNewAction', kwargs={'c_id': self.customer.pk}), self.html, ) def test_ui_classes_are_namespaced(self): # پیشوند ax- جلوی تداخل استایل با سایر صفحات را می‌گیرد. self.assertIn('ax-card', self.html) self.assertIn('ax-section', self.html) class CoordinationDoneConfirmTests(TestCase): """پیش‌فرض «انجام شده» برای هماهنگی + هشدار و تأیید پیش از ثبت. قواعد قفل‌شده: * فقط «هماهنگی» تیکِ خودکار می‌گیرد؛ سایر انواع اقدام دست‌نخورده‌اند. * پیش از ثبتِ هماهنگیِ انجام‌شده، تأیید گرفته می‌شود. * در حالت ویرایش و برای انواع دیگر، هیچ تأییدی در مسیر نیست. """ def setUp(self): self.user = make_user('confirm_viewer') self.user.user_permissions.add(*Permission.objects.filter( content_type__app_label='admin_panel')) self.client.force_login(self.user) self.customer = make_customer('مشتری تأیید هماهنگی') self.html = self.client.get( reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk}) ).content.decode() def test_warning_panel_starts_hidden(self): self.assertIn('id="holdDoneWarning"', self.html) block = self.html.split('id="holdDoneWarning"')[0].rsplit('', self.html)] self.assertEqual(len(widths), 8, 'تعداد ستون‌ها باید ۸ باشد') self.assertAlmostEqual(sum(widths), 100.0, places=1) def test_row_number_is_not_a_column(self): # شمارهٔ ردیف به چیپ کنار نام تبدیل شده، نه ستونِ جداگانه. self.assertNotIn('ردیف', self.html) self.assertIn('ez-idx', self.html) def test_header_cells_match_body_cells(self): thead = self.html.split('')[0] first_row = self.html.split('')[1].split('')[0] self.assertEqual(thead.count(''), first_row.count(']*min-width', self.html), []) def test_card_and_table_breakpoints_agree(self): # اگر مرز کارت (max-width) و جدول (min-width) یکی نباشند، محدوده‌ای # از عرض صفحه هر دو حالت را هم‌زمان می‌گیرد و چیدمان می‌شکند. cards = re.findall(r'@media \(max-width: (\d+)px\)', self.html) tables = [w for w in re.findall(r'@media \(min-width: (\d+)px\)', self.html) if w != '1024'] # 1024 مخصوص نوار آمار است self.assertTrue(cards, 'بلوک کارت موبایل پیدا نشد') self.assertIn(str(int(cards[0]) + 1), tables) def test_phone_renders_left_to_right(self): self.assertIn('ez-phone', self.html) # شمارهٔ تلفن باید در یک تکه و بدون شکست نمایش داده شود. self.assertIn('ez-nowrap', self.html) class EntezarPageControlsTests(TestCase): """کنترل‌های صفحهٔ *انتظار*: فیلتر نما، مرتب‌سازی، جست‌وجو و کارت‌های آمار. این تست‌ها رفتار کاربری را قفل می‌کنند: هر عدد کارت باید با تعداد همان نما بخواند و جست‌وجو/مرتب‌سازی نباید ردیف‌ها را از دست بدهد. """ URL = '/panel/entezar/' def setUp(self): self.user = make_user('entezar_viewer') self.user.user_permissions.add(*Permission.objects.filter( content_type__app_label='admin_panel')) self.client.force_login(self.user) def _get(self, **params): return self.client.get(self.URL, params) def _overdue_customer(self, name, install_days_ago=900, visit_days_ago=None): customer = make_customer(name) Customer.objects.filter(pk=customer.pk).update( installDate=(timezone.now() - timedelta(days=install_days_ago)).date()) if visit_days_ago is not None: make_visit(customer, action_dt=at(-visit_days_ago), finish_dt=at(-visit_days_ago)) return Customer.objects.get(pk=customer.pk) def test_default_view_is_uncoordinated(self): response = self._get() self.assertEqual(response.status_code, 200) self.assertEqual(response.context['view'], 'uncoordinated') self.assertEqual(response.context['sort'], 'oldest') def test_invalid_view_and_sort_fall_back(self): response = self._get(view='nonsense', sort='bogus') self.assertEqual(response.status_code, 200) self.assertEqual(response.context['view'], 'uncoordinated') self.assertEqual(response.context['sort'], 'oldest') def test_counts_partition_total(self): self._overdue_customer('معوق ساده', visit_days_ago=400) counts = self._get().context['counts'] self.assertEqual(counts['total'], counts['uncoordinated'] + counts['coordinated']) self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated']) def test_coordinated_customer_hidden_in_default_view(self): customer = self._overdue_customer('با قرار', visit_days_ago=400) # قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»). make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite') set_visit_hold(customer, at(+10)) default_ids = [obj.id for obj in self._get().context['customers']] self.assertNotIn(customer.id, default_ids) all_ids = [obj.id for obj in self._get(view='all').context['customers']] self.assertIn(customer.id, all_ids) manual_ids = [obj.id for obj in self._get(view='manual').context['customers']] self.assertIn(customer.id, manual_ids) def test_cancelled_coordination_returns_customer_to_list(self): customer = self._overdue_customer('هماهنگی لغو شده', visit_days_ago=400) coordination = make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite') set_visit_hold(customer, at(+10)) self.assertNotIn(customer.id, [obj.id for obj in self._get().context['customers']]) coordination.status = 'cancell' coordination.save(update_fields=['status']) self.assertIn(customer.id, [obj.id for obj in self._get().context['customers']]) def test_search_by_name_and_phone(self): customer = self._overdue_customer('علی رضایی', visit_days_ago=400) PhoneNumber.objects.create(customer=customer, title='تلفن', phone_number='09121234567') Customer.objects.filter(pk=customer.pk).update(phone1='09121234567') for field, value in (('name', 'رضایی'), ('phone', '09121234567')): response = self._get(searchVal=value, searchOn=field) self.assertEqual(response.status_code, 200) self.assertIn(customer.id, [obj.id for obj in response.context['customers']]) def test_sort_overdue_puts_biggest_delay_first(self): oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900) newer = self._overdue_customer('کمتر معوق', visit_days_ago=300) ids = [obj.id for obj in self._get(view='all', sort='overdue').context['customers']] self.assertLess(ids.index(oldest.id), ids.index(newer.id)) ids_newest = [obj.id for obj in self._get(view='all', sort='newest').context['customers']] self.assertLess(ids_newest.index(newer.id), ids_newest.index(oldest.id))