feat(site-finder): per-building помещения/машино-места + parking_ratio (on-demand MVP, #96)
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (push) Successful in 8m48s
CI / backend-tests (pull_request) Successful in 8m48s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m58s
Deploy / build-worker (push) Successful in 5m46s
Deploy / deploy (push) Successful in 1m30s
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m59s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (push) Successful in 8m48s
CI / backend-tests (pull_request) Successful in 8m48s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m58s
Deploy / build-worker (push) Successful in 5m46s
Deploy / deploy (push) Successful in 1m30s
Оживляет мёртвый foundation list_objects_in_building (#168 Q3-deferred). Fix реального блокера: NSPDOptions не парсил objdocId (camelCase из NSPD search) → objdoc_id всегда None, метод нельзя было вызвать. AliasChoices(objdoc_id, objdocId) + registers. premises_lookup.get_building_premises(cad_num) — резолв objdoc → list_objects_in_building, graceful (WAF/сеть/not-found → None). parking_ratio = машино-места/помещения (None при 0 помещений, 0.0 при реальном отсутствии паркинга). Verified live 66:41:0106036:183. MVP on-demand (без bulk/схемы/prod-записей). parking_ratio готов, но НЕ wired в analyze: конкуренты приходят из ДОМ.РФ без cad_num → нужен domrf↔cad_buildings geom-match (отдельная задача). Сервис примет cad_num как только matching появится. 11 тестов, ruff clean. Refs #96
This commit is contained in:
parent
11aa011d11
commit
2c22c3f7ea
4 changed files with 347 additions and 3 deletions
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class NSPDOptions(BaseModel):
|
class NSPDOptions(BaseModel):
|
||||||
|
|
@ -42,8 +42,19 @@ class NSPDOptions(BaseModel):
|
||||||
category_type: str | None = None
|
category_type: str | None = None
|
||||||
build_record_type: str | None = None
|
build_record_type: str | None = None
|
||||||
build_record_type_value: str | None = None
|
build_record_type_value: str | None = None
|
||||||
objdoc_id: int | None = None
|
# NSPD geoportal search отдаёт objdoc/registers в camelCase (objdocId /
|
||||||
registers_id: int | None = None
|
# registersId), а bulk grid-walk иногда в snake_case. AliasChoices ловит оба
|
||||||
|
# (verified live на 66:41:0106036:183 — search вернул objdocId=40995027,
|
||||||
|
# snake-only парсинг давал None и ломал list_objects_in_building). objdoc_id —
|
||||||
|
# ключ к tab-group objectsList (помещения/машино-места).
|
||||||
|
objdoc_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("objdoc_id", "objdocId"),
|
||||||
|
)
|
||||||
|
registers_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
validation_alias=AliasChoices("registers_id", "registersId"),
|
||||||
|
)
|
||||||
okato: str | None = None
|
okato: str | None = None
|
||||||
kladr: str | None = None
|
kladr: str | None = None
|
||||||
|
|
||||||
|
|
@ -142,6 +153,19 @@ class ObjectsListing(BaseModel):
|
||||||
flats_cad_nums: list[str] = []
|
flats_cad_nums: list[str] = []
|
||||||
parking_cad_nums: list[str] = []
|
parking_cad_nums: list[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parking_ratio(self) -> float | None:
|
||||||
|
"""Обеспеченность паркингом: машино-места / помещения (#96).
|
||||||
|
|
||||||
|
Сигнал класса/дефицита для recommend: бизнес обычно ≥1.0, эконом <0.5.
|
||||||
|
None если помещений 0 (нельзя делить) — caller трактует как «нет данных»,
|
||||||
|
НЕ как 0 (graceful, не выдумываем дефицит из пустоты). Округление до 0.01
|
||||||
|
— детерминированно, без накопления float-шума при агрегации по соседям.
|
||||||
|
"""
|
||||||
|
if self.flats_count <= 0:
|
||||||
|
return None
|
||||||
|
return round(self.parking_count / self.flats_count, 2)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NSPDBulkFeature",
|
"NSPDBulkFeature",
|
||||||
|
|
|
||||||
130
backend/app/services/site_finder/premises_lookup.py
Normal file
130
backend/app/services/site_finder/premises_lookup.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Per-building помещения ЕГРН + машино-места + parking_ratio (#96, MVP on-demand).
|
||||||
|
|
||||||
|
Тонкая обёртка над уже-готовым foundation `NSPDBulkClient.list_objects_in_building`
|
||||||
|
(#168 Q3-deferred). Связывает кадастровый номер здания → objdoc_id → NSPD
|
||||||
|
tab-group `objectsList` → counts помещений/машино-мест → `parking_ratio`.
|
||||||
|
|
||||||
|
Зачем именно так (#96 scope decision):
|
||||||
|
- Полный план #96 (cad_premises/cad_parking таблицы, per-flat кадастровая
|
||||||
|
стоимость, bulk_parking_for_district, миграция) упирается в незакрытый
|
||||||
|
data-foundation gap: здания-конкуренты в analyze живут в координатах ДОМ.РФ
|
||||||
|
(obj_id + lat/lon), а НЕ имеют cad_num/objdoc_id НСПД. Массовый backfill
|
||||||
|
помещений по всем зданиям ЕКБ (38k × tab-group) — тяжёлый rate-limited
|
||||||
|
отдельный таск. Поэтому MVP: on-demand по конкретному cad_num здания, без
|
||||||
|
схемы и без bulk-сбора.
|
||||||
|
|
||||||
|
Поток (2 HTTP-запроса на здание):
|
||||||
|
1. NSPDClient.search_by_cad(building_cad) → options.objdoc_id (sync urllib)
|
||||||
|
2. NSPDBulkClient.list_objects_in_building(objdoc_id) → ObjectsListing (async)
|
||||||
|
|
||||||
|
objdoc_id из НСПД search приходит в camelCase `objdocId` — парсится через
|
||||||
|
NSPDOptions AliasChoices (см. schemas/nspd_bulk.py). Verified live на
|
||||||
|
66:41:0106036:183 (МКД, 232 помещения, 0 машино-мест).
|
||||||
|
|
||||||
|
Вызывается из синхронного кода (FastAPI threadpool handler / Celery task) —
|
||||||
|
async-часть изолирована в asyncio.run() внутри функции (паттерн poi_loader.py /
|
||||||
|
pzz_loader.py). НЕ блокирует основной event loop, т.к. analyze_parcel — `def`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.schemas.nspd_bulk import ObjectsListing
|
||||||
|
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
||||||
|
from app.services.scrapers.nspd_client import NSPDClient, NspdLiteError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# thematic_id=5 — НСПД search-фильтр «Здания» (см. NSPDClient.search_by_cad
|
||||||
|
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
||||||
|
_THEMATIC_BUILDING = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
|
||||||
|
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
||||||
|
|
||||||
|
Возвращает None если здание не найдено или НСПД не отдал objdocId (часть
|
||||||
|
объектов его не имеет). Сетевые/WAF-ошибки логируются и дают None — caller
|
||||||
|
трактует как «нет данных», analyze не падает (graceful).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = client.search_by_cad(cad_num, thematic_id=_THEMATIC_BUILDING)
|
||||||
|
except NspdLiteError as e:
|
||||||
|
# WAF/сеть/4xx — не наш баг, не валим analyze. Один building без
|
||||||
|
# parking_ratio лучше, чем 500 на весь отчёт.
|
||||||
|
logger.warning("premises_lookup: search_by_cad failed cad_num=%s: %s", cad_num, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
feature = result.first
|
||||||
|
if feature is None:
|
||||||
|
logger.info("premises_lookup: здание не найдено в НСПД cad_num=%s", cad_num)
|
||||||
|
return None
|
||||||
|
|
||||||
|
options = feature.properties.get("options") or {}
|
||||||
|
raw = options.get("objdocId") or options.get("objdoc_id")
|
||||||
|
if raw is None:
|
||||||
|
# Fallback: feature.id у зданий = objdoc (verified live на
|
||||||
|
# 66:41:0106036:183 — tab-group по feature.id вернул помещения).
|
||||||
|
raw = feature.feature_id
|
||||||
|
if raw is None:
|
||||||
|
logger.info("premises_lookup: нет objdoc_id для cad_num=%s", cad_num)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("premises_lookup: невалидный objdoc_id %r для cad_num=%s", raw, cad_num)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_listing(objdoc_id: int) -> ObjectsListing | None:
|
||||||
|
"""list_objects_in_building в одноразовом async-клиенте.
|
||||||
|
|
||||||
|
Изолированный async context (`async with`) — корректный lifecycle сокета +
|
||||||
|
semaphore под текущим event loop (см. NSPDBulkClient.__aenter__ / #260).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with NSPDBulkClient() as client:
|
||||||
|
return await client.list_objects_in_building(objdoc_id)
|
||||||
|
except NspdBulkError as e:
|
||||||
|
logger.warning("premises_lookup: list_objects failed objdoc_id=%d: %s", objdoc_id, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_building_premises(cad_num: str) -> ObjectsListing | None:
|
||||||
|
"""Помещения + машино-места + parking_ratio для здания по кад. номеру (#96).
|
||||||
|
|
||||||
|
On-demand, 2 НСПД-запроса. Возвращает None если здание не резолвится в
|
||||||
|
objdoc_id или НСПД недоступен (graceful — caller продолжает без сигнала).
|
||||||
|
`ObjectsListing.parking_ratio` = машино-места / помещения (None если
|
||||||
|
помещений 0).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cad_num: кадастровый номер ЗДАНИЯ (5-сегментный, напр.
|
||||||
|
`66:41:0106036:183`). Квартал/участок дадут пустой/нерелевантный
|
||||||
|
результат — caller обязан передавать именно здание.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ObjectsListing | None. None трактуется как «нет данных», НЕ как нулевой
|
||||||
|
паркинг (не выдумываем дефицит из недоступности НСПД).
|
||||||
|
"""
|
||||||
|
sync_client = NSPDClient()
|
||||||
|
objdoc_id = _resolve_objdoc_id(cad_num, sync_client)
|
||||||
|
if objdoc_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
listing = asyncio.run(_fetch_listing(objdoc_id))
|
||||||
|
if listing is not None:
|
||||||
|
logger.info(
|
||||||
|
"premises_lookup: cad_num=%s objdoc_id=%d flats=%d parking=%d ratio=%s",
|
||||||
|
cad_num,
|
||||||
|
objdoc_id,
|
||||||
|
listing.flats_count,
|
||||||
|
listing.parking_count,
|
||||||
|
listing.parking_ratio,
|
||||||
|
)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["get_building_premises"]
|
||||||
|
|
@ -210,6 +210,46 @@ def test_objects_listing_defaults() -> None:
|
||||||
assert listing.flats_cad_nums == []
|
assert listing.flats_cad_nums == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nspd_options_parses_camelcase_objdoc_id() -> None:
|
||||||
|
"""#96: NSPD search отдаёт objdocId/registersId camelCase → AliasChoices ловит.
|
||||||
|
|
||||||
|
Regression: snake-only парсинг давал None (verified live на
|
||||||
|
66:41:0106036:183, objdocId=40995027) и ломал list_objects_in_building.
|
||||||
|
"""
|
||||||
|
from app.schemas.nspd_bulk import NSPDOptions
|
||||||
|
|
||||||
|
opts = NSPDOptions.model_validate({"objdocId": 40995027, "registersId": 36441})
|
||||||
|
assert opts.objdoc_id == 40995027
|
||||||
|
assert opts.registers_id == 36441
|
||||||
|
|
||||||
|
|
||||||
|
def test_nspd_options_snake_case_objdoc_still_works() -> None:
|
||||||
|
"""#96: bulk grid-walk snake_case objdoc_id по-прежнему парсится (back-compat)."""
|
||||||
|
from app.schemas.nspd_bulk import NSPDOptions
|
||||||
|
|
||||||
|
opts = NSPDOptions.model_validate({"objdoc_id": 777, "registers_id": 36441})
|
||||||
|
assert opts.objdoc_id == 777
|
||||||
|
assert opts.registers_id == 36441
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_listing_parking_ratio() -> None:
|
||||||
|
"""#96: parking_ratio = машино-места / помещения, округление 0.01."""
|
||||||
|
listing = ObjectsListing(objdoc_id=1, flats_count=200, parking_count=240)
|
||||||
|
assert listing.parking_ratio == 1.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_listing_parking_ratio_none_when_no_flats() -> None:
|
||||||
|
"""#96: parking_ratio = None при flats_count=0 (нельзя делить, не выдумываем 0)."""
|
||||||
|
listing = ObjectsListing(objdoc_id=1, flats_count=0, parking_count=0)
|
||||||
|
assert listing.parking_ratio is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_objects_listing_parking_ratio_zero_parking() -> None:
|
||||||
|
"""#96: 0 машино-мест при N помещений → ratio 0.0 (реальный дефицит, не None)."""
|
||||||
|
listing = ObjectsListing(objdoc_id=1, flats_count=232, parking_count=0)
|
||||||
|
assert listing.parking_ratio == 0.0
|
||||||
|
|
||||||
|
|
||||||
# ── Unit tests: client parse logic (mocked HTTP) ─────────────────────────────
|
# ── Unit tests: client parse logic (mocked HTTP) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
150
backend/tests/services/site_finder/test_premises_lookup.py
Normal file
150
backend/tests/services/site_finder/test_premises_lookup.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"""Тесты premises_lookup (#96 MVP on-demand per-building помещения/паркинг).
|
||||||
|
|
||||||
|
Сетевые вызовы НЕ делаем — мокаем sync NSPDClient.search_by_cad (резолв objdoc)
|
||||||
|
и async NSPDBulkClient.list_objects_in_building (tab-group counts). Проверяем
|
||||||
|
happy path + graceful-фоллбэки (здание не найдено / нет objdoc / WAF).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.schemas.nspd_bulk import ObjectsListing
|
||||||
|
from app.services.scrapers.nspd_client import NspdLiteWafError
|
||||||
|
|
||||||
|
MODULE = "app.services.site_finder.premises_lookup"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_returns(listing: ObjectsListing | None) -> Any:
|
||||||
|
"""side_effect для мока asyncio.run: закрывает переданную корутину и отдаёт listing.
|
||||||
|
|
||||||
|
Без close() реальная корутина _fetch_listing(...) остаётся never-awaited
|
||||||
|
(RuntimeWarning), т.к. мок asyncio.run её не исполняет. Закрываем явно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _side_effect(coro: Any) -> ObjectsListing | None:
|
||||||
|
coro.close()
|
||||||
|
return listing
|
||||||
|
|
||||||
|
return _side_effect
|
||||||
|
|
||||||
|
|
||||||
|
def _search_result(options: dict[str, Any] | None, feature_id: str | None = "999") -> MagicMock:
|
||||||
|
"""Замокать NSPDSearchResult.first → NSPDFeature-подобный объект."""
|
||||||
|
result = MagicMock()
|
||||||
|
if options is None and feature_id is None:
|
||||||
|
result.first = None
|
||||||
|
return result
|
||||||
|
feature = MagicMock()
|
||||||
|
feature.properties = {"options": options or {}}
|
||||||
|
feature.feature_id = feature_id
|
||||||
|
result.first = feature
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_premises_happy_path() -> None:
|
||||||
|
"""search → objdocId → list_objects → ObjectsListing с parking_ratio."""
|
||||||
|
from app.services.site_finder.premises_lookup import get_building_premises
|
||||||
|
|
||||||
|
listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
|
||||||
|
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run,
|
||||||
|
):
|
||||||
|
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
|
||||||
|
{"objdocId": 40995027}
|
||||||
|
)
|
||||||
|
out = get_building_premises("66:41:0106036:183")
|
||||||
|
|
||||||
|
assert out is listing
|
||||||
|
assert out.parking_ratio == 0.5
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_premises_snake_case_objdoc() -> None:
|
||||||
|
"""objdoc_id в snake_case тоже резолвится (bulk-shape)."""
|
||||||
|
from app.services.site_finder.premises_lookup import get_building_premises
|
||||||
|
|
||||||
|
listing = ObjectsListing(objdoc_id=777, flats_count=10, parking_count=2)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
|
||||||
|
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)),
|
||||||
|
):
|
||||||
|
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
|
||||||
|
{"objdoc_id": 777}
|
||||||
|
)
|
||||||
|
out = get_building_premises("66:41:0106036:183")
|
||||||
|
|
||||||
|
assert out is listing
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_premises_fallback_to_feature_id() -> None:
|
||||||
|
"""Нет objdocId в options → fallback на feature.id (verified live: id=objdoc)."""
|
||||||
|
from app.services.site_finder.premises_lookup import get_building_premises
|
||||||
|
|
||||||
|
listing = ObjectsListing(objdoc_id=12345, flats_count=5, parking_count=0)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
|
||||||
|
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)) as mock_run,
|
||||||
|
):
|
||||||
|
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
|
||||||
|
{}, feature_id="12345"
|
||||||
|
)
|
||||||
|
out = get_building_premises("66:41:0106036:183")
|
||||||
|
|
||||||
|
assert out is listing
|
||||||
|
# objdoc_id=12345 должен был уйти в _fetch_listing
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_premises_not_found_returns_none() -> None:
|
||||||
|
"""Здание не найдено в НСПД (result.first=None) → None, async не дёргается."""
|
||||||
|
from app.services.site_finder.premises_lookup import get_building_premises
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
|
||||||
|
patch(f"{MODULE}.asyncio.run") as mock_run,
|
||||||
|
):
|
||||||
|
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
|
||||||
|
None, feature_id=None
|
||||||
|
)
|
||||||
|
out = get_building_premises("66:41:0106036:183")
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_premises_no_objdoc_returns_none() -> None:
|
||||||
|
"""Нет ни objdocId, ни feature.id → None (нельзя дёрнуть tab-group)."""
|
||||||
|
from app.services.site_finder.premises_lookup import get_building_premises
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
|
||||||
|
patch(f"{MODULE}.asyncio.run") as mock_run,
|
||||||
|
):
|
||||||
|
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
|
||||||
|
{}, feature_id=None
|
||||||
|
)
|
||||||
|
out = get_building_premises("66:41:0106036:183")
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_building_premises_waf_returns_none() -> None:
|
||||||
|
"""WAF/сеть на search_by_cad → None (graceful, analyze не падает)."""
|
||||||
|
from app.services.site_finder.premises_lookup import get_building_premises
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
|
||||||
|
patch(f"{MODULE}.asyncio.run") as mock_run,
|
||||||
|
):
|
||||||
|
mock_client_cls.return_value.search_by_cad.side_effect = NspdLiteWafError("HTTP 403 WAF")
|
||||||
|
out = get_building_premises("66:41:0106036:183")
|
||||||
|
|
||||||
|
assert out is None
|
||||||
|
mock_run.assert_not_called()
|
||||||
Loading…
Add table
Reference in a new issue