Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m57s
CI / backend-tests (pull_request) Successful in 14m44s
Task 1: _norm_flat теперь читает catalog_url_hash из уже доступного kn-API elemId вместо жёсткого None (domrf_kn.py:479) — разблокирует источник хэшей для 983k квартир при следующем kn-sweep. elemId or None корректно нормализует и отсутствие, и пустую строку. Task 2: новый scrape_kn_catalog_flats Celery task оборачивает уже смерженный scrape_catalog_batch (#2441) — batch-выборка по последнему snapshot WHERE catalog_url_hash IS NOT NULL + 30d freshness/force. Зарегистрирован в celery_app, ручной триггер POST /kn-catalog-flats. Beat-запись оставлена закомментированной — по образцу уже отключённого object-level catalog scraper: DOM.РФ WAF hard-ban на VPS IP с 2026-05-24 (см. #2443), cooldown не подтверждён. Task ships полностью dormant: beat не назначен в рантайме, admin-endpoint требует авторизации, browser- stack импортируется лениво внутри функции (не на старте воркера), живой fetch недостижим без непустого batch. code-reviewer отдельно проверил WAF-safety независимо — подтверждено. Batch наполнится непустыми строками только после того, как Task 1 задеплоится И пройдёт свежий kn-sweep — ожидаемо 0 строк первое время. Refs #2442, #2441, #2443
88 lines
4.1 KiB
Python
88 lines
4.1 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
|
||
|
||
|
||
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
|