gendesign/backend/tests/test_gisogd66_loader.py
bot-backend ae3278f92f
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m51s
Deploy / build-worker (push) Successful in 3m3s
Deploy / deploy (push) Successful in 1m31s
fix(gisogd66): битый 500-сегмент портала — no-retry карточек, circuit breaker, периодический commit (#2371)
2026-07-04 04:33:44 +00:00

534 lines
24 KiB
Python
Raw Permalink 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
import httpx
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
self.commit_count = 0
@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
self.commit_count += 1
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 ───────────────────────────────────────
_COUNT_KEYS = (
"inserted",
"updated",
"skipped_unchanged",
"fetched_details",
"no_cad",
"no_geom",
"card_failed",
"group_aborted",
)
def _fresh_counts() -> dict[str, int]:
"""Свежий словарь счётчиков _sync_group (все ключи, что лоадер инкрементит)."""
return dict.fromkeys(_COUNT_KEYS, 0)
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 = _fresh_counts()
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 = _fresh_counts()
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 = _fresh_counts()
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 = _fresh_counts()
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
# ── Хотфикс #2370: 5xx-карточки без ретраев, circuit breaker, периодический commit ──
class _FakeResponse:
"""Мини-заглушка httpx.Response: только status_code + json/raise_for_status/request."""
def __init__(self, status_code: int, payload: Any = None) -> None:
self.status_code = status_code
self._payload = payload
self.request = httpx.Request("GET", "https://example/x")
def json(self) -> Any:
return self._payload
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise httpx.HTTPStatusError(
f"{self.status_code}",
request=self.request,
response=None, # type: ignore[arg-type]
)
class _CountingClient:
"""Заглушка httpx.Client: считает GET-и, всегда отдаёт заданный status_code."""
def __init__(self, status_code: int, payload: Any = None) -> None:
self.status_code = status_code
self.payload = payload
self.get_calls: list[str] = []
def get(self, path: str) -> _FakeResponse:
self.get_calls.append(path)
return _FakeResponse(self.status_code, self.payload)
def test_fetch_doc_card_500_single_attempt(monkeypatch: Any) -> None:
"""Хотфикс: карточка отдаёт 500 → ровно 1 HTTP-попытка (retries_5xx=0), затем raise.
Битый сегмент старых ключей портала детерминированно 5xx — ретраи = ~12с/карточку
впустую. Убираем sleep, чтоб тест был мгновенным.
"""
monkeypatch.setattr(g.time, "sleep", lambda _s: None)
client = _CountingClient(status_code=500)
raised = False
try:
g.fetch_doc_card(client, "agate_ekbgo", "100001007000123") # type: ignore[arg-type]
except httpx.HTTPStatusError:
raised = True
assert raised is True
assert len(client.get_calls) == 1 # НИ одного ретрая на 5xx карточки
def test_fetch_group_list_500_retries(monkeypatch: Any) -> None:
"""Список группы 5xx ретраится с дефолтом (1 + _MAX_RETRIES попыток) — контраст с карточкой."""
monkeypatch.setattr(g.time, "sleep", lambda _s: None)
client = _CountingClient(status_code=500)
raised = False
try:
g.fetch_group_list(client, "agate_ekbgo", "DocRS") # type: ignore[arg-type]
except httpx.HTTPStatusError:
raised = True
assert raised is True
assert len(client.get_calls) == 1 + g._MAX_RETRIES # 3 попытки
def test_sync_group_circuit_breaker_aborts(monkeypatch: Any) -> None:
"""50 карточек ПОДРЯД с 5xx → группа прервана: group_aborted=1, остаток НЕ запрошен."""
monkeypatch.setattr(g.time, "sleep", lambda _s: None)
requested: list[Any] = []
def _always_fail(client: Any, schema: str, key: Any) -> dict[str, Any] | None:
requested.append(key)
raise httpx.HTTPStatusError("500", request=httpx.Request("GET", "x"), response=None) # type: ignore[arg-type]
monkeypatch.setattr(g, "fetch_doc_card", _always_fail)
db = _FakeSession(scalar_value=True)
# 60 новых ключей — breaker (50) должен сработать раньше, чем дойдём до 60-го.
items = [{**_LIST_ITEM, "key": 100001007000000 + i} for i in range(60)]
counts = _fresh_counts()
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", items, {}, counts) # type: ignore[arg-type]
assert counts["group_aborted"] == 1
assert counts["card_failed"] == g._CARD_FAIL_BREAKER # ровно 50 фейлов до аборта
assert len(requested) == g._CARD_FAIL_BREAKER # 51-й ключ НЕ запрашивался
assert counts["inserted"] == 0
def test_sync_group_success_resets_breaker(monkeypatch: Any) -> None:
"""Успешная карточка между фейлами сбрасывает breaker → группа НЕ прерывается.
49 фейлов, потом успех (reset), потом ещё 49 фейлов — суммарно 98 фейлов, но НИ разу
50 подряд → group_aborted=0, вся группа обработана.
"""
monkeypatch.setattr(g.time, "sleep", lambda _s: None)
# Индексы 49 и 99 (0-based) — успех; остальные — фейл. Между ними ≤49 подряд.
good_idx = {49, 99}
seq: list[int] = []
def _mostly_fail(client: Any, schema: str, key: Any) -> dict[str, Any] | None:
idx = len(seq)
seq.append(idx)
if idx in good_idx:
return dict(_CARD)
raise httpx.HTTPStatusError("500", request=httpx.Request("GET", "x"), response=None) # type: ignore[arg-type]
monkeypatch.setattr(g, "fetch_doc_card", _mostly_fail)
db = _FakeSession(scalar_value=True)
items = [{**_LIST_ITEM, "key": 200000000 + i} for i in range(100)]
counts = _fresh_counts()
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", items, {}, counts) # type: ignore[arg-type]
assert counts["group_aborted"] == 0 # breaker сбрасывался — не прервались
assert counts["card_failed"] == 98 # 100 2 успешных
assert counts["fetched_details"] == 2 # 2 успешные карточки
assert len(seq) == 100 # прошли ВСЮ группу
def test_sync_group_periodic_commit(monkeypatch: Any) -> None:
""">200 обработанных записей → db.commit() вызывается периодически (каждые 200)."""
monkeypatch.setattr(g.time, "sleep", lambda _s: None)
_patch_card_fetch(monkeypatch, [])
db = _FakeSession(scalar_value=True) # каждый upsert → inserted
# 450 новых ключей → 2 периодических commit (на 200-й и 400-й записи).
items = [{**_LIST_ITEM, "key": 300000000 + i} for i in range(450)]
counts = _fresh_counts()
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", items, {}, counts) # type: ignore[arg-type]
assert counts["inserted"] == 450
assert db.commit_count == 2 # 450 // 200 = 2 периодических commit
# ── Конфликт двух схем: поздний 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}