"""Unit-тесты для нормализаторов scrapers/domrf_kn (#1208). Главный сценарий: детерминированный fallback flat_id через sha256 — НЕ рандомизированный hash(). Раньше при resume упавшего sweep (новый процесс воркера) та же квартира получала другой id → ON CONFLICT не срабатывал → дубли в одном snapshot. """ from __future__ import annotations import hashlib from app.services.scrapers.domrf_kn import _norm_flat def _expected_sha_fallback(elem: str) -> int: """Зеркало формулы из _norm_flat — для прямого сравнения в тестах.""" digest = hashlib.sha256(str(elem).encode("utf-8")).digest()[:8] return int.from_bytes(digest, "big") % (2**63 - 1) class TestFlatIdFallbackDeterministic: """flat_id fallback ОБЯЗАН быть детерминированным между процессами/перезапусками.""" def test_fallback_matches_sha256_formula(self) -> None: row = {"elemId": "abc123"} out = _norm_flat(row, region_cd=66) assert out["id"] == _expected_sha_fallback("abc123") def test_fallback_stable_across_calls(self) -> None: # Тот же elemId → тот же id. (Раньше hash() мог дать другой id в новом процессе.) row = {"elemId": "stable-elem-id"} a = _norm_flat(row, region_cd=66)["id"] b = _norm_flat(row, region_cd=66)["id"] assert a == b assert a == _expected_sha_fallback("stable-elem-id") def test_different_elems_give_different_ids(self) -> None: a = _norm_flat({"elemId": "elem-A"}, region_cd=66)["id"] b = _norm_flat({"elemId": "elem-B"}, region_cd=66)["id"] assert a != b def test_flat_id_present_wins_over_fallback(self) -> None: # Если flatId есть — используем его, fallback не активируется. row = {"flatId": 12345, "elemId": "should-be-ignored"} out = _norm_flat(row, region_cd=66) assert out["id"] == 12345 def test_no_flat_id_no_elem_id_returns_none(self) -> None: out = _norm_flat({}, region_cd=66) assert out["id"] is None def test_fallback_fits_bigint(self) -> None: # 8-байтовый digest усечён в pos-int, всегда < 2**63. out = _norm_flat({"elemId": "x" * 1000}, region_cd=66) assert out["id"] is not None assert 0 <= out["id"] < 2**63 class TestCatalogUrlHash: """catalog_url_hash ОБЯЗАН пробрасываться из elemId (#2442 Task 1). Ранее хардкодился в None → flat-catalog scraper выбирал 0 строк (WHERE catalog_url_hash IS NOT NULL). Теперь берётся из того же elemId, что и fallback flat_id, и живёт независимо от того, пришёл ли flatId. """ def test_hash_populated_from_elem_id(self) -> None: row = {"elemId": "abc-hash-123"} out = _norm_flat(row, region_cd=66) assert out["catalog_url_hash"] == "abc-hash-123" def test_hash_populated_even_when_flat_id_present(self) -> None: # flatId присутствует → fallback-ветка flat_id НЕ выполняется, но hash # всё равно должен прочитаться (регрессия, которую чинит Task 1). row = {"flatId": 999, "elemId": "hash-with-flatid"} out = _norm_flat(row, region_cd=66) assert out["id"] == 999 assert out["catalog_url_hash"] == "hash-with-flatid" def test_hash_none_when_elem_id_absent(self) -> None: out = _norm_flat({"flatId": 1}, region_cd=66) assert out["catalog_url_hash"] is None def test_hash_empty_string_normalized_to_none(self) -> None: # Пустую строку трактуем как отсутствие (не пишем "" в БД). out = _norm_flat({"flatId": 1, "elemId": ""}, region_cd=66) assert out["catalog_url_hash"] is None