fix mobile and names

This commit is contained in:
javad committed 2026-10-15 11:21:55 +03:30
1 parent e3a6d1f9d4
commit 7e5c83bea2
12 files changed
+82792 -6

No files matched your search

+476
View File
@@ -0,0 +1,476 @@
"""تست‌های قابلیت «افزودن پیشوند آقای/خانم به نام مشتریان».
همهٔ تست‌ها روی دیتابیس تستی Django اجرا می‌شوند (SQLite در حافظه) و هیچ‌گاه به
``data9/kt.sqlite3`` (دیتابیس واقعی) دست نمی‌زنند. فایل دادهٔ واقعی هم دست‌نخورده
می‌ماند؛ هر تست روی یک فایل JSON ساختگیِ موقت کار می‌کند.
"""
import hashlib
import io
import itertools
import json
import shutil
import tempfile
from datetime import timedelta
from pathlib import Path
from unittest import mock
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import connections
from django.db.models.query import QuerySet
from django.db.utils import OperationalError
from django.test import SimpleTestCase, TestCase
from django.utils import timezone
from admin_panel.models import Customer, CustomerNameFixJob
from admin_panel.name_fix import (ALGORITHM_VERSION, DatasetError,
build_name_index, decide_prefix, load_name_index,
normalize_name_key)
from admin_panel.management.commands.fix_customer_names import (LOCK_TTL,
MAX_DB_RETRIES)
from .factories import make_customer
DATASET_RECORDS = [
{"Names": "محمد", "Gender": "M"},
{"Names": "مریم", "Gender": "F"},
{"Names": "علی", "Gender": "M"},
{"Names": "علي", "Gender": "M"}, # ي عربی؛ همان کلید نرمال‌شده
{"Names": "كاظم", "Gender": "M"}, # ك عربی
{"Names": "کاظم", "Gender": "M"}, # ک فارسی (همان کلید)
{"Names": "سارا", "Gender": "F"},
{"Names": "سارا", "Gender": "F"}, # تکراری هم‌جنسیت
{"Names": "آرش", "Gender": "M"},
{"Names": "آرش", "Gender": "F"}, # تکراری متناقض ⇒ مبهم
{"Names": "نازنین", "Gender": "D"}, # جنسیت نامعتبر
{"Names": 123, "Gender": "M"}, # رکورد malformed
{"Names": "", "Gender": "M"}, # نام خالی
{"Names": "بی‌جنس"}, # کلید Gender ندارد
]
def sha256_of(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
class DatasetFixtureMixin:
"""ساخت فایل JSON موقت و پاک‌سازی آن پس از تست."""
def setUp(self):
super().setUp()
self.tmpdir = tempfile.mkdtemp(prefix="namefix-")
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
def write_dataset(self, records=DATASET_RECORDS, name="iranianNamesDataset.json"):
path = Path(self.tmpdir) / name
path.write_text(json.dumps({"RECORDS": records}, ensure_ascii=False),
encoding="utf-8")
return path
class NameIndexTests(DatasetFixtureMixin, SimpleTestCase):
"""اعتبارسنجی فایل، نرمال‌سازی و ساخت index."""
def test_load_builds_conservative_index_and_report(self):
path = self.write_dataset()
name_index = load_name_index(path)
self.assertEqual(name_index.sha256, sha256_of(path))
self.assertEqual(name_index.lookup("محمد"), "M")
self.assertEqual(name_index.lookup("مریم"), "F")
# ي/ی و ك/ک یکسان‌سازی می‌شوند.
self.assertEqual(name_index.lookup("علی"), "M")
self.assertEqual(name_index.lookup("کاظم"), "M")
# تکراری متناقض و جنسیت نامعتبر کنار گذاشته می‌شوند.
self.assertIsNone(name_index.lookup("آرش"))
self.assertIsNone(name_index.lookup("نازنین"))
rep = name_index.report
self.assertEqual(rep["records_total"], 14)
self.assertEqual(rep["rejected_malformed"], 2)
self.assertEqual(rep["rejected_empty_name"], 1)
self.assertEqual(rep["rejected_unknown_gender"], 1)
self.assertEqual(rep["usable_keys"], 5)
self.assertEqual(rep["unusable_keys"], 2)
self.assertEqual(rep["duplicate_keys"], 4)
self.assertEqual(rep["duplicate_same_gender"], 3)
self.assertEqual(rep["duplicate_conflict"], 1)
def test_normalization_unifies_arabic_and_persian_letters(self):
self.assertEqual(normalize_name_key(" علي "), "علی")
self.assertEqual(normalize_name_key("كاظم"), "کاظم")
self.assertEqual(normalize_name_key("محمد"), "محمد")
self.assertEqual(normalize_name_key(None), "")
def test_missing_records_key_is_rejected(self):
path = Path(self.tmpdir) / "bad.json"
path.write_text(json.dumps({"DATA": []}), encoding="utf-8")
with self.assertRaises(DatasetError):
load_name_index(path)
def test_records_must_be_a_list(self):
path = Path(self.tmpdir) / "bad.json"
path.write_text(json.dumps({"RECORDS": {"Names": "x"}}), encoding="utf-8")
with self.assertRaises(DatasetError):
load_name_index(path)
def test_invalid_json_is_rejected(self):
path = Path(self.tmpdir) / "bad.json"
path.write_text("{not json", encoding="utf-8")
with self.assertRaises(DatasetError):
load_name_index(path)
def test_normalization_collision_with_conflicting_gender_is_ambiguous(self):
name_index = build_name_index([
{"Names": "علي", "Gender": "M"},
{"Names": "علی", "Gender": "F"}, # پس از نرمال‌سازی همان کلید
])
self.assertIsNone(name_index.lookup("علی"))
self.assertIn("علی", name_index.ambiguous)
class DecidePrefixTests(DatasetFixtureMixin, SimpleTestCase):
"""الگوریتم تصمیم، مستقل از دیتابیس."""
def setUp(self):
super().setUp()
self.name_index = load_name_index(self.write_dataset())
def test_expected_behaviour_table(self):
cases = [
("محمد یاوری", "آقای محمد یاوری", "male"),
("مریم احمدی", "خانم مریم احمدی", "female"),
(" محمد یاوری ", "آقای محمد یاوری", "male"),
("\tمریم احمدی\n", "خانم مریم احمدی", "female"),
("یاوری", None, "single_word"),
("محمد", None, "single_word"),
("مریم", None, "single_word"),
("آقا محمد یاوری", None, "has_prefix"),
("آقای محمد یاوری", None, "has_prefix"),
("خانم مریم احمدی", None, "has_prefix"),
("نام ناشناخته احمدی", None, "unknown_name"),
("آرش محمدی", None, "ambiguous_gender"),
("نازنین کریمی", None, "ambiguous_gender"),
("علي رضایی", "آقای علي رضایی", "male"), # نام اصلی دست‌نخورده می‌ماند
("", None, "empty"),
(" ", None, "empty"),
]
for raw, expected_new, expected_reason in cases:
decision = decide_prefix(raw, self.name_index)
self.assertEqual(decision.new_name, expected_new, msg=repr(raw))
self.assertEqual(decision.reason, expected_reason, msg=repr(raw))
def test_none_value_is_handled(self):
decision = decide_prefix(None, self.name_index)
self.assertIsNone(decision.new_name)
self.assertEqual(decision.reason, "empty")
def test_internal_spaces_are_not_rewritten(self):
decision = decide_prefix("محمد یاوری", self.name_index)
self.assertEqual(decision.new_name, "آقای محمد یاوری")
# (نام اولیه، نتیجهٔ مورد انتظار) — ترتیب = ترتیب ساخت ⇒ ترتیب pk
CASES = [
("محمد یاوری", "آقای محمد یاوری"),
("مریم احمدی", "خانم مریم احمدی"),
(" محمد یاوری ", "آقای محمد یاوری"),
("یاوری", "یاوری"),
("محمد", "محمد"),
("مریم", "مریم"),
("آقا محمد یاوری", "آقا محمد یاوری"),
("آقای محمد یاوری", "آقای محمد یاوری"),
("خانم مریم احمدی", "خانم مریم احمدی"),
("نام ناشناخته احمدی", "نام ناشناخته احمدی"),
("آرش محمدی", "آرش محمدی"),
("نازنین کریمی", "نازنین کریمی"),
("علي رضایی", "آقای علي رضایی"),
(" نام ناشناخته احمدی ", " نام ناشناخته احمدی "),
]
EXPECTED_CHANGED = 4 # موارد ۱، ۲، ۳، ۱۳
EXPECTED_SINGLE_WORD = 3 # یاوری، محمد، مریم
EXPECTED_HAS_PREFIX = 3
EXPECTED_UNKNOWN = 2 # «نام ناشناخته …» دو مورد (با/بی فاصله)
EXPECTED_AMBIGUOUS = 2 # آرش، نازنین
class CommandTestMixin(DatasetFixtureMixin):
"""ساخت مشتریان نمونه + فایل دادهٔ موقت برای تست‌های فرمان."""
def setUp(self):
super().setUp()
self.json_path = self.write_dataset()
self.customers = [make_customer(name) for name, _ in CASES]
def run_command(self, **options):
options.setdefault("json_path", str(self.json_path))
options.setdefault("stdout", self._stdout())
return call_command("fix_customer_names", **options)
def _stdout(self):
return io.StringIO()
def names(self):
return list(Customer.objects.order_by("pk")
.values_list("name_family", flat=True))
def expected_names(self):
return [expected for _, expected in CASES]
def max_pk(self):
return max(c.pk for c in self.customers)
class DryRunTests(CommandTestMixin, TestCase):
"""پیش‌فرض فرمان نباید هیچ‌چیز بنویسد."""
def test_dry_run_changes_nothing(self):
out = self._stdout()
self.run_command(stdout=out)
self.assertEqual(self.names(), [name for name, _ in CASES])
self.assertEqual(CustomerNameFixJob.objects.count(), 0)
self.assertIn("dry-run", out.getvalue())
def test_dry_run_flag_conflicts_with_apply(self):
with self.assertRaises(CommandError):
self.run_command(dry_run=True, apply=True, confirm_production=True)
self.assertEqual(self.names(), [name for name, _ in CASES])
def test_dry_run_reports_expected_counts(self):
out = self._stdout()
self.run_command(stdout=out)
text = out.getvalue()
self.assertIn(f"مشتریان بررسی‌شده: {len(CASES)}", text)
self.assertIn(f"قابل اصلاح / اصلاح‌شده: {EXPECTED_CHANGED}", text)
self.assertIn(f"تک‌کلمه‌ای: {EXPECTED_SINGLE_WORD}", text)
self.assertIn(f"ناشناخته در JSON: {EXPECTED_UNKNOWN}", text)
self.assertIn(f"جنسیت مبهم/نامعتبر: {EXPECTED_AMBIGUOUS}", text)
# نمونهٔ قبل/بعد نمایش داده می‌شود.
self.assertIn("«محمد یاوری» → «آقای محمد یاوری»", text)
class ApplyTests(CommandTestMixin, TestCase):
"""اجرای واقعی: گارد تأیید، تغییر تنها مشتریان واجد شرایط، اتمیک بودن."""
APPLY = {"apply": True, "confirm_production": True}
def test_apply_without_confirm_production_is_refused(self):
with self.assertRaises(CommandError):
self.run_command(apply=True)
self.assertEqual(self.names(), [name for name, _ in CASES])
self.assertEqual(CustomerNameFixJob.objects.count(), 0)
def test_apply_changes_only_eligible_names(self):
self.run_command(batch_size=3, **self.APPLY)
self.assertEqual(self.names(), self.expected_names())
job = CustomerNameFixJob.objects.get()
self.assertEqual(job.status, CustomerNameFixJob.Status.COMPLETED)
self.assertEqual(job.algorithm_version, ALGORITHM_VERSION)
self.assertEqual(job.source_hash, sha256_of(self.json_path))
self.assertEqual(job.initial_max_pk, self.max_pk())
self.assertEqual(job.last_processed_pk, self.max_pk())
self.assertEqual(job.stats["scanned"], len(CASES))
self.assertEqual(job.stats["changed"], EXPECTED_CHANGED)
self.assertEqual(job.stats["single_word"], EXPECTED_SINGLE_WORD)
self.assertEqual(job.stats["has_prefix"], EXPECTED_HAS_PREFIX)
self.assertEqual(job.stats["unknown_name"], EXPECTED_UNKNOWN)
self.assertEqual(job.stats["ambiguous_gender"], EXPECTED_AMBIGUOUS)
self.assertEqual(job.stats["batches"], 5) # ۱۴ مشتری، batch=3
def test_other_customer_fields_and_json_file_remain_untouched(self):
target = self.customers[0]
Customer.objects.filter(pk=target.pk).update(
address="تهران، خیابان تست", phone1="09120000000",
notes="یادداشت مهم", is_active=False)
before_hash = sha256_of(self.json_path)
self.run_command(**self.APPLY)
target.refresh_from_db()
self.assertEqual(target.name_family, "آقای محمد یاوری")
self.assertEqual(target.address, "تهران، خیابان تست")
self.assertEqual(target.phone1, "09120000000")
self.assertEqual(target.notes, "یادداشت مهم")
self.assertFalse(target.is_active)
self.assertEqual(sha256_of(self.json_path), before_hash)
def test_customer_model_has_no_gender_field(self):
# فقط فیلدهای واقعی ستون‌دار مدل (بدون روابط معکوس).
field_names = {f.name for f in Customer._meta.concrete_fields}
self.assertEqual(field_names, {
"id", "name_family", "address", "notes", "phone1", "phone2",
"phone3", "idCode", "installDate", "create_date", "last_bazdid_dt",
"laghv_bazdid", "is_active", "sms_primary_phones",
})
self.assertNotIn("gender", field_names)
def test_rerun_of_completed_job_requires_new_job(self):
self.run_command(**self.APPLY)
with self.assertRaises(CommandError):
self.run_command(**self.APPLY)
self.assertEqual(self.names(), self.expected_names())
self.assertEqual(CustomerNameFixJob.objects.count(), 1)
def test_new_job_rerun_is_idempotent(self):
self.run_command(**self.APPLY)
self.run_command(new_job=True, **self.APPLY)
# پیشوندهای موجود تکرار نشده‌اند.
self.assertEqual(self.names(), self.expected_names())
self.assertEqual(CustomerNameFixJob.objects.count(), 2)
self.assertEqual(CustomerNameFixJob.objects.latest("pk").stats["changed"], 0)
def test_missing_json_path_is_reported(self):
with self.assertRaises(CommandError):
self.run_command(json_path=str(Path(self.tmpdir) / "nope.json"),
apply=True, confirm_production=True)
self.assertEqual(self.names(), [name for name, _ in CASES])
def test_invalid_dataset_aborts_before_any_change(self):
bad = Path(self.tmpdir) / "bad.json"
bad.write_text("{oops", encoding="utf-8")
with self.assertRaises(CommandError):
self.run_command(json_path=str(bad), apply=True, confirm_production=True)
self.assertEqual(self.names(), [name for name, _ in CASES])
self.assertEqual(CustomerNameFixJob.objects.count(), 0)
def test_missing_job_table_is_reported(self):
with mock.patch.object(connections["default"].introspection,
"table_names", return_value=[]):
with self.assertRaises(CommandError):
self.run_command(**self.APPLY)
self.assertEqual(self.names(), [name for name, _ in CASES])
class ResilienceTests(CommandTestMixin, TestCase):
"""قفل، بازیابی پس از crash، hash، اتمیک بودن و retry قفل دیتابیس."""
APPLY = {"apply": True, "confirm_production": True}
def _job(self, **overrides):
values = {
"source_hash": sha256_of(self.json_path),
"algorithm_version": ALGORITHM_VERSION,
"status": CustomerNameFixJob.Status.PAUSED,
"batch_size": 3,
"initial_max_pk": self.max_pk(),
"last_processed_pk": 0,
"stats": {},
"heartbeat_at": timezone.now(),
}
values.update(overrides)
return CustomerNameFixJob.objects.create(**values)
def test_concurrent_run_is_blocked(self):
self._job(status=CustomerNameFixJob.Status.RUNNING,
heartbeat_at=timezone.now())
with self.assertRaises(CommandError):
self.run_command(**self.APPLY)
self.assertEqual(self.names(), [name for name, _ in CASES])
def test_stale_running_lock_is_recovered_and_resumed(self):
stale = self._job(status=CustomerNameFixJob.Status.RUNNING,
heartbeat_at=timezone.now() - LOCK_TTL - timedelta(minutes=1))
self.run_command(**self.APPLY)
stale.refresh_from_db()
self.assertEqual(stale.status, CustomerNameFixJob.Status.COMPLETED)
self.assertEqual(self.names(), self.expected_names())
def test_resume_continues_from_checkpoint(self):
first_pk = self.customers[0].pk
job = self._job(last_processed_pk=first_pk)
self.run_command(resume=job.pk, **self.APPLY)
names = self.names()
# مشتری قبل از checkpoint دست‌نخورده می‌ماند
self.assertEqual(names[0], "محمد یاوری")
self.assertEqual(names[1:], self.expected_names()[1:])
job.refresh_from_db()
self.assertEqual(job.status, CustomerNameFixJob.Status.COMPLETED)
self.assertEqual(job.last_processed_pk, self.max_pk())
self.assertEqual(job.stats["scanned"], len(CASES) - 1)
def test_resume_with_different_hash_is_refused(self):
job = self._job(source_hash="0" * 64)
with self.assertRaises(CommandError):
self.run_command(resume=job.pk, **self.APPLY)
self.assertEqual(self.names(), [name for name, _ in CASES])
job.refresh_from_db()
self.assertEqual(job.status, CustomerNameFixJob.Status.PAUSED)
def test_unknown_resume_id_is_refused(self):
with self.assertRaises(CommandError):
self.run_command(resume=999999, **self.APPLY)
self.assertEqual(self.names(), [name for name, _ in CASES])
def test_batch_failure_rolls_back_names_and_checkpoint_atomically(self):
original = QuerySet.bulk_update
calls = itertools.count(1)
def flaky(self, objs, fields, batch_size=None):
if next(calls) == 2:
raise RuntimeError("boom")
return original(self, objs, fields, batch_size)
with mock.patch.object(QuerySet, "bulk_update", flaky):
with self.assertRaises(CommandError):
self.run_command(batch_size=1, **self.APPLY)
names = self.names()
# batch اول (مشتری ۱) commit شده، batch دوم (مشتری ۲) rollback شده است.
self.assertEqual(names[0], "آقای محمد یاوری")
self.assertEqual(names[1], "مریم احمدی")
job = CustomerNameFixJob.objects.get()
self.assertEqual(job.status, CustomerNameFixJob.Status.FAILED)
# checkpoint دقیقاً روی پایان batch اول است ⇒ ادامه بدون تکرار/پرت شدن داده.
self.assertEqual(job.last_processed_pk, self.customers[0].pk)
def test_locked_database_is_retried_then_succeeds(self):
original = QuerySet.bulk_update
calls = itertools.count(1)
def flaky(self, objs, fields, batch_size=None):
if next(calls) == 1:
raise OperationalError("database is locked")
return original(self, objs, fields, batch_size)
with mock.patch.object(QuerySet, "bulk_update", flaky), \
mock.patch("admin_panel.management.commands.fix_customer_names.time.sleep"):
self.run_command(**self.APPLY)
self.assertEqual(self.names(), self.expected_names())
self.assertEqual(CustomerNameFixJob.objects.get().status,
CustomerNameFixJob.Status.COMPLETED)
def test_retry_is_bounded_and_surfaces_error(self):
calls = itertools.count(1)
attempts = []
def always_locked(self, objs, fields, batch_size=None):
attempts.append(next(calls))
raise OperationalError("database is locked")
with mock.patch.object(QuerySet, "bulk_update", always_locked), \
mock.patch("admin_panel.management.commands.fix_customer_names.time.sleep"):
with self.assertRaises(CommandError):
self.run_command(**self.APPLY)
self.assertEqual(len(attempts), MAX_DB_RETRIES) # retry نامحدود نیست
self.assertEqual(self.names(), [name for name, _ in CASES])
self.assertEqual(CustomerNameFixJob.objects.get().status,
CustomerNameFixJob.Status.FAILED)
def test_batches_cover_all_customers_across_sizes(self):
for batch_size in (1, 2, 5, 200):
with self.subTest(batch_size=batch_size):
CustomerNameFixJob.objects.all().delete()
for customer, (name, _) in zip(self.customers, CASES):
Customer.objects.filter(pk=customer.pk).update(name_family=name)
self.run_command(batch_size=batch_size, new_job=True, **self.APPLY)
self.assertEqual(self.names(), self.expected_names())
+383
View File
@@ -0,0 +1,383 @@
"""تست‌های دستور ``normalize_customer_sms_phones``.
همهٔ تست‌ها روی دیتابیس تستی Django اجرا می‌شوند؛ نه به ``data9/kt.sqlite3`` دست
می‌زنند و نه فایل وضعیت داخل ریشهٔ پروژه می‌نویسند (مسیر وضعیت موقت است).
"""
import io
import json
import shutil
import tempfile
from pathlib import Path
from unittest import mock
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db.models.query import QuerySet
from django.test import TestCase
from admin_panel.models import Customer, PhoneNumber
from admin_panel.management.commands.normalize_customer_sms_phones import (
FIELD_MAX_LENGTH, digit_string, is_landline)
from admin_panel.sms.recipients import resolve_recipients
from .factories import make_customer
def make_phone(customer, number, title='تلفن'):
return PhoneNumber.objects.create(
customer=customer, title=title, phone_number=number)
class PureHelperTests(TestCase):
"""توابع کمکی اعتبارسنجی/تفکیک (بدون دیتابیس واقعی)."""
def test_digit_string_converts_persian_and_arabic_digits(self):
self.assertEqual(digit_string('۰۹۱۲-۳۴۵'), '0912345')
self.assertEqual(digit_string('٠٩١٢ ٣٤٥'), '0912345')
self.assertEqual(digit_string('(0912) 345'), '0912345')
self.assertEqual(digit_string(None), '')
self.assertEqual(digit_string(' '), '')
def test_landline_detection(self):
for raw in ('02112345678', '۰۲۱۱۲۳۴۵۶۷۸', '031-33445566', '05138490000',
'57387608', '57473005'): # ۸رقمی = ثابت محلی
self.assertTrue(is_landline(raw), msg=raw)
for raw in ('09123456789', '9123456789', '12345', '', None,
'+989123456789', '0012345678'):
self.assertFalse(is_landline(raw), msg=repr(raw))
class CommandTestCase(TestCase):
"""زیرساخت مشترک: دیتابیس تستی + فایل وضعیت موقت."""
def setUp(self):
super().setUp()
self.tmpdir = tempfile.mkdtemp(prefix='smsnorm-')
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
self.checkpoint = Path(self.tmpdir) / '.sms_normalization_checkpoint.json'
def run_cmd(self, **options):
options.setdefault('checkpoint', str(self.checkpoint))
options.setdefault('stdout', io.StringIO())
call_command('normalize_customer_sms_phones', **options)
return options['stdout'].getvalue()
def stored(self):
return dict(Customer.objects.values_list('id', 'sms_primary_phones'))
class DryRunTests(CommandTestCase):
"""پیش‌فرض: هیچ دیتایی در دیتابیس یا فایل وضعیت نوشته نمی‌شود."""
def test_dry_run_writes_nothing(self):
customer = make_customer('خشکی')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', sms_primary_phones='+989120000009')
make_phone(customer, '09120000002')
out = self.run_cmd()
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '+989120000009')
self.assertFalse(self.checkpoint.exists())
self.assertIn('dry-run', out)
self.assertIn('قابل‌به‌روزرسانی', out)
def test_dry_run_reports_counts(self):
customer = make_customer('گزارش')
Customer.objects.filter(pk=customer.pk).update(phone1='09120000001')
PhoneNumber.objects.create(
customer=customer, title='t', phone_number='02112345678')
out = self.run_cmd()
self.assertIn('مشتریان بررسی‌شده: 1', out)
self.assertIn('شماره‌های فیلترشده: ثابت=1', out)
def test_dry_run_flag_conflicts_with_apply(self):
customer = make_customer('تناقض فلگ')
with self.assertRaises(CommandError):
self.run_cmd(dry_run=True, apply=True)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '')
def test_dry_run_ignores_reset_checkpoint(self):
self.checkpoint.write_text(json.dumps({'last_id': 5}), encoding='utf-8')
self.run_cmd(reset_checkpoint=True)
self.assertTrue(self.checkpoint.exists())
class ApplyTests(CommandTestCase):
"""اجرای واقعی: تجمیع، حذف تکراری، فیلتر نامعتبر، ایمنی داده."""
def test_aggregates_all_sources_in_priority_order(self):
customer = make_customer('چند منبع')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', phone2='09120000002', phone3='09120000003')
PhoneNumber.objects.create(customer=customer, title='a', phone_number='09120000004')
PhoneNumber.objects.create(customer=customer, title='b', phone_number='09120000005')
self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(
customer.sms_primary_phones,
'+989120000001,+989120000002,+989120000003,+989120000004,+989120000005',
)
def test_dedupes_preserving_priority_order(self):
customer = make_customer('تکراری')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', phone2='989120000001') # همان شماره
PhoneNumber.objects.create(
customer=customer, title='a', phone_number='+98 912 000 0002')
PhoneNumber.objects.create(
customer=customer, title='b', phone_number='۰۹۱۲۰۰۰۰۰۰۲') # تکراری
out = self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones,
'+989120000001,+989120000002')
self.assertIn('شماره‌های تکراری حذف‌شده: 2', out)
def test_converts_international_and_local_variants(self):
customer = make_customer('قالب‌ها')
Customer.objects.filter(pk=customer.pk).update(phone1='+989120000001')
PhoneNumber.objects.create(customer=customer, title='a', phone_number='00989120000002')
PhoneNumber.objects.create(customer=customer, title='b', phone_number='989120000003')
PhoneNumber.objects.create(customer=customer, title='c', phone_number='9120000004')
PhoneNumber.objects.create(customer=customer, title='d', phone_number='0912-000-0005')
self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(
customer.sms_primary_phones,
'+989120000001,+989120000002,+989120000003,+989120000004,+989120000005',
)
def test_filters_landlines_and_invalid_values(self):
customer = make_customer('نامعتبر')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', phone2='02112345678', phone3='0')
PhoneNumber.objects.create(customer=customer, title='a', phone_number='57387608')
PhoneNumber.objects.create(customer=customer, title='b', phone_number='اعظمی')
PhoneNumber.objects.create(customer=customer, title='c', phone_number='')
out = self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '+989120000001')
self.assertIn('ثابت=2', out) # 02112345678 + 57387608
self.assertIn('نامعتبر=2', out) # '0' + 'اعظمی' (ردیف خالی شمرده نمی‌شود)
def test_source_fields_and_phone_rows_are_untouched(self):
customer = make_customer('ایزوله', address='تهران')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', phone2='02112345678')
row = make_phone(customer, '09120000002', title='موبایل')
self.run_cmd(apply=True)
customer.refresh_from_db()
row.refresh_from_db()
self.assertEqual(customer.phone1, '09120000001')
self.assertEqual(customer.phone2, '02112345678')
self.assertEqual(customer.name_family, 'ایزوله')
self.assertEqual(customer.address, 'تهران')
self.assertEqual(row.phone_number, '09120000002')
self.assertEqual(row.title, 'موبایل')
self.assertEqual(PhoneNumber.objects.filter(customer=customer).count(), 1)
def test_unchanged_rows_are_not_rewritten(self):
customer = make_customer('بدون تغییر')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', sms_primary_phones='+989120000001')
writes = []
original = QuerySet.bulk_update
def spy(self, objs, fields, batch_size=None):
writes.append(len(objs))
return original(self, objs, fields, batch_size)
with mock.patch.object(QuerySet, 'bulk_update', spy):
out = self.run_cmd(apply=True)
self.assertEqual(writes, []) # هیچ نوشتنی رخ نداد
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '+989120000001')
self.assertIn('بدون تغییر: 1', out)
def test_empty_result_never_erases_existing_value(self):
customer = make_customer('محافظت')
Customer.objects.filter(pk=customer.pk).update(
phone1='02112345678', sms_primary_phones='+989120000009')
out = self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '+989120000009')
self.assertIn('محافظت شد', out)
def test_idempotent_second_run_updates_nothing(self):
customer = make_customer('تکرار اجرا')
Customer.objects.filter(pk=customer.pk).update(phone1='09120000001')
self.run_cmd(apply=True)
first = self.stored()
out = self.run_cmd(apply=True, reset_checkpoint=True)
self.assertEqual(self.stored(), first)
self.assertIn('مشتریان به‌روزشده: 0', out)
class OverflowTests(CommandTestCase):
"""سرریز طول: truncate نمی‌شود، رکورد دست‌نخورده می‌ماند و در گزارش می‌آید."""
def test_overflow_leaves_customer_untouched(self):
numbers = ['09120000%03d' % i for i in range(1, 11)] # 10×13 = 139 > 100
customer = make_customer('سرریز')
Customer.objects.filter(pk=customer.pk).update(
phone1=numbers[0], phone2=numbers[1])
for number in numbers[2:]:
PhoneNumber.objects.create(customer=customer, title='t', phone_number=number)
out = self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(customer.sms_primary_phones, '')
self.assertIn(f'سرریز طول (>{FIELD_MAX_LENGTH} کاراکتر، دست‌نخورده): 1', out)
self.assertIn(f'شناسه‌های سرریز: {customer.id}', out)
def test_exactly_under_max_length_is_saved(self):
# 7 شماره × 13 کاراکتر + 6 کاما = 97 ≤ 100
numbers = ['09120000%03d' % i for i in range(1, 8)]
customer = make_customer('لبه طول')
Customer.objects.filter(pk=customer.pk).update(phone1=numbers[0])
for number in numbers[1:]:
PhoneNumber.objects.create(customer=customer, title='t', phone_number=number)
out = self.run_cmd(apply=True)
customer.refresh_from_db()
self.assertEqual(len(customer.sms_primary_phones), 97)
self.assertEqual(customer.sms_primary_phones.count(','), 6)
self.assertIn('سرریز طول', out)
class BatchAndCheckpointTests(CommandTestCase):
"""keyset pagination، batch اتمیک، و بازیابی از فایل وضعیت."""
def _customers_with_phone(self, count):
pks = []
for i in range(count):
customer = make_customer(f'مشتری {i}')
Customer.objects.filter(pk=customer.pk).update(phone1='09120%06d' % (i + 1))
pks.append(customer.pk)
return pks
def test_all_customers_covered_with_small_batches(self):
pks = self._customers_with_phone(7)
out = self.run_cmd(apply=True, batch_size=2)
self.assertEqual(
Customer.objects.filter(pk__in=pks).exclude(sms_primary_phones='').count(), 7)
self.assertIn('batchهای تکمیل‌شده: 4', out) # ۷ مشتری / ۲ = ۴ batch
def test_checkpoint_written_after_each_batch(self):
self._customers_with_phone(5)
self.run_cmd(apply=True, batch_size=2)
data = json.loads(self.checkpoint.read_text(encoding='utf-8'))
self.assertEqual(data['last_id'], Customer.objects.order_by('-id').first().pk)
self.assertEqual(data['batches'], 3)
self.assertTrue(data['applied'])
def test_resume_continues_from_checkpoint(self):
pks = self._customers_with_phone(4)
self.checkpoint.write_text(json.dumps({'last_id': pks[1]}), encoding='utf-8')
self.run_cmd(apply=True, resume=True)
self.assertEqual(Customer.objects.get(pk=pks[0]).sms_primary_phones, '')
self.assertEqual(
Customer.objects.filter(pk__in=pks[2:]).exclude(sms_primary_phones='').count(), 2)
def test_resume_without_checkpoint_fails(self):
with self.assertRaises(CommandError):
self.run_cmd(apply=True, resume=True)
def test_resume_with_invalid_checkpoint_fails(self):
self.checkpoint.write_text('{not json', encoding='utf-8')
with self.assertRaises(CommandError):
self.run_cmd(apply=True, resume=True)
def test_start_id_takes_precedence(self):
pks = self._customers_with_phone(4)
self.checkpoint.write_text(json.dumps({'last_id': 0}), encoding='utf-8')
self.run_cmd(apply=True, resume=True, start_id=pks[2])
self.assertEqual(Customer.objects.get(pk=pks[0]).sms_primary_phones, '')
self.assertEqual(Customer.objects.get(pk=pks[3]).sms_primary_phones,
'+989120000004')
def test_reset_checkpoint_restarts_from_beginning(self):
self._customers_with_phone(3)
self.run_cmd(apply=True, batch_size=1)
self.checkpoint.write_text(json.dumps({'last_id': 10**9}), encoding='utf-8')
self.run_cmd(apply=True, reset_checkpoint=True, batch_size=1)
data = json.loads(self.checkpoint.read_text(encoding='utf-8'))
self.assertEqual(data['batches'], 3) # از ابتدا شروع شد
def test_batch_failure_keeps_previous_batches_and_checkpoint(self):
pks = self._customers_with_phone(3)
original = QuerySet.bulk_update
calls = iter([1, 2])
def flaky(self, objs, fields, batch_size=None):
if next(calls, 2) == 2:
raise RuntimeError('boom')
return original(self, objs, fields, batch_size)
with mock.patch.object(QuerySet, 'bulk_update', flaky):
with self.assertRaises(CommandError):
self.run_cmd(apply=True, batch_size=1)
# batch اول commit شده، batch دوم rollback شده، مشتری سوم دست‌نخورده
self.assertEqual(Customer.objects.get(pk=pks[0]).sms_primary_phones,
'+989120000001')
self.assertEqual(Customer.objects.get(pk=pks[1]).sms_primary_phones, '')
self.assertEqual(Customer.objects.get(pk=pks[2]).sms_primary_phones, '')
data = json.loads(self.checkpoint.read_text(encoding='utf-8'))
self.assertEqual(data['last_id'], pks[0])
def test_resume_after_failure_completes_remaining(self):
pks = self._customers_with_phone(3)
original = QuerySet.bulk_update
calls = iter([1, 2])
def flaky(self, objs, fields, batch_size=None):
if next(calls, 2) == 2:
raise RuntimeError('boom')
return original(self, objs, fields, batch_size)
with mock.patch.object(QuerySet, 'bulk_update', flaky):
with self.assertRaises(CommandError):
self.run_cmd(apply=True, batch_size=1)
self.run_cmd(apply=True, resume=True, batch_size=1)
for pk in pks:
self.assertNotEqual(Customer.objects.get(pk=pk).sms_primary_phones, '')
class IntegrationAndEfficiencyTests(CommandTestCase):
"""سازگاری با مسیر پیامک موجود + جلوگیری از N+1."""
def test_panel_resolution_reads_back_aggregated_numbers(self):
customer = make_customer('گیرنده')
Customer.objects.filter(pk=customer.pk).update(
phone1='09120000001', phone2='09120000002')
self.run_cmd(apply=True)
customer.refresh_from_db()
# قالب ذخیره‌شده باید همان چیزی باشد که مسیر پیامک انتظار دارد.
self.assertEqual(resolve_recipients(customer),
['+989120000001', '+989120000002'])
def test_query_count_is_constant_per_batch(self):
for i in range(3):
customer = make_customer(f'کوئری {i}')
Customer.objects.filter(pk=customer.pk).update(phone1='09120%06d' % (i + 1))
for j in range(5):
PhoneNumber.objects.create(
customer=customer, title='t',
phone_number='09130%06d' % (i * 10 + j))
with self.assertNumQueries(3): # ۱ کوئری سقف دامنه + ۱ مشتریان + ۱ prefetch
self.run_cmd()