""" Tests for the business plan PDF export pipeline. Covers: - get_plan_sections() — pure formatting function - generate_business_plan() — end-to-end WeasyPrint rendering - handle_generate_business_plan() — worker handler status transitions - /planner/export and /planner/export/ routes """ from datetime import UTC, datetime from pathlib import Path from unittest.mock import AsyncMock, patch import pytest from padelnomics.businessplan import generate_business_plan, get_plan_sections from padelnomics.planner.calculator import calc, validate_state try: from weasyprint import HTML as _WP # noqa: F401 _HAS_WEASYPRINT = True except OSError: _HAS_WEASYPRINT = False requires_weasyprint = pytest.mark.skipif( not _HAS_WEASYPRINT, reason="WeasyPrint native libs not available" ) # ── Helpers ────────────────────────────────────────────────────────────────── EXPECTED_SECTION_KEYS = { "lang", "title", "subtitle", "courts", "executive_summary", "investment", "operations", "revenue", "annuals", "financing", "metrics", "cashflow_12m", "labels", } def _make_sections(language: str = "en", country: str = "DE") -> dict: state = validate_state({"dblCourts": 4, "sglCourts": 2, "country": country}) d = calc(state) return get_plan_sections(state, d, language) # ════════════════════════════════════════════════════════════ # get_plan_sections() — pure function, no DB, no WeasyPrint # ════════════════════════════════════════════════════════════ class TestGetPlanSections: def test_returns_all_sections_en(self): sections = _make_sections("en") assert EXPECTED_SECTION_KEYS.issubset(sections.keys()) def test_returns_all_sections_de(self): sections = _make_sections("de") assert EXPECTED_SECTION_KEYS.issubset(sections.keys()) def test_lang_field_matches_requested_language(self): assert _make_sections("en")["lang"] == "en" assert _make_sections("de")["lang"] == "de" def test_currency_formatting_eu_style(self): """German/EU style: € prefix, dot as thousands separator.""" sections = _make_sections("de", country="DE") capex = sections["investment"]["total"] # EU-style: €1.234.567 (dot separator, no comma) assert capex.startswith("€") assert "," not in capex # no comma separator in EU style def test_currency_formatting_us_style(self): """US style: $ prefix, comma as thousands separator.""" sections = _make_sections("en", country="US") capex = sections["investment"]["total"] assert capex.startswith("$") assert "." not in capex.replace(".", "") # commas, not dots def test_payback_formatting_en(self): sections = _make_sections("en") payback = sections["executive_summary"]["payback"] # Should be "X months" or "X.X years" or "not reached" assert isinstance(payback, str) assert len(payback) > 0 def test_irr_formatting(self): sections = _make_sections("en") irr = sections["metrics"]["irr"] assert "%" in irr or irr == "-" def test_executive_summary_has_required_keys(self): sections = _make_sections("en") es = sections["executive_summary"] for key in ("heading", "courts", "sqm", "total_capex", "equity", "loan", "y1_revenue", "y3_ebitda", "irr", "payback"): assert key in es, f"Missing key in executive_summary: {key}" def test_labels_has_required_keys(self): sections = _make_sections("en") labels = sections["labels"] for key in ("scenario", "generated_by", "disclaimer", "currency_sym", "total_investment", "equity_required", "year3_ebitda"): assert key in labels, f"Missing key in labels: {key}" def test_annuals_has_5_years(self): sections = _make_sections("en") years = sections["annuals"]["years"] assert len(years) == 5 def test_cashflow_12m_has_12_months(self): sections = _make_sections("en") months = sections["cashflow_12m"]["months"] assert len(months) == 12 def test_investment_items_have_formatted_amounts(self): sections = _make_sections("en") for item in sections["investment"]["items"]: assert "formatted_amount" in item def test_de_labels_are_german(self): de_sections = _make_sections("de") en_sections = _make_sections("en") # Title should differ between languages assert de_sections["title"] != en_sections["title"] # ════════════════════════════════════════════════════════════ # generate_business_plan() — end-to-end WeasyPrint # ════════════════════════════════════════════════════════════ @requires_weasyprint class TestGenerateBusinessPlan: async def test_generates_valid_pdf_en(self, db, scenario): pdf_bytes = await generate_business_plan( scenario["id"], scenario["user_id"], "en" ) assert isinstance(pdf_bytes, bytes) assert pdf_bytes[:4] == b"%PDF" assert len(pdf_bytes) > 10_000 # non-trivially sized async def test_generates_valid_pdf_de(self, db, scenario): pdf_bytes = await generate_business_plan( scenario["id"], scenario["user_id"], "de" ) assert isinstance(pdf_bytes, bytes) assert pdf_bytes[:4] == b"%PDF" assert len(pdf_bytes) > 10_000 async def test_raises_for_missing_scenario(self, db, test_user): with pytest.raises(ValueError, match="not found"): await generate_business_plan(99999, test_user["id"], "en") async def test_raises_for_wrong_user(self, db, scenario): with pytest.raises(ValueError, match="not found"): await generate_business_plan(scenario["id"], scenario["user_id"] + 999, "en") # ════════════════════════════════════════════════════════════ # Worker handler — status transitions + file write # ════════════════════════════════════════════════════════════ async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pending") -> dict: """Insert a business_plan_exports row, return dict with id and token.""" import secrets now = datetime.now(UTC).isoformat() token = secrets.token_urlsafe(16) async with db.execute( """INSERT INTO business_plan_exports (user_id, scenario_id, language, status, token, created_at) VALUES (?, ?, 'en', ?, ?, ?)""", (user_id, scenario_id, status, token, now), ) as cursor: export_id = cursor.lastrowid await db.commit() return {"id": export_id, "token": token} @requires_weasyprint class TestWorkerHandler: async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario): from padelnomics.worker import handle_generate_business_plan export = await _insert_export(db, scenario["user_id"], scenario["id"]) output_file = None with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email: await handle_generate_business_plan({ "export_id": export["id"], "user_id": scenario["user_id"], "scenario_id": scenario["id"], "language": "en", }) # Status should be 'ready' from padelnomics.core import fetch_one row = await fetch_one( "SELECT * FROM business_plan_exports WHERE id = ?", (export["id"],) ) try: assert row["status"] == "ready" assert row["file_path"] is not None assert row["completed_at"] is not None # File should exist on disk and be a valid PDF output_file = Path(row["file_path"]) assert output_file.exists() assert output_file.read_bytes()[:4] == b"%PDF" # Email should have been sent mock_email.assert_called_once() assert "to" in mock_email.call_args.kwargs assert "subject" in mock_email.call_args.kwargs finally: if output_file and output_file.exists(): output_file.unlink() async def test_marks_failed_on_bad_scenario(self, db, scenario): """Handler marks export failed when user_id doesn't match scenario owner.""" from padelnomics.worker import handle_generate_business_plan # Use a non-existent user_id so generate_business_plan raises ValueError wrong_user_id = scenario["user_id"] + 9999 export = await _insert_export(db, scenario["user_id"], scenario["id"]) with patch("padelnomics.worker.send_email", new_callable=AsyncMock): with pytest.raises(ValueError): await handle_generate_business_plan({ "export_id": export["id"], "user_id": wrong_user_id, "scenario_id": scenario["id"], "language": "en", }) from padelnomics.core import fetch_one row = await fetch_one( "SELECT status FROM business_plan_exports WHERE id = ?", (export["id"],) ) assert row["status"] == "failed" # ════════════════════════════════════════════════════════════ # Export routes — thin HTTP layer # ════════════════════════════════════════════════════════════ class TestExportRoutes: async def test_export_page_requires_login(self, client): resp = await client.get("/en/planner/export") assert resp.status_code == 302 async def test_export_page_shows_scenarios(self, auth_client, db, scenario): resp = await auth_client.get("/en/planner/export") assert resp.status_code == 200 html = (await resp.data).decode() assert "Test Scenario" in html async def test_download_nonexistent_returns_404(self, auth_client): resp = await auth_client.get("/en/planner/export/bogus-token-xyz") assert resp.status_code == 404 async def test_download_pending_shows_generating_page(self, auth_client, db, scenario): export = await _insert_export( db, scenario["user_id"], scenario["id"], status="pending" ) resp = await auth_client.get(f"/en/planner/export/{export['token']}") assert resp.status_code == 200 await resp.data # consume response # Should show the generating/pending template, not a PDF assert "pdf" not in resp.content_type.lower() async def test_download_generating_shows_generating_page(self, auth_client, db, scenario): export = await _insert_export( db, scenario["user_id"], scenario["id"], status="generating" ) resp = await auth_client.get(f"/en/planner/export/{export['token']}") assert resp.status_code == 200 await resp.data # consume response assert "pdf" not in resp.content_type.lower() async def test_download_ready_returns_pdf(self, auth_client, db, scenario, tmp_path): # Write a minimal valid PDF to a temp file dummy_pdf = b"%PDF-1.4 tiny test pdf" pdf_file = tmp_path / "test_export.pdf" pdf_file.write_bytes(dummy_pdf) import secrets now = datetime.now(UTC).isoformat() token = secrets.token_urlsafe(16) async with db.execute( """INSERT INTO business_plan_exports (user_id, scenario_id, language, status, token, file_path, created_at, completed_at) VALUES (?, ?, 'en', 'ready', ?, ?, ?, ?)""", (scenario["user_id"], scenario["id"], token, str(pdf_file), now, now), ): pass await db.commit() resp = await auth_client.get(f"/en/planner/export/{token}") assert resp.status_code == 200 assert resp.content_type == "application/pdf" data = await resp.data assert data == dummy_pdf async def test_download_other_users_export_returns_404(self, app, db, scenario): """Another user cannot download someone else's export.""" now = datetime.now(UTC).isoformat() # Create a second user async with db.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", ("other@example.com", "Other", now), ) as cursor: other_id = cursor.lastrowid await db.commit() export = await _insert_export(db, scenario["user_id"], scenario["id"]) # Log in as the other user async with app.test_client() as c: async with c.session_transaction() as sess: sess["user_id"] = other_id resp = await c.get(f"/en/planner/export/{export['token']}") assert resp.status_code == 404