fix: CI test failure — skip WeasyPrint tests when native libs unavailable

- 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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 23:07:04 +01:00
parent 76695f3902
commit 0521e89d7c
3 changed files with 66 additions and 40 deletions

View File

@@ -8,7 +8,17 @@ from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
import mistune 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 ..auth.routes import role_required
from ..core import csrf_protect, execute, fetch_all, fetch_one, slugify 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") @role_required("admin")
async def scenario_pdf(scenario_id: int): async def scenario_pdf(scenario_id: int):
"""Generate and immediately download a business plan PDF for a published scenario.""" """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 ..businessplan import get_plan_sections
from ..planner.calculator import validate_state from ..planner.calculator import validate_state
@@ -1418,7 +1427,7 @@ async def scenario_pdf(scenario_id: int):
if not scenario: if not scenario:
return jsonify({"error": "Scenario not found."}), 404 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"): if lang not in ("en", "de"):
lang = "en" lang = "en"

View File

@@ -6,16 +6,17 @@ import hmac
import sqlite3 import sqlite3
import tempfile import tempfile
import time import time
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import aiosqlite import aiosqlite
import pytest import pytest
from padelnomics import core
from padelnomics.app import create_app from padelnomics.app import create_app
from padelnomics.migrations.migrate import migrate from padelnomics.migrations.migrate import migrate
from padelnomics import core
_SCHEMA_CACHE = None _SCHEMA_CACHE = None
@@ -87,7 +88,7 @@ async def client(app):
@pytest.fixture @pytest.fixture
async def test_user(db): async def test_user(db):
"""Create a test user, return dict with id/email/name.""" """Create a test user, return dict with id/email/name."""
now = datetime.utcnow().isoformat() now = datetime.now(UTC).isoformat()
async with db.execute( async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("test@example.com", "Test User", now), ("test@example.com", "Test User", now),
@@ -119,7 +120,7 @@ def create_subscription(db):
provider_subscription_id: str = "sub_test456", provider_subscription_id: str = "sub_test456",
current_period_end: str = "2025-03-01T00:00:00Z", current_period_end: str = "2025-03-01T00:00:00Z",
) -> int: ) -> int:
now = datetime.utcnow().isoformat() now = datetime.now(UTC).isoformat()
# Create billing_customers record if provider_customer_id given # Create billing_customers record if provider_customer_id given
if provider_customer_id: if provider_customer_id:
await db.execute( await db.execute(
@@ -147,9 +148,10 @@ def create_subscription(db):
async def scenario(db, test_user): async def scenario(db, test_user):
"""User scenario with valid planner state for PDF generation.""" """User scenario with valid planner state for PDF generation."""
import json import json
from padelnomics.planner.calculator import validate_state from padelnomics.planner.calculator import validate_state
state = validate_state({"dblCourts": 4, "sglCourts": 2}) state = validate_state({"dblCourts": 4, "sglCourts": 2})
now = datetime.utcnow().isoformat() now = datetime.now(UTC).isoformat()
async with db.execute( async with db.execute(
"""INSERT INTO scenarios (user_id, name, state_json, created_at) """INSERT INTO scenarios (user_id, name, state_json, created_at)
VALUES (?, 'Test Scenario', ?, ?)""", VALUES (?, 'Test Scenario', ?, ?)""",

View File

@@ -7,8 +7,7 @@ Covers:
- handle_generate_business_plan() — worker handler status transitions - handle_generate_business_plan() — worker handler status transitions
- /planner/export and /planner/export/<id> routes - /planner/export and /planner/export/<id> routes
""" """
import json from datetime import UTC, datetime
from datetime import datetime
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -16,6 +15,16 @@ import pytest
from padelnomics.businessplan import generate_business_plan, get_plan_sections from padelnomics.businessplan import generate_business_plan, get_plan_sections
from padelnomics.planner.calculator import calc, validate_state 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 ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -126,6 +135,7 @@ class TestGetPlanSections:
# generate_business_plan() — end-to-end WeasyPrint # generate_business_plan() — end-to-end WeasyPrint
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@requires_weasyprint
class TestGenerateBusinessPlan: class TestGenerateBusinessPlan:
async def test_generates_valid_pdf_en(self, db, scenario): async def test_generates_valid_pdf_en(self, db, scenario):
pdf_bytes = await generate_business_plan( pdf_bytes = await generate_business_plan(
@@ -156,30 +166,33 @@ class TestGenerateBusinessPlan:
# Worker handler — status transitions + file write # Worker handler — status transitions + file write
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pending") -> int: async def _insert_export(db, user_id: int, scenario_id: int, status: str = "pending") -> dict:
"""Insert a business_plan_exports row, return its id.""" """Insert a business_plan_exports row, return dict with id and token."""
now = datetime.utcnow().isoformat() import secrets
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(16)
async with db.execute( async with db.execute(
"""INSERT INTO business_plan_exports """INSERT INTO business_plan_exports
(user_id, scenario_id, language, status, created_at) (user_id, scenario_id, language, status, token, created_at)
VALUES (?, ?, 'en', ?, ?)""", VALUES (?, ?, 'en', ?, ?, ?)""",
(user_id, scenario_id, status, now), (user_id, scenario_id, status, token, now),
) as cursor: ) as cursor:
export_id = cursor.lastrowid export_id = cursor.lastrowid
await db.commit() await db.commit()
return export_id return {"id": export_id, "token": token}
@requires_weasyprint
class TestWorkerHandler: class TestWorkerHandler:
async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario): async def test_happy_path_generates_pdf_and_updates_status(self, db, scenario):
from padelnomics.worker import handle_generate_business_plan 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 output_file = None
with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email: with patch("padelnomics.worker.send_email", new_callable=AsyncMock) as mock_email:
await handle_generate_business_plan({ await handle_generate_business_plan({
"export_id": export_id, "export_id": export["id"],
"user_id": scenario["user_id"], "user_id": scenario["user_id"],
"scenario_id": scenario["id"], "scenario_id": scenario["id"],
"language": "en", "language": "en",
@@ -188,7 +201,7 @@ class TestWorkerHandler:
# Status should be 'ready' # Status should be 'ready'
from padelnomics.core import fetch_one from padelnomics.core import fetch_one
row = await 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: try:
assert row["status"] == "ready" assert row["status"] == "ready"
@@ -214,12 +227,12 @@ class TestWorkerHandler:
# Use a non-existent user_id so generate_business_plan raises ValueError # Use a non-existent user_id so generate_business_plan raises ValueError
wrong_user_id = scenario["user_id"] + 9999 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 patch("padelnomics.worker.send_email", new_callable=AsyncMock):
with pytest.raises(ValueError): with pytest.raises(ValueError):
await handle_generate_business_plan({ await handle_generate_business_plan({
"export_id": export_id, "export_id": export["id"],
"user_id": wrong_user_id, "user_id": wrong_user_id,
"scenario_id": scenario["id"], "scenario_id": scenario["id"],
"language": "en", "language": "en",
@@ -227,7 +240,7 @@ class TestWorkerHandler:
from padelnomics.core import fetch_one from padelnomics.core import fetch_one
row = await 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" assert row["status"] == "failed"
@@ -248,26 +261,26 @@ class TestExportRoutes:
assert "Test Scenario" in html assert "Test Scenario" in html
async def test_download_nonexistent_returns_404(self, auth_client): 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 assert resp.status_code == 404
async def test_download_pending_shows_generating_page(self, auth_client, db, scenario): 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" 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 assert resp.status_code == 200
html = (await resp.data).decode() await resp.data # consume response
# Should show the generating/pending template, not a PDF # Should show the generating/pending template, not a PDF
assert "pdf" not in resp.content_type.lower() assert "pdf" not in resp.content_type.lower()
async def test_download_generating_shows_generating_page(self, auth_client, db, scenario): 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" 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 assert resp.status_code == 200
html = (await resp.data).decode() await resp.data # consume response
assert "pdf" not in resp.content_type.lower() assert "pdf" not in resp.content_type.lower()
async def test_download_ready_returns_pdf(self, auth_client, db, scenario, tmp_path): 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 = tmp_path / "test_export.pdf"
pdf_file.write_bytes(dummy_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( async with db.execute(
"""INSERT INTO business_plan_exports """INSERT INTO business_plan_exports
(user_id, scenario_id, language, status, file_path, created_at, completed_at) (user_id, scenario_id, language, status, token, file_path, created_at, completed_at)
VALUES (?, ?, 'en', 'ready', ?, ?, ?)""", VALUES (?, ?, 'en', 'ready', ?, ?, ?, ?)""",
(scenario["user_id"], scenario["id"], str(pdf_file), now, now), (scenario["user_id"], scenario["id"], token, str(pdf_file), now, now),
) as cursor: ):
export_id = cursor.lastrowid pass
await db.commit() 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.status_code == 200
assert resp.content_type == "application/pdf" assert resp.content_type == "application/pdf"
data = await resp.data data = await resp.data
@@ -294,7 +309,7 @@ class TestExportRoutes:
async def test_download_other_users_export_returns_404(self, app, db, scenario): async def test_download_other_users_export_returns_404(self, app, db, scenario):
"""Another user cannot download someone else's export.""" """Another user cannot download someone else's export."""
now = datetime.utcnow().isoformat() now = datetime.now(UTC).isoformat()
# Create a second user # Create a second user
async with db.execute( async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)", "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
@@ -303,11 +318,11 @@ class TestExportRoutes:
other_id = cursor.lastrowid other_id = cursor.lastrowid
await db.commit() 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 # Log in as the other user
async with app.test_client() as c: async with app.test_client() as c:
async with c.session_transaction() as sess: async with c.session_transaction() as sess:
sess["user_id"] = other_id 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 assert resp.status_code == 404