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