"""Тесты для 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()