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
209 lines
8.8 KiB
Python
209 lines
8.8 KiB
Python
"""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}"
|