diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 341fea48..7c5125ae 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -290,8 +290,9 @@ matplotlib.rcParams["font.family"] = "Manrope" # ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─ # Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится -# через безопасный fallback в _source_pill: серый фон + source.title() — код на -# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета +# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 — +# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется) +# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета # сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы — # намеренно не конвертируются в наши OKLCH-токены. _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = { @@ -303,15 +304,59 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = { "etazhi": ("#e30613", "#fff"), # Этажи красный } +# Публичные лейблы — канон `frontend/src/lib/source-registry.ts::SOURCES` (#3341, +# класс #3264): клиентский PDF не должен называть площадки-источники так же, как +# веб-отчёт и лендинг перестали делать это с 31.08. Ключи — ТОЛЬКО базовые id +# (алиасы валюации/написания резолвятся через _SOURCE_CANONICAL/_canonical_source +# ДО обращения сюда — иначе два источника правды для одной площадки). _SOURCE_DISPLAY_NAMES: dict[str, str] = { - "avito": "Avito", - "cian": "Циан", - "domklik": "Домклик · Сбер", - "yandex": "Я.Недвижимость", + "avito": "Источник 1", + "cian": "Источник 2", + "yandex": "Источник 3", + "domklik": "Источник 4", + "etazhi": "Источник 5", "rosreestr": "Росреестр", - "etazhi": "Этажи", } +# Алиас → базовый id. Зеркалит группировку publicLabel/dot в +# frontend/src/lib/source-registry.ts (одна площадка = один номер/цвет там же); +# правишь один реестр — проверь другой (#3341 review: без канонизации +# estimate.sources_used = [avito, avito_imv, cian, cian_valuation, ...] +# (estimator.py `_canonical_sources`, listing+valuation union) рендерил +# ДВЕ одинаковые пилюли «Источник 1, Источник 1» и серую точку у алиаса — +# ни avito_imv/cian_valuation/yandex_valuation/domclick/etagi не было ключом +# ни в _SOURCE_LOGO_COLORS, ни (после дедупа лейблов) в _SOURCE_DISPLAY_NAMES). +_SOURCE_CANONICAL: dict[str, str] = { + "avito_imv": "avito", + "cian_valuation": "cian", + "yandex_valuation": "yandex", + "domclick": "domklik", + "etagi": "etazhi", +} + + +def _canonical_source(source: str) -> str: + """Алиас (valuation-вариант / альтернативное написание) → базовый id площадки.""" + return _SOURCE_CANONICAL.get(source, source) + + +def _public_sources(ids: list[str]) -> list[str]: + """Канонизирует id и дедуплицирует с сохранением порядка первого появления. + + `estimate.sources_used` — отсортированное объединение listing-id и их + valuation-алиасов (estimator.py `_canonical_sources`), напр. + `[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]`. + Без канонизации+дедупа ДО среза `[:5]` рендерились дубли лейблов и «Источник 3» + (yandex) мог быть вытеснен алиасом другой площадки (#3341 review).""" + seen: set[str] = set() + result: list[str] = [] + for raw in ids: + canon = _canonical_source(raw) + if canon not in seen: + seen.add(canon) + result.append(canon) + return result + def _source_logo_pill(source: str) -> str: """Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома): @@ -319,10 +364,12 @@ def _source_logo_pill(source: str) -> str: непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра (Mera v2), бренд-цвет остаётся только акцентной точкой. Название — из _SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов) — - overflow-wrap на всякий случай, если source незнаком и попадёт .title() - произвольной длины (см. fallback ниже).""" - dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0] - name = _SOURCE_DISPLAY_NAMES.get(source, source.title()) + overflow-wrap на всякий случай, если source незнаком (см. fallback ниже). + Канонизирует алиасы (avito_imv → avito и т.п.) ДО lookup, чтобы цвет/лейбл + совпадали с базовой площадкой (#3341 review).""" + canon = _canonical_source(source) + dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0] + name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник") return ( "" - bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff")) - name = _SOURCE_DISPLAY_NAMES.get(source, source.title()) + canon = _canonical_source(source) + bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff")) + name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник") return ( f"{_html.escape(name)}" @@ -1167,12 +1216,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s advice_discount_text = ( f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту " f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, " - f"ДомКлик и продажи агентств недвижимости" + f"сделки площадок и продажи агентств недвижимости" ) else: advice_discount_text = ( "Фактические сделки проходят ниже цен в объявлениях, что подтверждают " - "Росреестр, ДомКлик и продажи агентств недвижимости" + "Росреестр, сделки площадок и продажи агентств недвижимости" ) disclaimer_html = "" @@ -1357,7 +1406,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra # count «с учётом ремонта» не существует, второе число было идентично n_total. # Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список). - sources_to_show = estimate.sources_used or [] + # #3341 review: sources_used — union listing+valuation алиасов (estimator.py + # `_canonical_sources`), напр. [avito, avito_imv, cian, cian_valuation, ...] — + # _public_sources канонизирует+дедуплицирует ДО среза [:5], иначе дубли пилюль + # и «Источник 3» мог быть вытеснен алиасом другой площадки. + sources_to_show = _public_sources(estimate.sources_used or []) sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5]) # Params правой колонки — параметры поиска (НЕ конкретной квартиры) @@ -1598,10 +1651,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) # Источники для сделок — берём из estimate.sources_used (не захардкоженный список). # Фильтруем по известным источникам сделок; fallback к пустому (не fabricate). + # #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же + # причины, что и на странице листингов (см. коммент там). _deal_source_keys = {"etazhi", "domklik", "rosreestr"} - deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys] + canonical_sources = _public_sources(estimate.sources_used or []) + deal_sources = [s for s in canonical_sources if s in _deal_source_keys] if not deal_sources: - deal_sources = [s for s in (estimate.sources_used or [])] + deal_sources = canonical_sources sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5]) area = float(input_snapshot.get("area_m2", 0) or 0) @@ -1847,7 +1903,7 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
Расходы на рекламу
Ежемесячное базовое продвижение объекта - на Циан, Авито, Я.Недвижимости
+ на основных площадках объявлений бесплатно diff --git a/tradein-mvp/backend/tests/test_pdf_public_source_labels.py b/tradein-mvp/backend/tests/test_pdf_public_source_labels.py new file mode 100644 index 00000000..2be05837 --- /dev/null +++ b/tradein-mvp/backend/tests/test_pdf_public_source_labels.py @@ -0,0 +1,267 @@ +"""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 diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index 7789b8e0..633133c7 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -381,13 +381,14 @@ def test_listings_page_no_crash_with_no_sources() -> None: def test_listings_sources_from_estimate_not_hardcoded() -> None: - """sources_used=['yandex'] → только yandex pill, не avito/cian/domklik/...""" + """sources_used=['yandex'] → только источник 3 (yandex) pill, не 1/2/4/... (#3341: + публичные лейблы, не реальные названия площадок).""" est = _estimate(sources_used=["yandex"]) html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) - assert "Я.Недвижимость" in html + assert "Источник 3" in html # avito and cian should NOT appear if not in sources_used - assert "Avito" not in html - assert "Циан" not in html + assert "Источник 1" not in html + assert "Источник 2" not in html def test_deals_sources_from_estimate_not_hardcoded() -> None: @@ -395,9 +396,9 @@ def test_deals_sources_from_estimate_not_hardcoded() -> None: est = _estimate(sources_used=["rosreestr"]) html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC) assert "Росреестр" in html - # Этажи/Домклик — не должны появиться - assert "Этажи" not in html - assert "Домклик" not in html + # источник 5 (этажи) / источник 4 (домклик) — не должны появиться (#3341) + assert "Источник 5" not in html + assert "Источник 4" not in html def test_examples_rows_none_price_per_m2_no_crash() -> None: