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:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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', ?, ?)""",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user