gendesign/backend/tests/services/test_ekburg_permits.py

355 lines
14 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.

"""Тесты для ekburg_permits.py (Issue #105).
Использует mock httpx + синтетические данные xlsx (openpyxl в памяти).
Не требует реального PostgreSQL или сетевого доступа.
"""
from __future__ import annotations
from datetime import date, datetime
from io import BytesIO
from typing import Any
from unittest.mock import MagicMock
import httpx
import pytest
from openpyxl import Workbook
from app.services.scrapers.ekburg_permits import (
EKBURG_PERMITS_URLS,
EkburgPermitsClient,
_clean_inn,
_detect_permit_type,
_to_date,
_to_float,
_to_str,
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_xlsx_bytes(sheets: dict[str, list[tuple[Any, ...]]]) -> bytes:
"""Создать xlsx в памяти с заданными листами и строками."""
wb = Workbook()
wb.remove(wb.active) # удалить дефолтный лист
for sheet_name, rows in sheets.items():
ws = wb.create_sheet(sheet_name)
for row in rows:
ws.append(list(row))
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _rns_sheet_rows() -> list[tuple[Any, ...]]:
"""Синтетические строки для листа РНС (Form 3).
Воспроизводит структуру 2024-2026: header row 4, данные с row 7.
"""
return [
# row 1: пусто
(None,) * 14,
# row 2: заголовок таблицы
(
"Таблица 3. Реестр выданных разрешений на строительство "
"объектов капитального строительства",
)
+ (None,) * 13,
# row 3: пусто
(None,) * 14,
# row 4: заголовки колонок
(
"Наименование застройщика",
"ИНН",
"Адрес застройщика",
"Тип строительного объекта1",
"Наименование объекта КС",
"Кадастровый номер ЗУ",
"Координаты X",
None,
"Адрес объекта",
"Реквизиты разрешения (номер)",
None,
"Дата окончания",
"Общая площадь, м2",
"Площадь жилых помещений по проекту, м2",
),
# row 5: подзаголовки
(None,) * 7 + ("X", "Y") + (None,) * 3 + ("номер", "дата") + (None,) * 2,
# row 6: нумерация столбцов
tuple(range(1, 15)),
# row 7: первая строка данных (МКД — ООО "Тест")
(
'ООО "Специализированный застройщик "Тест"',
6685000001,
"620000, г. Екатеринбург, ул. Тестовая, д. 1",
"многоквартирные жилые дома;",
"Многоквартирный жилой дом (№ 1 по ПЗУ)",
"66:41:0101001:123",
"1530000.0000",
"380000.0000",
"Свердловская обл., г. Екатеринбург, ул. Тестовая",
"66-41-99-2024",
datetime(2024, 3, 15),
datetime(2027, 3, 15),
12345.67,
8000.0,
),
# row 8: строка без permit_number (должна быть пропущена)
(
"ООО «Продолжение»",
6685000002,
"620001, г. Екатеринбург",
"производственные здания;",
"Производственный корпус",
"66:41:0101002:456",
"1531000.0000",
"381000.0000",
"Свердловская обл., г. Екатеринбург, ул. Другая",
None, # no permit_number → skip
datetime(2024, 5, 20),
datetime(2026, 5, 20),
500.0,
None,
),
# row 9: empty row (all None) — should be skipped
(None,) * 14,
]
def _rve_sheet_rows() -> list[tuple[Any, ...]]:
"""Синтетические строки для листа РВЭ (Form 4)."""
return [
(None,) * 19,
("Таблица 4. Реестр выданных разрешений на ввод в эксплуатацию",) + (None,) * 18,
(None,) * 19,
# row 4: заголовки
(
"Наименование застройщика",
"ИНН",
"Адрес застройщика",
"Тип строительного объекта1",
"Наименование объекта КС",
"Кадастровый номер ЗУ",
"Координаты X",
None,
"Адрес объекта",
"Реквизиты РНС (номер)",
None,
"Дата окончания РНС",
"Общая площадь, м2",
"Площадь жилых по проекту, м2",
"Площадь жилых фактически, м2",
"Реквизиты РВЭ (номер)",
None,
"Введённые мощности",
None,
),
(None,) * 7 + ("X", "Y") + (None,) * 5 + ("номер", "дата") + (None,) * 4,
tuple(range(1, 19)),
# row 7: первая строка данных
(
'ООО "Застройщик Ввода"',
6658000002,
"620100, г. Екатеринбург, ул. Вводная, д. 2",
"многоквартирные жилые дома;",
"Жилой дом (1 очередь) (№ 1 по ПЗУ)",
"66:41:0303001:789",
"1535000.0000",
"385000.0000",
"Свердловская обл., г. Екатеринбург, ул. Вводная",
"66-41-50-2022",
datetime(2022, 10, 1),
datetime(2026, 1, 1),
30000.0,
20000.0,
19800.5, # living_area_fact
"66-41-5-2026",
datetime(2026, 1, 13),
None,
None,
),
]
# ── unit tests: helpers ───────────────────────────────────────────────────────
class TestHelpers:
def test_to_date_datetime(self) -> None:
dt = datetime(2024, 3, 15, 0, 0)
assert _to_date(dt) == date(2024, 3, 15)
def test_to_date_string_dot(self) -> None:
assert _to_date("15.03.2024") == date(2024, 3, 15)
def test_to_date_none(self) -> None:
assert _to_date(None) is None
def test_to_date_invalid(self) -> None:
assert _to_date("not-a-date") is None
def test_to_float_comma(self) -> None:
assert _to_float("12345,67") == pytest.approx(12345.67)
def test_to_float_dash(self) -> None:
assert _to_float("-") is None
def test_to_float_int(self) -> None:
assert _to_float(1490) == pytest.approx(1490.0)
def test_to_str_nbsp(self) -> None:
assert _to_str("г.\xa0Екатеринбург") == "г. Екатеринбург"
def test_to_str_none(self) -> None:
assert _to_str(None) is None
def test_clean_inn_int(self) -> None:
assert _clean_inn(6685180480) == "6685180480"
def test_clean_inn_float(self) -> None:
# openpyxl иногда выдаёт float для длинных чисел
assert _clean_inn(6685180480.0) == "6685180480"
def test_clean_inn_string(self) -> None:
assert _clean_inn("6685180480") == "6685180480"
def test_clean_inn_none(self) -> None:
assert _clean_inn(None) is None
def test_clean_inn_short(self) -> None:
# 9 цифр — не ИНН
assert _clean_inn(123456789) is None
def test_detect_permit_type_rns(self) -> None:
assert _detect_permit_type("реестр разрешений на строительс") == "RNS"
def test_detect_permit_type_rve(self) -> None:
assert _detect_permit_type("реестр разрешений на ввод") == "RVE"
def test_detect_permit_type_unknown(self) -> None:
assert _detect_permit_type("Справочник") is None
# ── unit tests: xlsx parsing ──────────────────────────────────────────────────
class TestParseXlsx:
def _make_client(self) -> EkburgPermitsClient:
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
# Создаём mock httpx.Client чтобы не открывать реальные соединения
client._client = MagicMock()
return client
def test_parse_rns_yields_permit_row(self) -> None:
content = _make_xlsx_bytes(
{
"реестр разрешений на строительс": _rns_sheet_rows(),
"Справочник": [("многоквартирные жилые дома;",)],
}
)
client = self._make_client()
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
# Должна быть ровно одна строка (вторая без permit_number пропущена)
assert len(rows) == 1
row = rows[0]
assert row.permit_type == "RNS"
assert row.permit_number == "66-41-99-2024"
assert row.issue_date == date(2024, 3, 15)
assert row.expiry_date == date(2027, 3, 15)
assert row.developer_inn == "6685000001"
assert row.object_type == "многоквартирные жилые дома;"
assert row.total_area_sqm == pytest.approx(12345.67)
assert row.living_area_sqm == pytest.approx(8000.0)
assert row.source_year == 2024
def test_parse_rve_yields_permit_row_with_rve_fields(self) -> None:
content = _make_xlsx_bytes({"реестр разрешений на ввод": _rve_sheet_rows()})
client = self._make_client()
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
assert len(rows) == 1
row = rows[0]
assert row.permit_type == "RVE"
assert row.permit_number == "66-41-50-2022"
assert row.living_area_fact_sqm == pytest.approx(19800.5)
assert row.rve_number == "66-41-5-2026"
assert row.rve_date == date(2026, 1, 13)
def test_parse_skips_справочник_sheet(self) -> None:
content = _make_xlsx_bytes(
{
"Справочник": [("многоквартирные жилые дома;",)],
"Лист1": [(None,)],
}
)
client = self._make_client()
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
assert rows == []
def test_raw_row_stored(self) -> None:
content = _make_xlsx_bytes({"реестр разрешений на строительс": _rns_sheet_rows()})
client = self._make_client()
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
assert len(rows) == 1
# raw_row должен содержать dict с string keys
assert isinstance(rows[0].raw_row, dict)
assert "9" in rows[0].raw_row # колонка 9 = permit_number
# ── unit tests: download_xlsx ─────────────────────────────────────────────────
class TestDownloadXlsx:
def _client_with_mock_http(self, mock_response: MagicMock) -> EkburgPermitsClient:
"""Создать EkburgPermitsClient с замоканным внутренним httpx.Client."""
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
mock_http = MagicMock()
mock_http.get.return_value = mock_response
client._client = mock_http
return client
def test_download_returns_bytes(self) -> None:
fake_content = b"PK\x03\x04fake_xlsx_content"
mock_response = MagicMock()
mock_response.content = fake_content
mock_response.raise_for_status = MagicMock()
client = self._client_with_mock_http(mock_response)
result = client.download_xlsx(2026)
assert result == fake_content
def test_download_raises_for_unknown_year(self) -> None:
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
client._client = MagicMock()
with pytest.raises(ValueError, match="year=9999"):
client.download_xlsx(9999)
def test_download_raises_on_http_error(self) -> None:
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"404", request=MagicMock(), response=MagicMock()
)
client = self._client_with_mock_http(mock_response)
with pytest.raises(httpx.HTTPStatusError):
client.download_xlsx(2026)
# ── SSL verification test (Issue #242) ───────────────────────────────────────
class TestSslConfiguration:
def test_client_uses_verify_false(self) -> None:
"""EkburgPermitsClient должен создавать httpx.Client с verify=False.
екатеринбург.рф использует CA Минцифры РФ — не в certifi bundle.
Issue #242: SSL: CERTIFICATE_VERIFY_FAILED для всех 5 годов.
"""
with EkburgPermitsClient() as client:
# httpx.Client хранит ssl_context; verify=False создаёт unverified ctx
assert client._client._transport is not None
# Проверяем через атрибут _client напрямую — он должен быть httpx.Client
assert isinstance(client._client, httpx.Client)