"""Тесты АИС ППК ФРТ loader'а (app/services/frt_mkd_loader.py, мигр. 290/291, #frt-mkd). Coverage: - Чистые парс-хелперы: clean_text (мусорные значения-заглушки), parse_decimal_comma, parse_int_field (+ sanity-гейт границ), parse_bool_da_net. - parse_frt_mkd_csv — фикстуры собраны из РЕАЛЬНЫХ строк export-reestrmkd-66-20260901.csv (id 6556123 «д. 41» — плотно заполненная строка; id 6477794 «д. 99» — почти вся строка из значений-заглушек «Не заполнено»/пусто; id 9090614 — is_alarm='Да', project_type в нижнем регистре 'нет'). - fetch_frt_mkd_csv — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport. - Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST), _BACKFILL_HOUSES_SQL/_COUNT_SQL (COALESCE-only-NULL-гейт, houseguid COALESCE-матч, без `:x::type`). - backfill_houses — dry_run вызывает COUNT-SQL и не пишет, real-run читает rowcount (MagicMock db). """ from __future__ import annotations import io import os import re import zipfile from pathlib import Path from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import httpx import pytest from app.services import frt_mkd_loader as fml # ───────────────────────────────────────────────────────────────────────────── # Фикстуры: РЕАЛЬНЫЕ строки export-reestrmkd-66-20260901.csv (region 66) # ───────────────────────────────────────────────────────────────────────────── _HEADER = ( "id;region_id;area_id;city_id;street_id;shortname_region;formalname_region;" "shortname_area;formalname_area;shortname_city;formalname_city;shortname_street;" "formalname_street;house_number;building;block;letter;address;houseguid;" "management_organization_id;built_year;exploitation_start_year;project_type;house_type;" "is_alarm;method_of_forming_overhaul_fund;floor_count_max;floor_count_min;" "entrance_count;elevators_count;energy_efficiency;quarters_count;" "living_quarters_count;unliving_quarters_count;area_total;area_residential;" "area_non_residential;area_common_property;area_land;parking_square;playground;" "sportsground;other_beautification;foundation_type;floor_type;wall_material;" "basement_area;chute_type;chute_count;electrical_type;electrical_entries_count;" "heating_type;hot_water_type;cold_water_type;sewerage_type;" "sewerage_cesspools_volume;gas_type;ventilation_type;firefighting_type;drainage_type" ) # д. 41 — плотно заполненная строка, is_alarm='Нет', elevators_count=0. _ROW_DENSE = ( '6556123;;;;;;;;;;;;;41;;;;"д. 41";d6bad775-f0c8-b0d6-87ab-07c43513529e;8915908;1917;' '1917;Кирпичный;"Многоквартирный дом";Нет;"На счете регионального оператора";8;6;1;0;' '"Не присвоен";13;13;0;930,60;930,60;0,00;80,40;1100,00;0,00;500;500;;Ленточный;' "Деревянные;Кирпич;0,00;Отсутствует;0;Центральное;2;Центральное;" '"Открытая с отбором сетевой воды на горячее водоснабжение из тепловой сети";' 'Центральное;Центральное;0,00;Отсутствует;"Приточная вентиляция";Отсутствует;' '"Наружные водостоки"' ) # д. 99 — почти вся строка из значений-заглушек «Не заполнено» / пусто. _ROW_NOISE = ( '6477794;;;;;;;;;;;;;99;;;;"д. 99";c42aa407-54e8-0917-ca15-439b5b784903;;;1954;;' '"Многоквартирный дом";Нет;"Не заполнено";;;;;"Не заполнено";0;;;10156,60;7039,70;;;;;' '498;498;;"Не заполнено";"Не заполнено";"Не заполнено";;"Не заполнено";;' '"Не заполнено";;"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено";;' '"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено"' ) # is_alarm='Да' (аварийный), project_type='нет' (нижний регистр — проверка noise-гейта). _ROW_ALARM = ( "9090614;92b30014-4d52-4e2e-892d-928142b924bf;;a4ab722e-453a-4aed-bb73-728a05e2e27f;" "36a4e8b6-5c87-4f67-9ea1-12ac63f32939;обл.;Свердловская;;;г.;Алапаевск;км;123;3;;;;" '"обл. Свердловская, г. Алапаевск, км. 123, д. 3";' "37e2d973-9a07-4faa-86f5-95e417f86059;8917294;1911;1911;нет;" '"Многоквартирный дом";Да;"Не определен";1;1;0;0;"Не присвоен";5;5;0;110,50;110,50;' "0,00;0,00;2400,00;0,00;500;500;нет;Ленточный;Деревянные;Деревянные;0,00;Отсутствует;" "0;Центральное;1;Печное;Отсутствует;Отсутствует;Отсутствует;0,00;Отсутствует;" "Отсутствует;Отсутствует;Отсутствует" ) FRT_CSV = "\n".join([_HEADER, _ROW_DENSE, _ROW_NOISE, _ROW_ALARM]) + "\n" @pytest.fixture def frt_csv_path(tmp_path: Path) -> Path: p = tmp_path / "frt_mkd.csv" p.write_text(FRT_CSV, encoding="utf-8-sig") return p # ───────────────────────────────────────────────────────────────────────────── # clean_text / parse_decimal_comma / parse_int_field / parse_bool_da_net # ───────────────────────────────────────────────────────────────────────────── def test_clean_text_filters_noise_values() -> None: assert fml.clean_text("Кирпич") == "Кирпич" assert fml.clean_text("") is None assert fml.clean_text(None) is None assert fml.clean_text("нет данных") is None assert fml.clean_text("Не заполнено") is None assert fml.clean_text("отсутствует") is None assert fml.clean_text("данные отсутствуют") is None assert fml.clean_text("нет") is None assert fml.clean_text(" Кирпич ") == "Кирпич" def test_parse_decimal_comma() -> None: assert fml.parse_decimal_comma("930,60") == pytest.approx(930.60) assert fml.parse_decimal_comma("100.5") == pytest.approx(100.5) assert fml.parse_decimal_comma("") is None assert fml.parse_decimal_comma(None) is None assert fml.parse_decimal_comma("Не заполнено") is None def test_parse_int_field() -> None: assert fml.parse_int_field("1917") == 1917 assert fml.parse_int_field("8") == 8 assert fml.parse_int_field("") is None assert fml.parse_int_field(None) is None assert fml.parse_int_field("Не заполнено") is None assert fml.parse_int_field("5,0") == 5 def test_parse_int_field_sanity_gate() -> None: ymax = fml.plausible_year_max() assert fml.parse_int_field("1200", min_value=1850, max_value=ymax) is None assert fml.parse_int_field(str(ymax + 10), min_value=1850, max_value=ymax) is None assert fml.parse_int_field("1917", min_value=1850, max_value=ymax) == 1917 def test_parse_bool_da_net() -> None: assert fml.parse_bool_da_net("Да") is True assert fml.parse_bool_da_net("да") is True assert fml.parse_bool_da_net("Нет") is False assert fml.parse_bool_da_net("") is None assert fml.parse_bool_da_net(None) is None assert fml.parse_bool_da_net("Не заполнено") is None # ───────────────────────────────────────────────────────────────────────────── # parse_frt_mkd_csv # ───────────────────────────────────────────────────────────────────────────── def test_parse_frt_mkd_csv_dense_row(frt_csv_path: Path) -> None: rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66) assert set(rows.keys()) == { "d6bad775-f0c8-b0d6-87ab-07c43513529e", "c42aa407-54e8-0917-ca15-439b5b784903", "37e2d973-9a07-4faa-86f5-95e417f86059", } dense = rows["d6bad775-f0c8-b0d6-87ab-07c43513529e"] assert dense.region_code == 66 assert dense.built_year == 1917 assert dense.is_alarm is False assert dense.floor_count_max == 8 assert dense.entrance_count == 1 assert dense.elevators_count == 0 assert dense.wall_material == "Кирпич" assert dense.floor_type == "Деревянные" assert dense.area_land == pytest.approx(1100.00) assert dense.area_total == pytest.approx(930.60) assert dense.living_quarters_count == 13 assert dense.heating_type == "Центральное" assert dense.playground == 500 # id справочника, не булев assert dense.management_organization_id == 8915908 def test_parse_frt_mkd_csv_noise_row_collapses_to_none(frt_csv_path: Path) -> None: rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66) noise = rows["c42aa407-54e8-0917-ca15-439b5b784903"] # Почти вся строка — "Не заполнено"/пусто → None, не должно попасть в staging как текст. assert noise.wall_material is None assert noise.foundation_type is None assert noise.floor_type is None assert noise.built_year is None # built_year пуст в этой строке assert noise.exploitation_start_year == 1954 assert noise.area_total == pytest.approx(10156.60) assert noise.is_alarm is False def test_parse_frt_mkd_csv_alarm_row_and_lowercase_noise(frt_csv_path: Path) -> None: rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66) alarm = rows["37e2d973-9a07-4faa-86f5-95e417f86059"] assert alarm.is_alarm is True # project_type='нет' (нижний регистр) — та же заглушка, что 'Нет'/'НЕТ' → None. assert alarm.project_type is None assert alarm.built_year == 1911 def test_parse_frt_mkd_csv_skips_blank_houseguid(tmp_path: Path) -> None: csv_text = _HEADER + "\n" + (";" * _HEADER.count(";")) + "\n" p = tmp_path / "blank.csv" p.write_text(csv_text, encoding="utf-8-sig") assert fml.parse_frt_mkd_csv(p, region_code=66) == {} # ───────────────────────────────────────────────────────────────────────────── # fetch_frt_mkd_csv — скачивание+распаковка zip, HTTP замокан (MockTransport) # ───────────────────────────────────────────────────────────────────────────── def _zip_bytes(inner_name: str, content: str) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr(inner_name, content.encode("utf-8-sig")) return buf.getvalue() def test_fetch_frt_mkd_csv_downloads_and_extracts(tmp_path: Path) -> None: zip_bytes = _zip_bytes("export-reestrmkd-66-20260901.csv", FRT_CSV) expected_url = f"{fml.BASE_URL}/opendata/export/110" def handler(request: httpx.Request) -> httpx.Response: if str(request.url) == expected_url: return httpx.Response(200, content=zip_bytes) raise AssertionError(f"unexpected URL {request.url}") transport = httpx.MockTransport(handler) with httpx.Client(transport=transport) as client: out_path = fml.fetch_frt_mkd_csv(tmp_path, node_id=110, client=client) assert out_path.exists() and out_path.name.endswith(".csv") rows = fml.parse_frt_mkd_csv(out_path, region_code=66) assert len(rows) == 3 def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("readme.txt", "not a csv") with pytest.raises(ValueError, match="csv"): fml._extract_csv_from_zip(buf.getvalue(), tmp_path) # ───────────────────────────────────────────────────────────────────────────── # load_frt_mkd — dry_run не пишет (упаковано в тонкий MagicMock-flow) # ───────────────────────────────────────────────────────────────────────────── def test_load_frt_mkd_dry_run_parses_but_does_not_upsert(frt_csv_path: Path) -> None: db = MagicMock() result = fml.load_frt_mkd(db, src_path=frt_csv_path, region_code=66, dry_run=True) assert result == {"rows": 3, "upserted": 0} db.execute.assert_not_called() # ───────────────────────────────────────────────────────────────────────────── # Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность) # ───────────────────────────────────────────────────────────────────────────── _UPSERT_SQL = str(fml._UPSERT_SQL.text) def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None: assert not re.search(r":\w+::", _UPSERT_SQL) assert "CAST(:houseguid AS text)" in _UPSERT_SQL assert "CAST(:built_year AS smallint)" in _UPSERT_SQL assert "CAST(:is_alarm AS boolean)" in _UPSERT_SQL assert "CAST(:area_land AS numeric)" in _UPSERT_SQL def test_upsert_sql_idempotent_on_conflict() -> None: flat = re.sub(r"\s+", " ", _UPSERT_SQL) assert "INSERT INTO frt_mkd" in flat assert "ON CONFLICT (houseguid) DO UPDATE SET" in flat assert "frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat assert "frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land" in flat def test_upsert_uses_savepoint_per_chunk_and_does_not_commit() -> None: import inspect src = inspect.getsource(fml.upsert_frt_mkd) assert "with db.begin_nested():" in src assert "db.commit()" not in src def test_load_frt_mkd_does_not_commit() -> None: import inspect src = inspect.getsource(fml.load_frt_mkd) assert "db.commit()" not in src # ───────────────────────────────────────────────────────────────────────────── # Статические asserts: _BACKFILL_HOUSES_SQL / _COUNT_SQL # ───────────────────────────────────────────────────────────────────────────── _BACKFILL_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_SQL.text)) _BACKFILL_COUNT_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_COUNT_SQL.text)) def test_backfill_sql_matches_by_houseguid_coalesce_priority() -> None: assert ( "s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)" in _BACKFILL_SQL ) def test_backfill_sql_never_overwrites_existing_non_null() -> None: # Каждый таргет своим гейтом h.