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