gendesign/backend/tests/test_gisogd66_loader.py
bot-backend 1b96ec1baf
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Failing after 1m56s
CI / backend-tests (pull_request) Successful in 14m37s
feat(scrapers): реестр РНС/РВЭ ГИСОГД-СО из открытого API (#2367 PR-1)
Ранний сигнал future-supply: разрешения на строительство появляются в
ГИСОГД до проектных деклараций ЕИСЖС. Старый minstroy-путь мёртв (WAF),
портал gisogd66.midural.ru отдаёт открытый JSON-API без авторизации.

Миграция 187_gisogd_permits (RS+RV, UNIQUE(doc_group,doc_num), GIST geom
4326, GIN cad_nums) + инкрементальный httpx-лоадер (схемы agate_ekbgo +
agate_sverdregion, сверка по (source_key,date_reg), карточки только для
новых/изменённых, SAVEPOINT per-row, WHERE-гейт предпочтения позднего
date_reg) + Celery weekly (вт 06:30 МСК, limit 3600/3300) + on-demand
admin-триггер + freshness-реестр (data-table, 14/45).

Refs #2367
2026-07-04 07:58:59 +05:00

392 lines
18 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.

"""Unit-тесты лоадера реестра РНС/РВЭ ГИСОГД-СО (#2367 PR-1).
Pure / mock-based — без реальной сети и БД. Фикстуры — реальные фрагменты ответов
API (list-элемент + карточка), снятые при разведке 2026-07-04. Покрывает:
- _parse_date: ISO-datetime источника → date, пусто/мусор → None.
- parse_cad_nums: split по запятой, strip, валидация формата, отброс мусора, дедуп.
- extract_geojson: строка-GeoJSON, отсутствие (hasGeometry=false), битый JSON, dict.
- _build_record: docNum strip (список с пробелами), fallback полей, RS/RV-код.
- инкрементальность: известный key с тем же date_reg → карточка НЕ тянется (skip).
- _upsert_permit: insert / update / skipped_unchanged (по scalar-заглушке).
- _sync_group: счётчики no_cad/no_geom, Polygon→(SQL ST_Multi) не падает без geom/cad.
- конфликт двух схем по (doc_group, doc_num): поздний date_reg параметризуется в UPSERT.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import date
from typing import Any
from app.services.scrapers import gisogd66 as g
# ── Реальные фрагменты API (разведка 2026-07-04) ──────────────────────────────
# Элемент списка группы DocRS (/gisogddocgroupdocs/razdel13/DocRS).
_LIST_ITEM = {
"key": 1000210001947595,
"gisogdSectionId": "razdel13",
"name": "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
"docNum": "66-45-08-2026",
"dateDoc": "2026-06-02T00:00:00",
"numReg": "65740000-13-2026-548",
"dateReg": "2026-06-19T15:55:11",
}
# Карточка (/gisogddocs/razdel13/{key}).
_CARD = {
"spatialUnit": "66:45:0200124:343",
"key": 1000210001947595,
"name": "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
"docNum": "66-45-08-2026",
"dateDoc": "2026-06-02T00:00:00",
"numReg": "65740000-13-2026-548",
"dateReg": "2026-06-19T15:55:11",
"infosetGeoJson": (
'{"type":"Polygon","coordinates":[[[61.942031258,56.368423297],'
"[61.941099943,56.368422956],[61.941111179,56.367992961],"
"[61.942039129,56.367981137],[61.942031258,56.368423297]]]}"
),
"hasGeometry": True,
"approvedOrganization": (
'Орган местного самоуправления "Комитет по архитектуре и '
'градостроительству Каменск-Уральского городского округа"'
),
"textFiles": [],
}
# ── _parse_date ───────────────────────────────────────────────────────────────
def test_parse_date_iso_datetime() -> None:
assert g._parse_date("2026-06-19T15:55:11") == date(2026, 6, 19)
assert g._parse_date("2026-06-02T00:00:00") == date(2026, 6, 2)
def test_parse_date_empty_and_garbage() -> None:
assert g._parse_date(None) is None
assert g._parse_date("") is None
assert g._parse_date("не дата") is None
# ── parse_cad_nums ────────────────────────────────────────────────────────────
def test_parse_cad_nums_single() -> None:
assert g.parse_cad_nums("66:45:0200124:343") == ["66:45:0200124:343"]
def test_parse_cad_nums_multi_with_whitespace() -> None:
"""Несколько кадастровых номеров через запятую + пробелы вокруг → strip."""
su = " 66:45:0200124:343 , 66:41:0704002:12 "
assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]
def test_parse_cad_nums_six_and_seven_digit_block() -> None:
"""Третий блок кадастрового квартала может быть 6 ИЛИ 7 знаков — оба валидны.
Реальные данные Свердловской обл. — 7 знаков (66:45:0200124:343). Спец. регекс
\\d{6,7}: 8-значный блок (не встречается) отбраковывается как мусор.
"""
assert g.parse_cad_nums("66:45:020012:343") == ["66:45:020012:343"] # 6 знаков
assert g.parse_cad_nums("66:45:0200124:343") == ["66:45:0200124:343"] # 7 знаков
assert g.parse_cad_nums("66:45:02001240:343") == [] # 8 знаков → отброшен
def test_parse_cad_nums_drops_garbage() -> None:
"""Невалидные токены (мусор) отбрасываются, валидные остаются."""
su = "66:45:0200124:343, мусор, 12345, 66:41:0704002:12"
assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]
def test_parse_cad_nums_dedup_preserves_order() -> None:
su = "66:45:0200124:343, 66:45:0200124:343, 66:41:0704002:12"
assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]
def test_parse_cad_nums_absent() -> None:
assert g.parse_cad_nums(None) == []
assert g.parse_cad_nums("") == []
assert g.parse_cad_nums(", , ") == []
# ── extract_geojson ───────────────────────────────────────────────────────────
def test_extract_geojson_polygon_string() -> None:
"""infosetGeoJson приходит СТРОКОЙ — возвращаем её как есть (валидный JSON)."""
out = g.extract_geojson(_CARD)
assert out is not None
assert out.startswith('{"type":"Polygon"')
def test_extract_geojson_absent() -> None:
"""hasGeometry=false / нет поля → None (geom=NULL)."""
assert g.extract_geojson({"hasGeometry": False}) is None
assert g.extract_geojson({"infosetGeoJson": None}) is None
assert g.extract_geojson({"infosetGeoJson": ""}) is None
def test_extract_geojson_broken_json() -> None:
"""Битая GeoJSON-строка → None (не отправляем мусор в PostGIS)."""
assert g.extract_geojson({"infosetGeoJson": "{not json"}) is None
def test_extract_geojson_dict_form() -> None:
"""Если источник когда-нибудь отдаст объект (не строку) — сериализуем."""
obj = {"type": "Polygon", "coordinates": [[[60.0, 56.0], [60.1, 56.0], [60.0, 56.1]]]}
out = g.extract_geojson({"infosetGeoJson": obj})
assert out is not None
assert '"Polygon"' in out
# ── _build_record ─────────────────────────────────────────────────────────────
def test_build_record_from_real_fixtures() -> None:
rec = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1000210001947595", None)
assert rec is not None
assert rec["doc_group"] == "RS"
assert rec["doc_num"] == "66-45-08-2026"
assert rec["date_doc"] == date(2026, 6, 2)
assert rec["date_reg"] == date(2026, 6, 19)
assert rec["cad_nums"] == ["66:45:0200124:343"]
assert rec["geojson"] is not None
assert rec["source_schema"] == "agate_ekbgo"
assert rec["source_key"] == "1000210001947595"
assert "Каменск-Уральск" in rec["approved_organization"]
def test_build_record_strips_docnum_whitespace() -> None:
"""~25% номеров в списке с ведущими/хвостовыми пробелами → strip обязателен."""
item = {**_LIST_ITEM, "docNum": " 66-RU66333000-832-2016 "}
card = {**_CARD, "docNum": " 66-RU66333000-832-2016 "}
rec = g._build_record(item, card, "agate_sverdregion", "RS", "1", None)
assert rec is not None
assert rec["doc_num"] == "66-RU66333000-832-2016"
def test_build_record_no_docnum_returns_none() -> None:
rec = g._build_record({}, {"spatialUnit": "66:45:0200124:343"}, "agate_ekbgo", "RS", "9", None)
assert rec is None
def test_build_record_no_cad_no_geom() -> None:
"""Карточка без spatialUnit и без geometry — запись строится, не падает."""
card = {"docNum": "66-99-1-2026", "dateReg": "2026-06-01T10:00:00", "hasGeometry": False}
rec = g._build_record({}, card, "agate_ekbgo", "RV", "77", None)
assert rec is not None
assert rec["cad_nums"] == []
assert rec["geojson"] is None
assert rec["doc_group"] == "RV"
def test_build_record_list_datereg_fallback() -> None:
"""dateReg берётся из карточки; если её нет — fallback на list_reg из списка."""
card = {"docNum": "66-1-1-2026", "spatialUnit": "66:45:0200124:343"}
rec = g._build_record({}, card, "agate_ekbgo", "RS", "5", date(2026, 5, 1))
assert rec is not None
assert rec["date_reg"] == date(2026, 5, 1)
# ── Fake DB session (как в test_gas_grs_loader) ───────────────────────────────
class _FakeResult:
def __init__(self, scalar_value: Any = True, rows: list[Any] | None = None) -> None:
self._scalar = scalar_value
self._rows = rows or []
def scalar(self) -> Any:
return self._scalar
def all(self) -> list[Any]:
return self._rows
class _FakeSession:
"""Мини-заглушка Session: execute + begin_nested SAVEPOINT-контекст.
scalar_value управляет исходом _upsert_permit (True→insert, False→update,
None→skipped_unchanged). select_rows отдаётся из .all() для _existing_reg_dates.
"""
def __init__(
self,
scalar_value: Any = True,
select_rows: list[Any] | None = None,
) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._select_rows = select_rows or []
self.committed = False
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
sql = str(stmt)
self.calls.append((sql, params))
if sql.strip().upper().startswith("SELECT"):
return _FakeResult(self._scalar, self._select_rows)
return _FakeResult(self._scalar)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
def close(self) -> None:
pass
# ── _upsert_permit: insert / update / skipped_unchanged ───────────────────────
def _sample_rec(**over: Any) -> dict[str, Any]:
base = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1000210001947595", None)
assert base is not None
base.update(over)
return base
def test_upsert_permit_insert() -> None:
db = _FakeSession(scalar_value=True) # xmax=0 → insert
assert g._upsert_permit(db, _sample_rec()) == "inserted" # type: ignore[arg-type]
# cad_nums параметризуется как list (→ CAST(:cad_nums AS text[]) в SQL).
assert db.calls[0][1]["cad_nums"] == ["66:45:0200124:343"]
assert db.calls[0][1]["doc_group"] == "RS"
def test_upsert_permit_update() -> None:
db = _FakeSession(scalar_value=False) # xmax<>0 → update
assert g._upsert_permit(db, _sample_rec()) == "updated" # type: ignore[arg-type]
def test_upsert_permit_skipped_unchanged() -> None:
"""WHERE-гейт не пропустил апдейт (более старый date_reg) → RETURNING NULL → skip."""
db = _FakeSession(scalar_value=None)
assert g._upsert_permit(db, _sample_rec()) == "skipped_unchanged" # type: ignore[arg-type]
def test_upsert_permit_no_geom_passes_null() -> None:
"""geojson=None → в params передаётся None (SQL CASE запишет geom=NULL), не падает."""
db = _FakeSession(scalar_value=True)
rec = _sample_rec(geojson=None, cad_nums=[])
assert g._upsert_permit(db, rec) == "inserted" # type: ignore[arg-type]
assert db.calls[0][1]["geojson"] is None
assert db.calls[0][1]["cad_nums"] == []
# ── Инкрементальность через _sync_group ───────────────────────────────────────
class _RecordingClient:
"""Заглушка httpx-клиента: считает, сколько карточек запрошено (fetch_doc_card)."""
def _patch_card_fetch(monkeypatch: Any, calls: list[Any]) -> None:
def _fake(client: Any, schema: str, key: Any) -> dict[str, Any]:
calls.append(key)
return dict(_CARD)
monkeypatch.setattr(g, "fetch_doc_card", _fake)
def test_sync_group_incremental_skip(monkeypatch: Any) -> None:
"""Известный key с ТЕМ ЖЕ date_reg → карточка НЕ тянется, счётчик skipped_unchanged."""
fetched: list[Any] = []
_patch_card_fetch(monkeypatch, fetched)
db = _FakeSession(scalar_value=True)
existing = {"1000210001947595": date(2026, 6, 19)} # тот же dateReg, что в списке
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], existing, counts) # type: ignore[arg-type]
assert fetched == [] # карточку не тянули
assert counts["skipped_unchanged"] == 1
assert counts["fetched_details"] == 0
def test_sync_group_changed_datereg_fetches(monkeypatch: Any) -> None:
"""Известный key, но date_reg в списке ИЗМЕНИЛСЯ → карточка тянется + upsert."""
fetched: list[Any] = []
_patch_card_fetch(monkeypatch, fetched)
db = _FakeSession(scalar_value=False) # update
existing = {"1000210001947595": date(2026, 6, 10)} # старее списочного 06-19
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], existing, counts) # type: ignore[arg-type]
assert fetched == ["1000210001947595"] # карточку тянули
assert counts["fetched_details"] == 1
assert counts["updated"] == 1
def test_sync_group_new_key_inserts_and_counts_geom(monkeypatch: Any) -> None:
"""Новый key → fetch + insert; карточка с geom+cad → no_cad/no_geom НЕ инкрементятся."""
fetched: list[Any] = []
_patch_card_fetch(monkeypatch, fetched)
db = _FakeSession(scalar_value=True) # insert
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], {}, counts) # type: ignore[arg-type]
assert counts["inserted"] == 1
assert counts["fetched_details"] == 1
assert counts["no_cad"] == 0 # карточка с cad
assert counts["no_geom"] == 0 # карточка с geom
def test_sync_group_no_cad_no_geom_counts(monkeypatch: Any) -> None:
"""Карточка без spatialUnit и без geometry → no_cad и no_geom += 1, upsert идёт."""
def _fake(client: Any, schema: str, key: Any) -> dict[str, Any]:
return {"docNum": "66-99-1-2026", "dateReg": "2026-06-01T10:00:00", "hasGeometry": False}
monkeypatch.setattr(g, "fetch_doc_card", _fake)
db = _FakeSession(scalar_value=True)
item = {**_LIST_ITEM, "key": 42, "dateReg": "2026-06-01T10:00:00"}
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RV", [item], {}, counts) # type: ignore[arg-type]
assert counts["no_cad"] == 1
assert counts["no_geom"] == 1
assert counts["inserted"] == 1
# ── Конфликт двух схем: поздний date_reg параметризуется в UPSERT ──────────────
def test_conflict_resolution_prefers_later_datereg() -> None:
"""Обе схемы содержат один (doc_group, doc_num); UPSERT-параметры несут date_reg
записи — WHERE-гейт в SQL (EXCLUDED.date_reg > existing) выберет позднюю.
Проверяем именно то, что лоадер параметризует date_reg корректно (SQL-гейт покрыт
на уровне БД). ekbgo (позднее 06-19) vs sverdregion (раннее 06-10).
"""
db = _FakeSession(scalar_value=False)
ekb = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1", None)
sverd_card = {**_CARD, "dateReg": "2026-06-10T09:00:00"}
sverd = g._build_record(_LIST_ITEM, sverd_card, "agate_sverdregion", "RS", "2", None)
assert ekb is not None and sverd is not None
g._upsert_permit(db, ekb) # type: ignore[arg-type]
g._upsert_permit(db, sverd) # type: ignore[arg-type]
assert db.calls[0][1]["date_reg"] == date(2026, 6, 19) # ekbgo — поздняя
assert db.calls[1][1]["date_reg"] == date(2026, 6, 10) # sverdregion — ранняя
# Оба несут одинаковый бизнес-ключ (doc_group, doc_num).
assert db.calls[0][1]["doc_num"] == db.calls[1][1]["doc_num"] == "66-45-08-2026"
# ── _existing_reg_dates ───────────────────────────────────────────────────────
def test_existing_reg_dates_maps_key_to_datereg() -> None:
rows = [("100", date(2026, 6, 1)), ("200", None)]
db = _FakeSession(select_rows=rows)
out = g._existing_reg_dates(db, "agate_ekbgo") # type: ignore[arg-type]
assert out == {"100": date(2026, 6, 1), "200": None}