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
Веб-отчёт и лендинг с #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` — только счётчик, имена не рендерит.
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""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
|