Some checks failed
Fallback flat_id в _norm_flat использовал abs(hash(elem)) % (2**63 - 1). В CPython hash(str) РАНДОМИЗИРОВАН per-process (PYTHONHASHSEED нигде в репо не зафиксирован — uvicorn/celery не выставляют его). Эффект: - При resume упавшего sweep (resume_kn_run в новом процессе воркера) до 10 объектов после checkpoint перечитываются. Квартиры без flatId получают ДРУГОЙ hash-id → ON CONFLICT (id, snapshot_date) не срабатывает → дубли строк одной квартиры в одном snapshot. - То же при повторном прогоне за ту же дату — каждый раз новый id. - Дубли инфлируют все агрегации (units_sold/price медианы, supply_layers). Patch: - abs(hash(elem)) → int.from_bytes(sha256(elem)[:8], "big") % (2**63 - 1). - sha256 стабилен между процессами/перезапусками. 8 байт → BIGINT-fit. 6 новых юнит-тестов (test_domrf_kn_normalize.py): formula matches sha256, stable across calls, distinct elems→distinct ids, flatId wins over fallback, no-id→None, BIGINT-fit. ruff clean. Closes #1208
57 lines
2.6 KiB
Python
57 lines
2.6 KiB
Python
"""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
|