Files
khatibi/tests/test_visit_policy.py
T
2026-10-21 10:50:42 +03:30

1339 lines
65 KiB
Python

"""تست‌های «آخرین بازدید» (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_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(), finishDateTime=timezone.now())
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: هم قرار دستی و هم بازدید برنامه‌ریزی‌شده.
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
actionDateTime=timezone.now(), finishDateTime=timezone.now())
set_visit_hold(d, timezone.now() + timedelta(days=25))
Action.objects.create(customer=d, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=9))
counts = self._entezar().context['counts']
self.assertEqual(counts['uncoordinated'], 1) # a
self.assertEqual(counts['manual'], 1) # b
self.assertEqual(counts['scheduled'], 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_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('<div', 1)[-1]
self.assertIn('is-hidden', block, 'پنل هشدار نباید از ابتدا باز باشد')
def test_warning_text_is_present(self):
self.assertIn('آیا مطمئن هستی که هماهنگی را انجام دادی؟', self.html)
self.assertIn('اگر هنوز هماهنگی نکردی، این تیک را بردار.', self.html)
def test_confirm_buttons_exist(self):
self.assertIn('id="holdConfirmYes"', self.html)
self.assertIn('id="holdConfirmNo"', self.html)
def test_autocheck_only_for_coordination(self):
# تیک خودکار باید داخل شاخهٔ hamahangi باشد و برای بقیه برداشته شود.
block = self.html.split("$(\"select#actionType\").on('change'", 1)[1]
block = block.split("});", 1)[0]
self.assertIn("type === 'hamahangi'", block)
self.assertIn('holdAutoChecked = true', block)
self.assertIn('holdAutoChecked = false', block)
def test_confirmation_is_gated_before_submit(self):
block = self.html.split('$("#ActionBox").on(\'submit\'', 1)[1]
block = block.split('});', 1)[0]
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
self.assertIn('isEditMode || holdConfirmed', block)
self.assertIn("!== 'hamahangi'", block)
self.assertIn('prop("checked")', block)
self.assertIn('event.preventDefault()', block)
def test_cancelling_confirmation_unchecks(self):
block = self.html.split('$("#holdConfirmNo").click', 1)[1]
block = block.split('});', 1)[0]
self.assertIn('prop("checked", false)', block)
def test_edit_mode_skips_confirmation(self):
# با کلیک روی ردیف موجود، isEditMode روشن می‌شود ⇒ مسیر تأیید رد می‌شود.
self.assertIn('isEditMode = true', self.html)
self.assertIn('isEditMode = false', self.html)
def test_other_action_types_keep_old_done_toggle(self):
# تیک «انجام شده» همچنان فیلدهای اتمام/نتیجه را نشان می‌دهد.
self.assertIn('function syncDoneFields', self.html)
self.assertIn('showWhenDone', self.html)
def test_every_picker_selector_matches_markup(self):
"""هر کلاسی که در JS برای ساخت تقویم استفاده می‌شود باید در قالب باشد.
این تست جلوی باگ «تقویم باز نمی‌شود» را می‌گیرد: اگر نام کلاس در قالب
با سلکتور جاوااسکریپت یکی نباشد، ``pDatepicker`` روی مجموعهٔ خالی
اجرا می‌شود، ``undefined`` برمی‌گرداند و ``setDate`` با TypeError
می‌شکند — که باعث از کار افتادن بقیهٔ منطق مودال هم می‌شد.
"""
selectors = re.findall(r'\$\("\.([\w-]+)"\)\.pDatepicker\(', self.html)
self.assertTrue(selectors, 'هیچ پیکری در قالب پیدا نشد')
for selector in selectors:
self.assertRegex(
self.html,
rf'class="[^"]*\b{re.escape(selector)}\b',
f'تقویم .{selector} در JS هست ولی در قالب هیچ المانی با این کلاس نیست',
)
def test_every_alt_field_class_exists_in_markup(self):
"""کلاس ``altField`` هر پیکر باید در قالب وجود داشته باشد."""
alt_fields = re.findall(r"altField:\s*'\.([\w-]+)'", self.html)
self.assertTrue(alt_fields, 'هیچ altField ای پیدا نشد')
for selector in alt_fields:
self.assertRegex(
self.html,
rf'class="[^"]*\b{re.escape(selector)}\b',
f'altField .{selector} در قالب وجود ندارد',
)
def test_hold_picker_calls_are_guarded(self):
"""فراخوانی setDate پیکر «تاریخ پیگیری» باید فقط از مسیر محافظ‌شده باشد.
چون ``pDatepicker`` در صورت عدم تطبیق سلکتور ``undefined`` می‌دهد،
فراخوانی مستقیم ``.setDate()`` کل هندلر را می‌شکند. پس تنها فراخوانی
مجاز، همان یکی داخل ``resetHoldPicker`` است و بقیهٔ نقاط باید از آن
استفاده کنند.
"""
self.assertIn('function resetHoldPicker', self.html)
calls = re.findall(r'dateTimePickerHoldUntil\.setDate\(\)', self.html)
self.assertEqual(len(calls), 1,
'همهٔ ریست‌ها باید از resetHoldPicker عبور کنند')
# آن یک فراخوانی باید داخل تابع محافظ باشد (بعد از بررسی typeof).
func = self.html.split('function resetHoldPicker', 1)[1].split('\n }', 1)[0]
self.assertIn('typeof dateTimePickerHoldUntil.setDate === \'function\'', func)
self.assertIn('dateTimePickerHoldUntil.setDate()', func)
# نقاط استفاده باید resetHoldPicker را صدا بزنند.
self.assertGreaterEqual(self.html.count('resetHoldPicker()'), 3)
class EntezarLayoutTests(TestCase):
"""ثابت‌های چیدمان صفحهٔ انتظار (جلوگیری از رگرسیون UI).
این تست‌ها چیزهایی را قفل می‌کنند که با یک ویرایش ساده می‌شکنند و در
تست‌های منطقی دیده نمی‌شوند: تناسب ستون‌ها، حذف ستون «ردیف»، و هم‌ترازی
مرز جدول/کارت.
"""
URL = '/panel/entezar/'
def setUp(self):
self.user = make_user('layout_viewer')
self.user.user_permissions.add(*Permission.objects.filter(
content_type__app_label='admin_panel'))
self.client.force_login(self.user)
customer = make_customer('مشتری چیدمان')
Customer.objects.filter(pk=customer.pk).update(
installDate=(timezone.now() - timedelta(days=900)).date(),
phone1='09121234567', idCode='C-1',
)
make_visit(customer, action_dt=at(-400), finish_dt=at(-400))
self.html = self.client.get(self.URL, {'view': 'all'}).content.decode()
def test_column_widths_sum_to_one_hundred(self):
widths = [float(w) for w in re.findall(
r'<col style="width: (\d+(?:\.\d+)?)%">', 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('<th>ردیف</th>', self.html)
self.assertIn('ez-idx', self.html)
def test_header_cells_match_body_cells(self):
thead = self.html.split('<tbody>')[0]
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
self.assertEqual(thead.count('<th>'), first_row.count('<td'))
def test_no_fixed_min_width_on_columns(self):
# min-width روی ستون‌ها باعث اسکرول افقی و بی‌تناسبی می‌شد.
self.assertEqual(re.findall(r'<th[^>]*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))