fix mobile and names
This commit is contained in:
1 parent
e3a6d1f9d4
commit
7e5c83bea2
12 files changed
+82792
-6
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user