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` — только счётчик, имена не рендерит.
561 lines
23 KiB
Python
561 lines
23 KiB
Python
"""PDF security + correctness tests (#7/#9/#13/#33).
|
||
|
||
Tests cover:
|
||
- Part A (#7): brand derived from owner, not query param (tested at service level via
|
||
generate_trade_in_pdf — API-layer change verified separately via grep).
|
||
- Part B (#9): insufficient_data=True → empty-state page, NOT numeric report / loss table.
|
||
- Part C (#8): pdf_disclaimer / logo_url from brand object rendered on cover.
|
||
- Part D (#13): _safe_logo_url rejects file:// / non-allowlist; _safe_color rejects
|
||
non-hex; _make_safe_url_fetcher blocks file:// scheme.
|
||
- Part E (#33): n_with_repair real count; sources_used respected; None fields no crash.
|
||
|
||
WeasyPrint is stubbed — tests exercise HTML builders only (consistent with
|
||
tests/services/test_trade_in_pdf_dual_price.py pattern).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from datetime import UTC, datetime, timedelta
|
||
from unittest.mock import MagicMock, patch
|
||
from uuid import uuid4
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
_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
|
||
|
||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||
|
||
_GENERIC = Brand(
|
||
slug="generic",
|
||
name="Trade-In",
|
||
logo_url=None,
|
||
primary_color="#1d4ed8",
|
||
accent_color="#f59e0b",
|
||
footer_text=None,
|
||
pdf_disclaimer=None,
|
||
)
|
||
|
||
_PRAKTIKA = Brand(
|
||
slug="praktika",
|
||
name="Практика",
|
||
logo_url="https://gk-praktika.ru/images/logo/logo-pink.svg",
|
||
primary_color="#235F49",
|
||
accent_color="#1FCECB",
|
||
footer_text="ГК «Практика» · Екатеринбург",
|
||
pdf_disclaimer="Отчёт носит ориентировочный характер. ГК Практика, 2026.",
|
||
)
|
||
|
||
_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,
|
||
}
|
||
|
||
|
||
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 _zero_estimate(**overrides) -> AggregatedEstimate:
|
||
"""Оценка с median=0 — insufficient_data=True."""
|
||
base = dict(
|
||
estimate_id=uuid4(),
|
||
median_price_rub=0,
|
||
range_low_rub=0,
|
||
range_high_rub=0,
|
||
median_price_per_m2=0,
|
||
confidence="low",
|
||
n_analogs=0,
|
||
period_months=24,
|
||
analogs=[],
|
||
actual_deals=[],
|
||
expires_at=datetime.now(UTC) + timedelta(days=30),
|
||
)
|
||
base.update(overrides)
|
||
return AggregatedEstimate(**base)
|
||
|
||
|
||
# ── Part B (#9): insufficient_data guard ─────────────────────────────────────
|
||
|
||
|
||
def test_insufficient_data_renders_empty_state_not_price() -> None:
|
||
"""median=0 → _build_insufficient_data_page called, no numeric headline."""
|
||
est = _zero_estimate()
|
||
assert est.insufficient_data is True
|
||
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Недостаточно данных" in html
|
||
# no fabricated price / loss table
|
||
assert "млн. руб." not in html
|
||
assert "Общие финансовые потери" not in html
|
||
|
||
|
||
def test_insufficient_data_page_contains_address_and_report_num() -> None:
|
||
est = _zero_estimate()
|
||
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Ленина" in html # address
|
||
# #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан
|
||
# → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта).
|
||
assert "МЕРА-" in html
|
||
assert "EKБ-" not in html # старый mixed-script хардкод убран
|
||
|
||
|
||
def test_generate_pdf_insufficient_skips_offer_page() -> None:
|
||
"""generate_trade_in_pdf с insufficient_data → offer page (loss table) не рендерится."""
|
||
est = _zero_estimate()
|
||
# Stub WeasyPrint HTML/CSS so we can inspect the html_str fed to it.
|
||
captured: list[str] = []
|
||
|
||
class _FakeHTML:
|
||
def __init__(self, string=None, base_url=None, **kw):
|
||
captured.append(string or "")
|
||
|
||
def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None):
|
||
return b"%PDF-fake"
|
||
|
||
class _FakeCSS:
|
||
def __init__(self, string=None, font_config=None, url_fetcher=None):
|
||
pass
|
||
|
||
with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}):
|
||
import app.services.exporters.trade_in_pdf as _mod
|
||
|
||
_mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC)
|
||
|
||
assert captured, "HTML was never passed to WeasyPrint mock"
|
||
html = captured[0]
|
||
assert "Недостаточно данных" in html
|
||
assert "Общие финансовые потери" not in html
|
||
assert "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ" not in html
|
||
|
||
|
||
def test_generate_pdf_normal_estimate_has_offer_page() -> None:
|
||
"""Полная оценка → offer page присутствует."""
|
||
est = _estimate()
|
||
captured: list[str] = []
|
||
|
||
class _FakeHTML:
|
||
def __init__(self, string=None, base_url=None, **kw):
|
||
captured.append(string or "")
|
||
|
||
def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None):
|
||
return b"%PDF-fake"
|
||
|
||
class _FakeCSS:
|
||
def __init__(self, string=None, font_config=None, url_fetcher=None):
|
||
pass
|
||
|
||
with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}):
|
||
import app.services.exporters.trade_in_pdf as _mod
|
||
|
||
_mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC)
|
||
|
||
assert captured
|
||
html = captured[0]
|
||
assert "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ" in html
|
||
|
||
|
||
# ── Part C (#8): disclaimer + logo_url in cover ──────────────────────────────
|
||
|
||
|
||
def test_cover_renders_pdf_disclaimer() -> None:
|
||
est = _estimate()
|
||
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA)
|
||
assert "Отчёт носит ориентировочный характер" in html
|
||
|
||
|
||
def test_cover_disclaimer_absent_when_none() -> None:
|
||
est = _estimate()
|
||
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||
# No stray disclaimer block
|
||
assert "ориентировочный характер" not in html
|
||
|
||
|
||
def test_cover_renders_logo_img_when_valid_url() -> None:
|
||
# #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
|
||
# ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
|
||
# печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
|
||
# trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
|
||
# через _page_header, не через _build_cover (симметрично уже существовавшему
|
||
# паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
|
||
header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
||
assert "<img" in header_html
|
||
assert "gk-praktika.ru" in header_html
|
||
|
||
|
||
def test_cover_falls_back_to_name_when_logo_none() -> None:
|
||
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
|
||
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
|
||
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
|
||
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
||
assert "TRADE-IN" in header_html.upper()
|
||
assert "<img" not in header_html
|
||
|
||
|
||
# ── Part D (#13): _safe_logo_url ─────────────────────────────────────────────
|
||
|
||
|
||
def test_safe_logo_url_accepts_allowlisted_https() -> None:
|
||
url = "https://gk-praktika.ru/images/logo.svg"
|
||
assert mod._safe_logo_url(url) == url
|
||
|
||
|
||
def test_safe_logo_url_rejects_file_scheme() -> None:
|
||
assert mod._safe_logo_url("file:///etc/passwd") is None
|
||
|
||
|
||
def test_safe_logo_url_rejects_http() -> None:
|
||
assert mod._safe_logo_url("http://gk-praktika.ru/logo.svg") is None
|
||
|
||
|
||
def test_safe_logo_url_rejects_unknown_domain() -> None:
|
||
assert mod._safe_logo_url("https://evil.com/logo.svg") is None
|
||
|
||
|
||
def test_safe_logo_url_accepts_data_uri() -> None:
|
||
data = "data:image/svg+xml;base64,PHN2Zy8+"
|
||
assert mod._safe_logo_url(data) == data
|
||
|
||
|
||
def test_safe_logo_url_none_input() -> None:
|
||
assert mod._safe_logo_url(None) is None
|
||
|
||
|
||
# ── Part D (#13): _safe_color ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_safe_color_accepts_valid_hex() -> None:
|
||
assert mod._safe_color("#1d4ed8", "#000000") == "#1d4ed8"
|
||
assert mod._safe_color("#AbCdEf", "#000000") == "#AbCdEf"
|
||
|
||
|
||
def test_safe_color_rejects_css_expression() -> None:
|
||
assert mod._safe_color("expression(alert(1))", "#1d4ed8") == "#1d4ed8"
|
||
|
||
|
||
def test_safe_color_rejects_short_hex() -> None:
|
||
# 3-digit hex not accepted (require strict 6-digit)
|
||
assert mod._safe_color("#abc", "#1d4ed8") == "#1d4ed8"
|
||
|
||
|
||
def test_safe_color_rejects_none() -> None:
|
||
assert mod._safe_color(None, "#1d4ed8") == "#1d4ed8"
|
||
|
||
|
||
# ── Part D (#13): url_fetcher blocks file:// ─────────────────────────────────
|
||
|
||
|
||
def test_url_fetcher_rejects_file_scheme() -> None:
|
||
"""_make_safe_url_fetcher returns a fetcher that raises on file:// URLs."""
|
||
fake_default = MagicMock(return_value={"content": b"fake"})
|
||
urls_mod = MagicMock()
|
||
urls_mod.default_url_fetcher = fake_default
|
||
fetcher = mod._make_safe_url_fetcher()
|
||
with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
|
||
with pytest.raises(ValueError, match="not allowed"):
|
||
fetcher("file:///etc/passwd")
|
||
|
||
|
||
def test_url_fetcher_allows_data_uri() -> None:
|
||
fake_default = MagicMock(return_value={"content": b"fake"})
|
||
urls_mod = MagicMock()
|
||
urls_mod.default_url_fetcher = fake_default
|
||
fetcher = mod._make_safe_url_fetcher()
|
||
with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
|
||
result = fetcher("data:image/svg+xml;base64,PHN2Zy8+")
|
||
fake_default.assert_called_once()
|
||
assert result == {"content": b"fake"}
|
||
|
||
|
||
def test_url_fetcher_allows_https() -> None:
|
||
fake_default = MagicMock(return_value={"content": b"fake"})
|
||
urls_mod = MagicMock()
|
||
urls_mod.default_url_fetcher = fake_default
|
||
fetcher = mod._make_safe_url_fetcher()
|
||
with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
|
||
fetcher("https://gk-praktika.ru/logo.svg")
|
||
fake_default.assert_called_once()
|
||
|
||
|
||
def test_url_fetcher_rejects_http() -> None:
|
||
fake_default = MagicMock(return_value={"content": b"fake"})
|
||
urls_mod = MagicMock()
|
||
urls_mod.default_url_fetcher = fake_default
|
||
fetcher = mod._make_safe_url_fetcher()
|
||
with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}):
|
||
with pytest.raises(ValueError, match="not allowed"):
|
||
fetcher("http://example.com/style.css")
|
||
|
||
|
||
# ── Part D (#13): base_url no longer "file:///" ───────────────────────────────
|
||
|
||
|
||
def test_generate_pdf_no_file_base_url() -> None:
|
||
"""generate_trade_in_pdf must NOT pass base_url='file:///' to WeasyPrint."""
|
||
est = _estimate()
|
||
html_calls: list[dict] = []
|
||
|
||
class _FakeHTML:
|
||
def __init__(self, string=None, base_url=None, **kw):
|
||
html_calls.append({"base_url": base_url})
|
||
|
||
def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None):
|
||
return b"%PDF-fake"
|
||
|
||
class _FakeCSS:
|
||
def __init__(self, string=None, font_config=None, url_fetcher=None):
|
||
pass
|
||
|
||
with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}):
|
||
import app.services.exporters.trade_in_pdf as _mod
|
||
|
||
_mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC)
|
||
|
||
assert html_calls, "HTML() was never called"
|
||
base_url = html_calls[0]["base_url"]
|
||
assert base_url != "file:///", f"base_url should NOT be 'file:///' (got {base_url!r})"
|
||
|
||
|
||
# ── Part E (#33): n_with_repair + sources + None-guards ──────────────────────
|
||
|
||
|
||
def _analog(**overrides) -> AnalogLot:
|
||
base = dict(
|
||
address="ул. Тест, 1",
|
||
source="avito",
|
||
source_url="https://avito.ru/1",
|
||
price_rub=5_000_000,
|
||
price_per_m2=100_000,
|
||
area_m2=50.0,
|
||
rooms=2,
|
||
floor=3,
|
||
total_floors=9,
|
||
listing_date=None,
|
||
days_on_market=None,
|
||
distance_m=200,
|
||
)
|
||
base.update(overrides)
|
||
return AnalogLot(**base)
|
||
|
||
|
||
def test_n_with_repair_equals_n_analogs_not_fabricated() -> None:
|
||
"""n_with_repair должен совпадать с n_analogs (не fabricated ~40%)."""
|
||
est = _estimate(n_analogs=12, sources_used=["avito"])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
# both counts should show 12 (no fabricated sub-count)
|
||
assert "12 шт." in html
|
||
|
||
|
||
def test_listings_page_no_crash_with_no_sources() -> None:
|
||
est = _estimate(sources_used=[])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "РЫНОК КВАРТИР" in html
|
||
|
||
|
||
def test_listings_sources_from_estimate_not_hardcoded() -> None:
|
||
"""sources_used=['yandex'] → только источник 3 (yandex) pill, не 1/2/4/... (#3341:
|
||
публичные лейблы, не реальные названия площадок)."""
|
||
est = _estimate(sources_used=["yandex"])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Источник 3" in html
|
||
# avito and cian should NOT appear if not in sources_used
|
||
assert "Источник 1" not in html
|
||
assert "Источник 2" not in html
|
||
|
||
|
||
def test_deals_sources_from_estimate_not_hardcoded() -> None:
|
||
"""sources_used=['rosreestr'] → только rosreestr badges на deals-странице."""
|
||
est = _estimate(sources_used=["rosreestr"])
|
||
html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Росреестр" 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:
|
||
"""AnalogLot с price_per_m2=None — _examples_rows не падает, рендерит «—»."""
|
||
# price_per_m2 is typed int (not Optional) in AnalogLot, but defensively
|
||
# the renderer must guard it. We pass a valid lot and verify no crash.
|
||
lot = _analog(price_per_m2=100_000, days_on_market=None)
|
||
html = mod._examples_rows([lot])
|
||
assert "100 000" in html
|
||
assert "5 000 000" in html
|
||
|
||
|
||
def test_examples_rows_empty_list_no_crash() -> None:
|
||
html = mod._examples_rows([])
|
||
assert "Нет данных" in html
|
||
|
||
|
||
def test_build_listings_page_none_year_built_no_crash() -> None:
|
||
snap = dict(_SNAPSHOT)
|
||
snap["year_built"] = None
|
||
est = _estimate()
|
||
html = mod._build_listings_page(est, snap, _GENERIC)
|
||
assert "РЫНОК КВАРТИР" in html
|
||
|
||
|
||
# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ────────
|
||
# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py
|
||
# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list
|
||
# (thin listings kept as reference cards) used to print "0 шт." above a
|
||
# non-empty examples table — a client-visible contradiction that leaked into
|
||
# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_
|
||
# listings_note_html / _reliability_note_html.
|
||
|
||
|
||
def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None:
|
||
"""The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' —
|
||
falls back to the actually-shown population (3) and adds an honest
|
||
deals-sourced footnote."""
|
||
analogs = [
|
||
_analog(address="ул. Льва Толстого, 8А"),
|
||
_analog(address="ул. Кирова, 4"),
|
||
_analog(address="ул. Льва Толстого, 34"),
|
||
]
|
||
est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "0 шт." not in html
|
||
assert "3 шт." in html
|
||
assert "Оценка построена по зарегистрированным сделкам Росреестра" in html
|
||
assert "почти нет" in html
|
||
|
||
|
||
def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None:
|
||
"""Control: genuinely zero listings (no cards to show either) — '0 шт.' is
|
||
honest here, and the deals-sourced footnote (which explains a MISMATCH)
|
||
must NOT appear since there is nothing to reconcile."""
|
||
est = _estimate(n_analogs=0, analogs=[])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "0 шт." in html
|
||
assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html
|
||
|
||
|
||
def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None:
|
||
"""Control/regression guard for the max() choice: a healthy sample where
|
||
n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's
|
||
own top-10 cap) must keep printing the full honest count (15 шт.), NOT
|
||
silently understate it to len(analogs) (10 шт.)."""
|
||
analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)]
|
||
est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "15 шт." in html
|
||
assert "10 шт." not in html
|
||
|
||
|
||
def test_listings_page_relaxations_warning_shown_with_labels() -> None:
|
||
"""relaxations non-empty → warning block present, names the labels, and
|
||
reliability != 'ok' — mirrors what the web LowConfidenceBanner already
|
||
shows (see AggregatedEstimate docstring)."""
|
||
est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low")
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Точность оценки снижена." in html
|
||
assert "учтены студии" in html
|
||
assert "радиус расширен до 3000 м" in html
|
||
|
||
|
||
def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None:
|
||
"""reliability != 'ok' but relaxations is empty (cascade couldn't grow a
|
||
thin sample, estimator.py #oblast-F) → warning block still shown, with a
|
||
fallback sentence (not an empty label list)."""
|
||
est = _estimate(n_analogs=2, reliability="very_low", relaxations=[])
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Точность оценки снижена." in html
|
||
assert "небольшой выборке" in html
|
||
|
||
|
||
def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None:
|
||
"""Control: the common/unrelaxed case (reliability='ok' default, no
|
||
relaxations) — no warning block at all, byte-identical to the report
|
||
before these fields existed."""
|
||
est = _estimate()
|
||
assert est.reliability == "ok"
|
||
assert est.relaxations == []
|
||
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
|
||
assert "Точность оценки снижена." not in html
|
||
|
||
|
||
def test_build_deals_page_none_year_built_no_crash() -> None:
|
||
snap = dict(_SNAPSHOT)
|
||
snap["year_built"] = None
|
||
est = _estimate()
|
||
html = mod._build_deals_page(est, snap, _GENERIC)
|
||
assert "СДЕЛКИ" in html.upper()
|
||
|
||
|
||
# ── Part A (#7): brand derived from owner (service-level check) ───────────────
|
||
|
||
|
||
def test_brand_not_taken_from_query_param_docstring() -> None:
|
||
"""Verify the PDF endpoint no longer has a 'brand' query parameter."""
|
||
import inspect
|
||
|
||
from app.api.v1.trade_in import estimate_pdf
|
||
|
||
sig = inspect.signature(estimate_pdf)
|
||
param_names = list(sig.parameters.keys())
|
||
assert "brand" not in param_names, (
|
||
"estimate_pdf should NOT have a 'brand' query param after #7 fix"
|
||
)
|
||
|
||
|
||
# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ──
|
||
|
||
|
||
def test_cover_no_retain_until_row_when_unpaid() -> None:
|
||
"""retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна
|
||
до' row at all — B2B regression guard, cover renders bit-for-bit as before."""
|
||
est = _estimate()
|
||
assert est.retain_until is None
|
||
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||
assert "Ссылка доступна до" not in html
|
||
|
||
|
||
def test_cover_renders_retain_until_row_when_paid() -> None:
|
||
"""retain_until IS NOT NULL → 'Ссылка доступна до <date>' row present, with
|
||
its OWN date (not conflated with 'Срок действия данных' / expires_at)."""
|
||
retain = datetime(2027, 8, 6, tzinfo=UTC)
|
||
est = _estimate(retain_until=retain)
|
||
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||
assert "Ссылка доступна до" in html
|
||
assert "06.08.2027" in html
|
||
|
||
|
||
def test_expires_date_unaffected_by_retain_until() -> None:
|
||
"""«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to
|
||
expires_at regardless of retain_until — it is data-actuality, not the
|
||
paid-access retention window, and must not move when a report is paid."""
|
||
expires = datetime.now(UTC) + timedelta(hours=24)
|
||
est_unpaid = _estimate(expires_at=expires)
|
||
est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365))
|
||
assert mod._expires_date(est_unpaid) == expires.date()
|
||
assert mod._expires_date(est_paid) == expires.date()
|