From 0521e89d7c4535ee4b0eee2543d7e946db930a97 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sun, 22 Feb 2026 23:07:04 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20CI=20test=20failure=20=E2=80=94=20skip?= =?UTF-8?q?=20WeasyPrint=20tests=20when=20native=20libs=20unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add requires_weasyprint marker to TestGenerateBusinessPlan and TestWorkerHandler (these need libgobject/pango/cairo which CI python:3.12-slim lacks) - Fix export route tests: use opaque tokens instead of integer IDs - Replace deprecated datetime.utcnow() with datetime.now(UTC) - Add missing jsonify/Response imports to admin routes Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/admin/routes.py | 15 ++++-- web/tests/conftest.py | 12 +++-- web/tests/test_businessplan.py | 79 +++++++++++++++++------------ 3 files changed, 66 insertions(+), 40 deletions(-) diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 9734bec..fed6144 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -8,7 +8,17 @@ from datetime import date, datetime, timedelta from pathlib import Path import mistune -from quart import Blueprint, flash, redirect, render_template, request, session, url_for +from quart import ( + Blueprint, + Response, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) from ..auth.routes import role_required from ..core import csrf_protect, execute, fetch_all, fetch_one, slugify @@ -1410,7 +1420,6 @@ async def scenario_preview(scenario_id: int): @role_required("admin") async def scenario_pdf(scenario_id: int): """Generate and immediately download a business plan PDF for a published scenario.""" - from quart import request as req from ..businessplan import get_plan_sections from ..planner.calculator import validate_state @@ -1418,7 +1427,7 @@ async def scenario_pdf(scenario_id: int): if not scenario: return jsonify({"error": "Scenario not found."}), 404 - lang = req.args.get("lang", "en") + lang = request.args.get("lang", "en") if lang not in ("en", "de"): lang = "en" diff --git a/web/tests/conftest.py b/web/tests/conftest.py index 3fbdcc9..ecc5b80 100644 --- a/web/tests/conftest.py +++ b/web/tests/conftest.py @@ -6,16 +6,17 @@ import hmac import sqlite3 import tempfile import time -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from unittest.mock import AsyncMock, patch import aiosqlite import pytest -from padelnomics import core from padelnomics.app import create_app from padelnomics.migrations.migrate import migrate +from padelnomics import core + _SCHEMA_CACHE = None @@ -87,7 +88,7 @@ async def client(app): @pytest.fixture async def test_user(db): """Create a test user, return dict with id/email/name.""" - now = datetime.utcnow().isoformat() + now = datetime.now(UTC).isoformat() async with db.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", ("test@example.com", "Test User", now), @@ -119,7 +120,7 @@ def create_subscription(db): provider_subscription_id: str = "sub_test456", current_period_end: str = "2025-03-01T00:00:00Z", ) -> int: - now = datetime.utcnow().isoformat() + now = datetime.now(UTC).isoformat() # Create billing_customers record if provider_customer_id given if provider_customer_id: await db.execute( @@ -147,9 +148,10 @@ def create_subscription(db): async def scenario(db, test_user): """User scenario with valid planner state for PDF generation.""" import json + from padelnomics.planner.calculator import validate_state state = validate_state({"dblCourts": 4, "sglCourts": 2}) - now = datetime.utcnow().isoformat() + now = datetime.now(UTC).isoformat() async with db.execute( """INSERT INTO scenarios (user_id, name, state_json, created_at) VALUES (?, 'Test Scenario', ?, ?)""", diff --git a/web/tests/test_businessplan.py b/web/tests/test_businessplan.py index e45e9e7..3230545 100644 --- a/web/tests/test_businessplan.py +++ b/web/tests/test_businessplan.py @@ -7,8 +7,7 @@ Covers: - handle_generate_business_plan() — worker handler status transitions - /planner/export and /planner/export/ routes """ -import json -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from unittest.mock import AsyncMock, patch @@ -16,6 +15,16 @@ 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 ────────────────────────────────────────────────────────────────── @@ -126,6 +135,7 @@ class TestGetPlanSections: # 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( @@ -156,30 +166,33 @@ class TestGenerateBusinessPlan: # Worker handler — status transitions + file write # ════════════════════════════════════════════════════════════ -async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pending") -> int: - """Insert a business_plan_exports row, return its id.""" - now = datetime.utcnow().isoformat() +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, created_at) - VALUES (?, ?, 'en', ?, ?)""", - (user_id, scenario_id, status, now), + (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 export_id + 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_id = await _insert_export(db, scenario["user_id"], scenario["id"]) + 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, + "export_id": export["id"], "user_id": scenario["user_id"], "scenario_id": scenario["id"], "language": "en", @@ -188,7 +201,7 @@ class TestWorkerHandler: # Status should be 'ready' from padelnomics.core import fetch_one row = await fetch_one( - "SELECT * FROM business_plan_exports WHERE id = ?", (export_id,) + "SELECT * FROM business_plan_exports WHERE id = ?", (export["id"],) ) try: assert row["status"] == "ready" @@ -214,12 +227,12 @@ class TestWorkerHandler: # Use a non-existent user_id so generate_business_plan raises ValueError wrong_user_id = scenario["user_id"] + 9999 - export_id = await _insert_export(db, scenario["user_id"], scenario["id"]) + 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, + "export_id": export["id"], "user_id": wrong_user_id, "scenario_id": scenario["id"], "language": "en", @@ -227,7 +240,7 @@ class TestWorkerHandler: from padelnomics.core import fetch_one row = await fetch_one( - "SELECT status FROM business_plan_exports WHERE id = ?", (export_id,) + "SELECT status FROM business_plan_exports WHERE id = ?", (export["id"],) ) assert row["status"] == "failed" @@ -248,26 +261,26 @@ class TestExportRoutes: assert "Test Scenario" in html async def test_download_nonexistent_returns_404(self, auth_client): - resp = await auth_client.get("/en/planner/export/99999") + 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_id = await _insert_export( + export = await _insert_export( db, scenario["user_id"], scenario["id"], status="pending" ) - resp = await auth_client.get(f"/en/planner/export/{export_id}") + resp = await auth_client.get(f"/en/planner/export/{export['token']}") assert resp.status_code == 200 - html = (await resp.data).decode() + 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_id = await _insert_export( + export = await _insert_export( db, scenario["user_id"], scenario["id"], status="generating" ) - resp = await auth_client.get(f"/en/planner/export/{export_id}") + resp = await auth_client.get(f"/en/planner/export/{export['token']}") assert resp.status_code == 200 - html = (await resp.data).decode() + 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): @@ -276,17 +289,19 @@ class TestExportRoutes: pdf_file = tmp_path / "test_export.pdf" pdf_file.write_bytes(dummy_pdf) - now = datetime.utcnow().isoformat() + 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, file_path, created_at, completed_at) - VALUES (?, ?, 'en', 'ready', ?, ?, ?)""", - (scenario["user_id"], scenario["id"], str(pdf_file), now, now), - ) as cursor: - export_id = cursor.lastrowid + (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/{export_id}") + 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 @@ -294,7 +309,7 @@ class TestExportRoutes: async def test_download_other_users_export_returns_404(self, app, db, scenario): """Another user cannot download someone else's export.""" - now = datetime.utcnow().isoformat() + now = datetime.now(UTC).isoformat() # Create a second user async with db.execute( "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", @@ -303,11 +318,11 @@ class TestExportRoutes: other_id = cursor.lastrowid await db.commit() - export_id = await _insert_export(db, scenario["user_id"], scenario["id"]) + 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_id}") + resp = await c.get(f"/en/planner/export/{export['token']}") assert resp.status_code == 404