"""Gate test (#3341): клиентский PDF не должен называть реальные площадки-источники. Канон — `frontend/src/lib/source-registry.ts::SOURCES` (публичные лейблы, закреплены для веб-отчёта/лендинга с 31.08, #3264-scope): avito/avito_imv → «Источник 1», cian/cian_valuation → «Источник 2», yandex/yandex_valuation → «Источник 3», domklik/domclick → «Источник 4», etagi/etazhi → «Источник 5», rosreestr → «Росреестр», неизвестный id → «Другой источник» (НЕ сырой id — им тоже может утечь имя площадки). WeasyPrint стаблен (как в test_pdf_security.py) — тесты дергают только HTML-билдеры. """ from __future__ import annotations import os import re import sys from datetime import UTC, datetime, timedelta from uuid import uuid4 os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from unittest.mock import MagicMock _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402 from app.services.brand import Brand # noqa: E402 from app.services.exporters import trade_in_pdf as mod # noqa: E402 _GENERIC = Brand( slug="generic", name="Trade-In", logo_url=None, primary_color="#1d4ed8", accent_color="#f59e0b", footer_text=None, pdf_disclaimer=None, ) _SNAPSHOT = { "address": "Екатеринбург, ул. Ленина, 1", "area_m2": 50.0, "rooms": 2, "floor": 3, "total_floors": 9, "year_built": 2010, "house_type": "panel", "repair_state": "standard", "has_balcony": True, } # Все известные id источников (листинги + valuation-варианты + сделки) + неизвестный. _ALL_SOURCE_IDS = [ "avito", "avito_imv", "cian", "cian_valuation", "yandex", "yandex_valuation", "domklik", "domclick", "etazhi", "rosreestr", ] # Запрещённые в видимом тексте PDF реальные имена площадок (#pdf-honesty/#3264). _FORBIDDEN_NAMES = [ "Avito", "AVITO", "Авито", "Циан", "ЦИАН", "Cian", "ДомКлик", "Домклик", "ДОМКЛИК", "Domclick", "Я.Недвижимость", "Яндекс", "Yandex", "Этажи", "Avito_Imv", "Rosreestr", ] def _analog(**overrides) -> AnalogLot: base = dict( address="ул. Тест, 1", area_m2=50.0, rooms=2, floor=3, total_floors=9, price_rub=5_000_000, price_per_m2=100_000, listing_date=None, days_on_market=None, source="avito", source_url="https://avito.ru/1", distance_m=200, ) base.update(overrides) return AnalogLot(**base) def _estimate(**overrides) -> AggregatedEstimate: base = dict( estimate_id=uuid4(), median_price_rub=10_000_000, range_low_rub=9_000_000, range_high_rub=11_000_000, median_price_per_m2=200_000, confidence="high", n_analogs=15, period_months=24, analogs=[], actual_deals=[], expires_at=datetime.now(UTC) + timedelta(days=30), ) base.update(overrides) return AggregatedEstimate(**base) def _strip_tags(html: str) -> str: """Видимый текст без разметки — href/src (легальные URL объявлений) не проверяем.""" return re.sub(r"<[^>]+>", " ", html) # ── Part A: helper-функции напрямую (source-pill / badge fallback) ─────────── @pytest.mark.parametrize("forbidden", _FORBIDDEN_NAMES) def test_source_logo_pill_never_leaks_forbidden_names(forbidden: str) -> None: """Ни один известный id, ни неизвестный не рендерят реальное имя площадки.""" html = "".join(mod._source_logo_pill(sid) for sid in [*_ALL_SOURCE_IDS, "n1"]) assert forbidden not in html @pytest.mark.parametrize("forbidden", _FORBIDDEN_NAMES) def test_source_badge_inline_never_leaks_forbidden_names(forbidden: str) -> None: html = "".join(mod._source_badge_inline(sid) for sid in [*_ALL_SOURCE_IDS, "n1"]) assert forbidden not in html def test_source_logo_pill_known_ids_map_to_public_labels() -> None: assert "Источник 1" in mod._source_logo_pill("avito") assert "Источник 1" in mod._source_logo_pill("avito_imv") assert "Источник 2" in mod._source_logo_pill("cian") assert "Источник 2" in mod._source_logo_pill("cian_valuation") assert "Источник 3" in mod._source_logo_pill("yandex") assert "Источник 3" in mod._source_logo_pill("yandex_valuation") assert "Источник 4" in mod._source_logo_pill("domklik") assert "Источник 4" in mod._source_logo_pill("domclick") assert "Источник 5" in mod._source_logo_pill("etazhi") assert "Источник 5" in mod._source_logo_pill("etagi") assert "Росреестр" in mod._source_logo_pill("rosreestr") def test_source_logo_pill_unknown_id_falls_back_to_other_source() -> None: """Неизвестный source → «Другой источник», НЕ сырой id (сам тоже мог бы назвать площадку).""" html = mod._source_logo_pill("something_new") assert "Другой источник" in html assert "Something_New" not in html assert "something_new" not in html def test_source_badge_inline_unknown_id_falls_back_to_other_source() -> None: html = mod._source_badge_inline("something_new") assert "Другой источник" in html assert "Something_New" not in html assert "something_new" not in html # ── Part B: билдеры страниц (listings/deals/cover/offer) end-to-end ────────── def test_listings_page_all_sources_no_forbidden_names() -> None: analogs = [ _analog(source=sid, source_url=f"https://example.org/{i}", distance_m=i * 10) for i, sid in enumerate([*_ALL_SOURCE_IDS, "n1"]) ] est = _estimate(sources_used=_ALL_SOURCE_IDS, analogs=analogs, n_analogs=len(analogs)) html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) text = _strip_tags(html) for forbidden in _FORBIDDEN_NAMES: assert forbidden not in text, f"{forbidden!r} leaked into listings page" # Listings page shows only listing sources (first 5 of sources_used/analogs by # distance) — rosreestr (deals) legitimately doesn't reach it here, see deals-page # test below for that assertion. assert "Источник 1" in text def test_listings_page_dedupes_alias_sources_into_single_pill() -> None: """estimator.py `_canonical_sources` — sources_used — отсортированное union listing-id + их valuation-алиасов, напр. [avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation] (#3341 review). Листинги должны рендерить РОВНО одну пилюлю на канонический источник (не по одной на алиас), и алиас обязан нести цвет точки базовой площадки, не серый fallback.""" sources_used = [ "avito", "avito_imv", "cian", "cian_valuation", "domklik", "yandex", "yandex_valuation", ] est = _estimate(sources_used=sources_used) html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) for label in ("Источник 1", "Источник 2", "Источник 3", "Источник 4"): count = html.count(label) assert count == 1, f"{label!r} should render exactly once, got {count}" # avito_imv (valuation-алиас) обязан нести тот же бренд-цвет точки, что и avito. assert "#00aaff" in mod._source_logo_pill("avito") assert "#00aaff" in mod._source_logo_pill("avito_imv") def test_deals_page_all_sources_no_forbidden_names() -> None: deals = [ _analog(source=sid, source_url=f"https://example.org/{i}", distance_m=i * 10) for i, sid in enumerate(["etazhi", "domklik", "rosreestr", "n1"]) ] est = _estimate( sources_used=["etazhi", "domklik", "rosreestr"], actual_deals=deals, ) html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC) text = _strip_tags(html) for forbidden in _FORBIDDEN_NAMES: assert forbidden not in text, f"{forbidden!r} leaked into deals page" assert "Росреестр" in text def test_cover_advice_discount_text_no_forbidden_names_without_pct() -> None: """Ветка без expected_sold_price_rub (_discount_pct → None) — только else-текст.""" est = _estimate() html = mod._build_cover(est, _SNAPSHOT, _GENERIC) text = _strip_tags(html) assert "ДомКлик" not in text assert "сделки площадок" in text assert "Росреестр" in text def test_cover_advice_discount_text_no_forbidden_names_with_pct() -> None: """С expected_sold_price_rub _discount_pct возвращает %, покрываем именно эту f-строку (review #3341: без sold-цены тест не доходил до неё).""" est = _estimate(expected_sold_price_rub=9_000_000) html = mod._build_cover(est, _SNAPSHOT, _GENERIC) text = _strip_tags(html) assert "на 10%" in text assert "сделки площадок" in text assert "ДомКлик" not in text assert "Росреестр" in text def test_offer_page_advertising_row_no_brand_names() -> None: est = _estimate() html = mod._build_offer_page(est, _SNAPSHOT, _GENERIC) text = _strip_tags(html) assert "на основных площадках объявлений" in text assert "Циан" not in text assert "Авито" not in text assert "Я.Недвижимости" not in text