gendesign/tradein-mvp/backend/tests/test_pdf_public_source_labels.py
bot-backend f2f9ac941b
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
fix(tradein/pdf): клиентский PDF не называет площадки-источники — «Источник N», как в веб-отчёте (#3341)
Веб-отчёт и лендинг с #3342 не показывают названия площадок (канон publicLabel в
frontend/src/lib/source-registry.ts), а клиентский PDF по тому же /estimate/{id}
печатал Avito / Циан / «Домклик · Сбер» / Я.Недвижимость / Этажи в пилюлях
источников, «подтверждают Росреестр, ДомКлик…» в советах и «на Циан, Авито,
Я.Недвижимости» в тарифах. Один клиент — два документа с разной нормой, и юр-риск,
ради которого всё делалось, в PDF оставался открытым.

- `_SOURCE_DISPLAY_NAMES` → публичные лейблы 1:1 с реестром фронта: avito → «Источник 1»,
  cian → 2, yandex → 3, domklik → 4, etazhi → 5, rosreestr → «Росреестр»; fallback для
  незнакомого id — «Другой источник», а не `source.title()` (сырой id — та же утечка).
- Алиасы (avito_imv, cian_valuation, yandex_valuation, domclick, etagi) канонизируются
  ДО выбора цвета и лейбла (`_canonical_source`), а списки источников на страницах
  объявлений и сделок дедуплицируются до среза [:5] (`_public_sources`): иначе
  `sources_used` = listing ∪ valuation давал «Источник 1, Источник 1, Источник 2,
  Источник 2, Источник 4» с серой точкой у алиасов и вытеснял yandex.
- Цвета пилюль не тронуты: цвет — опознаватель источника, как на вебе.
- Тексты: «Росреестр, сделки площадок и продажи агентств», «на основных площадках
  объявлений» (двойник offer-rates.ts).
- Гейт `tests/test_pdf_public_source_labels.py`: видимый текст страниц (без тегов и
  атрибутов, href на домены площадок законны) не содержит названий площадок и сырых
  id, по одному кейсу на имя; дедуп алиасов; ветка совета с процентом.

Не тронуто: ссылки на объявления (avito.ru/domclick.ru) — отдельное решение;
`_QUALITY_SOURCE_SLOTS` — только счётчик, имена не рендерит.
2026-09-03 11:58:45 +03:00

267 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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