gendesign/tradein-mvp/backend/tests/test_pdf_real_render.py
lekss361 ca46411346
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m2s
Deploy Trade-In / build-backend (push) Successful in 5m38s
Deploy Trade-In / deploy (push) Successful in 1m4s
fix(tradein/tests): тесты авторизации проверяют настоящий guard + реальный рендер PDF (#2541)
2026-07-26 23:04:43 +00:00

209 lines
8.8 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.

"""Real (non-mocked) WeasyPrint render invariants for the Trade-In PDF report.
tests/test_pdf_security.py exercises only the HTML-builder layer with
WeasyPrint stubbed out (``sys.modules['weasyprint'] = MagicMock()``) — by
design, so those tests run everywhere without needing WeasyPrint's native
GTK/Pango/cairo libs. That design has a blind spot: it can NEVER catch a real
WeasyPrint pagination regression — e.g. the bug fixed in commit 42a50cf8
("running @page header/footer → ровно 4 страницы (без пустых)"), where the
persistent running header/footer produced an extra TRAILING BLANK 5th page
instead of the documented "Структура отчёта — 4 страницы" invariant (see
app/services/exporters/trade_in_pdf.py module docstring). A mocked
WeasyPrint can never lay out/paginate anything, so it structurally cannot see
that class of bug — only a real render can.
Requires WeasyPrint's native dependencies (Pango/cairo/GObject). These are
NOT installed in this dev sandbox (Windows, no libgobject-2.0-0) and NOT
installed on the bare ``ubuntu-latest`` runner used by
.forgejo/workflows/ci-tradein.yml (no apt-get step there — see that file).
This whole module self-skips via ``pytest.skip(..., allow_module_level=True)``
the moment the real `import weasyprint` fails for ANY reason (missing native
lib, etc.), so it is a harmless no-op everywhere it can't actually run.
How to run it for real:
- Inside the prod/dev docker image — the `runner` stage of
tradein-mvp/backend/Dockerfile installs libcairo2 + libpango-1.0-0 +
libpangoft2-1.0-0 + fonts-dejavu-core, so WeasyPrint's native deps are
present there:
docker exec tradein-backend python -m pytest -q -m pdf_render \
tests/test_pdf_real_render.py
- Locally on a Linux box with WeasyPrint's system deps installed (see
https://doc.courtbouillon.org/weasyprint/stable/first_steps.html):
uv run pytest -q -m pdf_render tests/test_pdf_real_render.py
- Marked ``@pytest.mark.pdf_render`` (registered in tests/conftest.py) so it
can be explicitly selected/excluded once a CI runner with the native libs
exists; today ci-tradein.yml's `ubuntu-latest` runner doesn't have them,
so this module simply self-skips there — nothing to deselect.
"""
from __future__ import annotations
import os
import sys
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# test_pdf_security.py deliberately does `sys.modules['weasyprint'] = MagicMock()`
# for its own unit tests. sys.modules is a process-global cache: if that module
# was imported before this one in the same pytest run, `import weasyprint` below
# would silently return the Mock instead of the real package. Purge any
# existing stub before attempting the real import — independent of collection
# order across test modules.
for _name in [n for n in sys.modules if n == "weasyprint" or n.startswith("weasyprint.")]:
del sys.modules[_name]
import pytest # noqa: E402
try:
import weasyprint
except Exception as exc: # pragma: no cover - env-dependent (native GTK/Pango/cairo libs)
pytest.skip(
f"WeasyPrint native deps unavailable, skipping real-render tests: {exc}",
allow_module_level=True,
)
pytestmark = pytest.mark.pdf_render
import unittest.mock as _mock # noqa: E402
from datetime import UTC, datetime, timedelta # noqa: E402
from uuid import uuid4 # noqa: E402
from app.schemas.trade_in import AggregatedEstimate # 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,
}
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 (одностраничный empty-state)."""
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)
def _render_real_document(estimate, snapshot, brand):
"""Call the REAL generate_trade_in_pdf (no mocking of WeasyPrint), capturing
the actual weasyprint.HTML/CSS instances + html string it constructs via
thin capturing subclasses — so we can additionally call .render() on the
exact same HTML instance to get a weasyprint.Document (whose .pages is the
public, documented page-count API), without duplicating trade_in_pdf.py's
own html_str/css_str assembly logic here (that would drift out of sync
with the real function, defeating the point of this test).
Returns (document, pdf_bytes, html_string).
"""
html_instances: list[weasyprint.HTML] = []
css_instances: list[weasyprint.CSS] = []
html_strings: list[str] = []
class _CapturingHTML(weasyprint.HTML):
def __init__(self, *a, **kw):
html_strings.append(kw.get("string") if "string" in kw else (a[0] if a else None))
super().__init__(*a, **kw)
html_instances.append(self)
class _CapturingCSS(weasyprint.CSS):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
css_instances.append(self)
with (
_mock.patch.object(weasyprint, "HTML", _CapturingHTML),
_mock.patch.object(weasyprint, "CSS", _CapturingCSS),
):
pdf_bytes = mod.generate_trade_in_pdf(estimate, snapshot, brand=brand)
assert html_instances, "HTML() was never constructed by generate_trade_in_pdf"
assert css_instances, "CSS() was never constructed by generate_trade_in_pdf"
document = html_instances[-1].render(stylesheets=[css_instances[-1]])
return document, pdf_bytes, html_strings[-1]
def test_real_pdf_has_exactly_4_pages_no_trailing_blank() -> None:
"""Regression for commit 42a50cf8: the running @page header/footer must
NOT produce a 5th trailing blank page. trade_in_pdf.py's own module
docstring documents "Структура отчёта — 4 страницы" as the fixed
cover/listings/deals/offer composition — that count IS the authoritative
"no empty pages" invariant for this report (any blank page shows up as an
extra page beyond these 4 named sections)."""
est = _estimate(n_analogs=12, sources_used=["avito"])
document, pdf_bytes, _html_str = _render_real_document(est, _SNAPSHOT, _GENERIC)
assert len(document.pages) == 4, (
f"expected exactly 4 pages (cover/listings/deals/offer), got "
f"{len(document.pages)} — likely a trailing/leading blank-page regression"
)
assert pdf_bytes.startswith(b"%PDF-"), "write_pdf must produce a real PDF"
def test_real_pdf_insufficient_data_is_single_page() -> None:
"""insufficient_data=True → one-page empty-state, not the 4-page report."""
est = _zero_estimate()
document, _pdf_bytes, html_str = _render_real_document(est, _SNAPSHOT, _GENERIC)
assert len(document.pages) == 1
assert "Недостаточно данных" in html_str
def test_real_pdf_key_blocks_present_exactly_once() -> None:
"""Key section headings for each of the 4 pages are present in the actual
composed HTML that WeasyPrint rendered (integration-level check — the
mocked tests in test_pdf_security.py already verify each builder function
in isolation; this catches a composition bug where wiring them together
in generate_trade_in_pdf silently drops or duplicates a section)."""
est = _estimate(n_analogs=12, sources_used=["avito"])
document, _pdf_bytes, html_str = _render_real_document(est, _SNAPSHOT, _GENERIC)
assert len(document.pages) == 4
markers = ["РЫНОК КВАРТИР", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ"]
for marker in markers:
found = html_str.count(marker)
assert found == 1, f"expected exactly one {marker!r} block, found {found}"