gendesign/tradein-mvp/backend/tests/test_zhkh_flats_loader.py
bot-backend 078dadc440
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein): cadastre ЖКХ-fallback + COALESCE merge-key + median-floors guard
Серия 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.
2026-06-29 01:32:48 +03:00

186 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты ЖКХ-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