Optional query param exclude_dev (CSV, case-insensitive) drops competitor rows by dev_name from the «Конкуренты» block of the parcel snapshot PDF. Client case: generate a report without a specific developer's offer. Without the param behavior is unchanged (zero behavior change).
203 lines
7.6 KiB
Python
203 lines
7.6 KiB
Python
"""Тесты для query-параметра exclude_dev в GET /parcels/{cad}/snapshot.pdf.
|
||
|
||
Скрывает конкурентов конкретных застройщиков (dev_name) из блока «Конкуренты»
|
||
PDF-снимка. Client-кейс: убрать оффер девелопера X из отчёта для девелопера Y.
|
||
|
||
Юнит-тесты — над чистой функцией `_filter_competitors_by_dev`.
|
||
Плюс 1 endpoint smoke (mock DB + patched WeasyPrint-рендер).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.v1.parcels import _filter_competitors_by_dev
|
||
from app.main import app
|
||
|
||
|
||
def _rows() -> list[dict[str, Any]]:
|
||
return [
|
||
{"dev_name": "Практика", "comm_name": "ЖК-А", "flat_count": 100, "distance_m": 200.0},
|
||
{"dev_name": "Брусника", "comm_name": "ЖК-Б", "flat_count": 200, "distance_m": 400.0},
|
||
{"dev_name": "Атлас", "comm_name": "ЖК-В", "flat_count": 50, "distance_m": 800.0},
|
||
{"dev_name": None, "comm_name": "ЖК-Г", "flat_count": 30, "distance_m": 900.0},
|
||
]
|
||
|
||
|
||
# ── Unit: _filter_competitors_by_dev ──────────────────────────────────────────
|
||
|
||
|
||
def test_filter_none_is_noop() -> None:
|
||
"""exclude_dev=None → список идентичен исходному (zero behavior change)."""
|
||
rows = _rows()
|
||
assert _filter_competitors_by_dev(rows, None) == rows
|
||
|
||
|
||
def test_filter_empty_string_is_noop() -> None:
|
||
"""Пустая строка / только запятые → ничего не исключаем."""
|
||
rows = _rows()
|
||
assert _filter_competitors_by_dev(rows, "") == rows
|
||
assert _filter_competitors_by_dev(rows, " , ,") == rows
|
||
|
||
|
||
def test_filter_single_dev() -> None:
|
||
"""exclude_dev=Практика → строки этого застройщика исключены, остальные есть."""
|
||
kept = _filter_competitors_by_dev(_rows(), "Практика")
|
||
names = [r["dev_name"] for r in kept]
|
||
assert "Практика" not in names
|
||
assert "Брусника" in names
|
||
assert "Атлас" in names
|
||
|
||
|
||
def test_filter_case_insensitive() -> None:
|
||
"""Регистронезависимо: 'практика' / 'ПРАКТИКА' исключают 'Практика'."""
|
||
for variant in ("практика", "ПРАКТИКА", "ПрАкТиКа"):
|
||
kept = _filter_competitors_by_dev(_rows(), variant)
|
||
assert "Практика" not in [r["dev_name"] for r in kept]
|
||
|
||
|
||
def test_filter_multi_dev() -> None:
|
||
"""exclude_dev=Практика,Брусника → обе исключены, прочие остаются."""
|
||
kept = _filter_competitors_by_dev(_rows(), "Практика,Брусника")
|
||
names = [r["dev_name"] for r in kept]
|
||
assert "Практика" not in names
|
||
assert "Брусника" not in names
|
||
assert "Атлас" in names
|
||
assert None in names # dev_name=None не исключается
|
||
|
||
|
||
def test_filter_none_dev_name_not_excluded() -> None:
|
||
"""Строка с dev_name=None не падает и не исключается даже при фильтре."""
|
||
kept = _filter_competitors_by_dev(_rows(), "Практика")
|
||
assert any(r["dev_name"] is None for r in kept)
|
||
|
||
|
||
def test_filter_whitespace_in_csv() -> None:
|
||
"""Пробелы вокруг имён в CSV тримятся."""
|
||
kept = _filter_competitors_by_dev(_rows(), " Практика , Брусника ")
|
||
names = [r["dev_name"] for r in kept]
|
||
assert "Практика" not in names
|
||
assert "Брусника" not in names
|
||
|
||
|
||
# ── Endpoint smoke: mock DB + patched render ──────────────────────────────────
|
||
|
||
|
||
def _parcel_row() -> MagicMock:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {
|
||
"address": "ул. Тестовая, 1",
|
||
"area_m2": 5000.0,
|
||
"land_category": "земли населённых пунктов",
|
||
"vri": "многоэтажная застройка",
|
||
"cadastral_cost": 10_000_000.0,
|
||
"last_update": None,
|
||
}[k]
|
||
return r
|
||
|
||
|
||
def _geom_row() -> MagicMock:
|
||
r = MagicMock()
|
||
r.__getitem__ = lambda self, k: {"wkt": "POINT(60.605 56.838)"}[k]
|
||
return r
|
||
|
||
|
||
def _comp_mapping(dev_name: str | None, obj_id: int) -> dict[str, Any]:
|
||
return {
|
||
"obj_id": obj_id,
|
||
"comm_name": f"ЖК-{obj_id}",
|
||
"dev_name": dev_name,
|
||
"obj_class": "comfort",
|
||
"flat_count": 100,
|
||
"distance_m": 100.0 * obj_id,
|
||
}
|
||
|
||
|
||
def _make_snapshot_db() -> MagicMock:
|
||
"""Порядок execute в parcel_snapshot_pdf:
|
||
1. parcel_row → .mappings().first()
|
||
2. geom → .mappings().first()
|
||
3. poi → .mappings().all()
|
||
4. competitors→ .mappings().all()
|
||
5. district → .mappings().first()
|
||
"""
|
||
db = MagicMock()
|
||
|
||
res_parcel = MagicMock()
|
||
res_parcel.mappings.return_value.first.return_value = _parcel_row()
|
||
|
||
res_geom = MagicMock()
|
||
res_geom.mappings.return_value.first.return_value = _geom_row()
|
||
|
||
res_poi = MagicMock()
|
||
res_poi.mappings.return_value.all.return_value = []
|
||
|
||
res_comp = MagicMock()
|
||
res_comp.mappings.return_value.all.return_value = [
|
||
_comp_mapping("Практика", 1),
|
||
_comp_mapping("Брусника", 2),
|
||
]
|
||
|
||
res_district = MagicMock()
|
||
res_district.mappings.return_value.first.return_value = None
|
||
|
||
db.execute.side_effect = [res_parcel, res_geom, res_poi, res_comp, res_district]
|
||
return db
|
||
|
||
|
||
def _override_db(db: MagicMock):
|
||
def _get_db_override():
|
||
yield db
|
||
|
||
return _get_db_override
|
||
|
||
|
||
def test_snapshot_endpoint_exclude_dev_filters_render_input(monkeypatch) -> None:
|
||
"""С exclude_dev=Практика конкурент с dev_name='Практика' не доходит до рендера."""
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_render(**kwargs: Any) -> bytes:
|
||
captured["competitor_rows"] = kwargs.get("competitor_rows")
|
||
return b"%PDF-1.4 fake"
|
||
|
||
monkeypatch.setattr("app.api.v1.parcels.generate_snapshot_pdf", _fake_render)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(_make_snapshot_db())
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.get("/api/v1/parcels/66:41:0303161:5/snapshot.pdf?exclude_dev=Практика")
|
||
assert resp.status_code == 200, resp.text
|
||
devs = [r["dev_name"] for r in captured["competitor_rows"]]
|
||
assert "Практика" not in devs
|
||
assert "Брусника" in devs
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_snapshot_endpoint_no_param_keeps_all(monkeypatch) -> None:
|
||
"""Без exclude_dev все конкуренты доходят до рендера (baseline)."""
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _fake_render(**kwargs: Any) -> bytes:
|
||
captured["competitor_rows"] = kwargs.get("competitor_rows")
|
||
return b"%PDF-1.4 fake"
|
||
|
||
monkeypatch.setattr("app.api.v1.parcels.generate_snapshot_pdf", _fake_render)
|
||
|
||
from app.core.db import get_db
|
||
|
||
app.dependency_overrides[get_db] = _override_db(_make_snapshot_db())
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.get("/api/v1/parcels/66:41:0303161:5/snapshot.pdf")
|
||
assert resp.status_code == 200, resp.text
|
||
devs = [r["dev_name"] for r in captured["competitor_rows"]]
|
||
assert "Практика" in devs
|
||
assert "Брусника" in devs
|
||
finally:
|
||
app.dependency_overrides.clear()
|