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
Ранний сигнал 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
392 lines
18 KiB
Python
392 lines
18 KiB
Python
"""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}
|