"""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}