Серия sale-share data-quality (по находкам юзера): - house_dedup_merge: cluster_key → tradein_canon_addr(COALESCE(short_address, full_address, address)) — ловит дубли с район-noise в address (Вайнера 66). - zhkh_flats_loader: cadastre-fallback (searchByAddress по cadastreNumber, guid-независимо) — резолвит дома где gar_house_guid ≠ ЖКХ houseGuid (Переходный 6: guid→0, cadastre→165). CLI --cadastre. - мигр.153: плаузибилити-гард по медиане этажности листингов — denom >= GREATEST(total_floors, median(listings.total_floors), 8); отсекает gar-недосчёт башен без ЖКХ (gar=9 vs листинги 19-26 эт → NULL). ruff clean, pytest 36 green.
186 lines
9.2 KiB
Python
186 lines
9.2 KiB
Python
"""Тесты ЖКХ-loader'а знаменателя (app/services/zhkh_flats_loader.py, мигр. 146/149).
|
||
|
||
Только ЧИСТЫЕ хелперы — БЕЗ сети и БД:
|
||
- parse_int — числовой парс полей ГИС ЖКХ.
|
||
- _build_body — полный DTO searchByAddress (15 ключей; pageIndex — query-param, НЕ в теле).
|
||
- _parse_item — извлечение guid + полей из реалистичного item ответа.
|
||
- статические asserts по _UPDATE_SQL (psycopg v3 CAST, IS DISTINCT FROM gate).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
|
||
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
|
||
# sibling-тестах задаём заглушку (ядро статично/без БД).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import zhkh_flats_loader as zfl
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# parse_int
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_parse_int_robust() -> None:
|
||
assert zfl.parse_int("429") == 429
|
||
assert zfl.parse_int("0") is None # 0 квартир — не пригодный знаменатель
|
||
assert zfl.parse_int("") is None
|
||
assert zfl.parse_int(None) is None
|
||
assert zfl.parse_int(162) == 162
|
||
assert zfl.parse_int("x") is None
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# _build_body — полный DTO
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_build_body_has_all_dto_keys_and_batch() -> None:
|
||
batch = ["g1", "g2", "g3"]
|
||
body = zfl._build_body(batch)
|
||
expected_keys = {
|
||
"regionCode",
|
||
"fiasHouseCodeList",
|
||
"estStatus",
|
||
"strStatus",
|
||
"calcCount",
|
||
"houseConditionRefList",
|
||
"houseTypeRefList",
|
||
"houseManagementTypeRefList",
|
||
"cadastreNumber",
|
||
"oktmo",
|
||
"statuses",
|
||
"regionProperty",
|
||
"municipalProperty",
|
||
"hostelTypeCodes",
|
||
"useReadOnlyDataSource",
|
||
}
|
||
assert len(expected_keys) == 15
|
||
assert set(body.keys()) == expected_keys
|
||
assert body["fiasHouseCodeList"] == batch
|
||
assert body["statuses"] == ["APPROVED"]
|
||
assert body["regionCode"] == zfl.REGION_GUID
|
||
assert body["calcCount"] is True
|
||
assert body["useReadOnlyDataSource"] is True
|
||
# pageIndex — query-параметр в ENDPOINT, его НЕТ в теле.
|
||
assert "pageIndex" not in body
|
||
assert "pageIndex=1" in zfl.ENDPOINT
|
||
assert "elementsPerPage=100" in zfl.ENDPOINT
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# _body_by_cadastre — тот же DTO, но поиск по кадастру
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_body_by_cadastre_sets_cadastre_and_nulls_fias() -> None:
|
||
body = zfl._body_by_cadastre("66:41:0604007:747")
|
||
expected_keys = {
|
||
"regionCode",
|
||
"fiasHouseCodeList",
|
||
"estStatus",
|
||
"strStatus",
|
||
"calcCount",
|
||
"houseConditionRefList",
|
||
"houseTypeRefList",
|
||
"houseManagementTypeRefList",
|
||
"cadastreNumber",
|
||
"oktmo",
|
||
"statuses",
|
||
"regionProperty",
|
||
"municipalProperty",
|
||
"hostelTypeCodes",
|
||
"useReadOnlyDataSource",
|
||
}
|
||
assert len(expected_keys) == 15
|
||
assert set(body.keys()) == expected_keys
|
||
# cadastreNumber — ОДИНОЧНОЕ значение (НЕ список); fiasHouseCodeList = None.
|
||
assert body["cadastreNumber"] == "66:41:0604007:747"
|
||
assert body["fiasHouseCodeList"] is None
|
||
assert body["regionCode"] == zfl.REGION_GUID
|
||
assert body["statuses"] == ["APPROVED"]
|
||
assert body["useReadOnlyDataSource"] is True
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# _parse_floors_max — макс. этаж из сырой maxFloorCount
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_parse_floors_max() -> None:
|
||
assert zfl._parse_floors_max("10-30") == 30
|
||
assert zfl._parse_floors_max("17,23") == 23
|
||
assert zfl._parse_floors_max("9") == 9
|
||
assert zfl._parse_floors_max(None) is None
|
||
assert zfl._parse_floors_max("") is None
|
||
assert zfl._parse_floors_max("нет") is None
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# _parse_item
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_parse_item_realistic() -> None:
|
||
item = {
|
||
"residentialPremiseCount": "429",
|
||
"operationYear": 2021,
|
||
"buildingYear": "2019",
|
||
"maxFloorCount": "10-30",
|
||
"address": {"house": {"houseGuid": "abc-guid-123"}},
|
||
"houseType": {"houseTypeName": "Многоквартирный"},
|
||
}
|
||
parsed = zfl._parse_item(item)
|
||
assert parsed is not None
|
||
assert parsed.house_guid == "abc-guid-123"
|
||
assert parsed.flat_count == 429
|
||
assert parsed.year == 2021 # operationYear предпочтительнее buildingYear
|
||
assert parsed.floors_raw == "10-30" # сырая строка, не распарсенный максимум
|
||
|
||
|
||
def test_parse_item_falls_back_to_building_year() -> None:
|
||
item = {
|
||
"residentialPremiseCount": "100",
|
||
"operationYear": None,
|
||
"buildingYear": "1998",
|
||
"maxFloorCount": "9",
|
||
"address": {"house": {"houseGuid": "g-fallback"}},
|
||
}
|
||
parsed = zfl._parse_item(item)
|
||
assert parsed is not None
|
||
assert parsed.year == 1998
|
||
assert parsed.flat_count == 100
|
||
|
||
|
||
def test_parse_item_no_guid_returns_none() -> None:
|
||
assert zfl._parse_item({"residentialPremiseCount": "100"}) is None
|
||
assert zfl._parse_item({"address": {"house": {}}}) is None
|
||
assert zfl._parse_item({"address": {}}) is None
|
||
|
||
|
||
def test_parse_item_zero_flatcount_is_none() -> None:
|
||
item = {
|
||
"residentialPremiseCount": "0",
|
||
"maxFloorCount": None,
|
||
"address": {"house": {"houseGuid": "g-zero"}},
|
||
}
|
||
parsed = zfl._parse_item(item)
|
||
assert parsed is not None
|
||
assert parsed.flat_count is None # «0» → None (не пригодный знаменатель)
|
||
assert parsed.floors_raw is None
|
||
assert parsed.year is None
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Статические asserts по _UPDATE_SQL (psycopg v3, идемпотентность)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_update_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
|
||
sql = str(zfl._UPDATE_SQL.text)
|
||
# Никаких :name::type — только CAST(:name AS type).
|
||
assert not re.search(r":\w+::", sql)
|
||
assert "CAST(:fc AS int)" in sql
|
||
assert "CAST(:g AS text)" in sql
|
||
assert "CAST(:yr AS smallint)" in sql
|
||
assert "CAST(:fr AS text)" in sql
|
||
|
||
|
||
def test_update_sql_is_idempotent_gate() -> None:
|
||
flat = re.sub(r"\s+", " ", str(zfl._UPDATE_SQL.text))
|
||
assert "UPDATE houses" in flat
|
||
assert "WHERE gar_house_guid = CAST(:g AS text)" in flat
|
||
# Повторный прогон не трогает уже совпавшие строки (IS DISTINCT FROM gate).
|
||
assert "IS DISTINCT FROM CAST(:fc AS int)" in flat
|
||
assert "zhkh_matched_at = now()" in flat
|