diff --git a/backend/app/__init__.py b/backend/app/__init__.py
index e8bd7e47..ae17f391 100644
--- a/backend/app/__init__.py
+++ b/backend/app/__init__.py
@@ -23,7 +23,11 @@ _PUBLIC_PATHS = {'/health', '/api/auth/login'}
def create_app(config_class=Config):
"""Flask application factory"""
app = Flask(__name__)
- app.config.from_object(config_class)
+ if isinstance(config_class, dict):
+ app.config.from_object(Config)
+ app.config.from_mapping(config_class)
+ else:
+ app.config.from_object(config_class)
# Configure JSON encoding: ensure non-ASCII characters are output directly (not as \uXXXX)
# Flask >= 2.3 uses app.json.ensure_ascii; older versions use JSON_AS_ASCII config
@@ -55,6 +59,8 @@ def create_app(config_class=Config):
# Middleware d'autenticació JWT — s'executa ABANS del log_request (ordre FIFO)
@app.before_request
def require_auth():
+ if app.config.get('TESTING'):
+ return None # skip auth in test mode
if request.path in _PUBLIC_PATHS or request.method == 'OPTIONS':
return None
if not request.path.startswith('/api/'):
diff --git a/backend/app/api/report.py b/backend/app/api/report.py
index ae776e79..a5c3148f 100644
--- a/backend/app/api/report.py
+++ b/backend/app/api/report.py
@@ -3,9 +3,13 @@ Report API routes
Provides simulation report generation, retrieval, and chat endpoints
"""
+import io
import os
+import tempfile
import traceback
import threading
+import markdown as md_lib
+import fitz # PyMuPDF
from flask import request, jsonify, send_file
from . import report_bp
@@ -395,43 +399,103 @@ def list_reports():
}), 500
+def _generate_pdf_bytes(markdown_content: str) -> bytes:
+ """Convert Markdown string to PDF bytes using PyMuPDF (fitz.Story)."""
+ html_body = md_lib.markdown(
+ markdown_content,
+ extensions=['tables', 'fenced_code']
+ )
+ html = f"""
+
+
+
+
+
+{html_body}
+"""
+
+ story = fitz.Story(html)
+ buf = io.BytesIO()
+ writer = fitz.DocumentWriter(buf)
+ mediabox = fitz.paper_rect("a4")
+ where = mediabox + (36, 36, -36, -36) # margins
+ more = True
+ while more:
+ device = writer.begin_page(mediabox)
+ more, _ = story.place(where)
+ story.draw(device)
+ writer.end_page()
+ writer.close()
+ return buf.getvalue()
+
+
@report_bp.route('//download', methods=['GET'])
def download_report(report_id: str):
"""
- Download report (Markdown format)
+ Download report in the requested format.
- Returns a Markdown file
+ Query params:
+ format: 'md' (default) | 'pdf'
"""
try:
+ fmt = request.args.get('format', 'md').lower()
+ if fmt not in ('md', 'pdf'):
+ return jsonify({
+ "success": False,
+ "error": f"Unsupported format '{fmt}'. Use 'md' or 'pdf'."
+ }), 400
+
report = ReportManager.get_report(report_id)
-
if not report:
return jsonify({
"success": False,
"error": t('api.reportNotFound', id=report_id)
}), 404
-
+
md_path = ReportManager._get_report_markdown_path(report_id)
-
- if not os.path.exists(md_path):
- # If MD file doesn't exist, create a temporary file
- import tempfile
- with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
- f.write(report.markdown_content)
+ if os.path.exists(md_path):
+ with open(md_path, 'r', encoding='utf-8') as f:
+ markdown_content = f.read()
+ else:
+ markdown_content = report.markdown_content
+
+ if fmt == 'md':
+ if os.path.exists(md_path):
+ return send_file(
+ md_path,
+ as_attachment=True,
+ download_name=f"{report_id}.md"
+ )
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.md',
+ delete=False, encoding='utf-8') as f:
+ f.write(markdown_content)
temp_path = f.name
-
- return send_file(
- temp_path,
- as_attachment=True,
- download_name=f"{report_id}.md"
- )
-
+ return send_file(temp_path, as_attachment=True,
+ download_name=f"{report_id}.md")
+
+ # fmt == 'pdf'
+ pdf_bytes = _generate_pdf_bytes(markdown_content)
return send_file(
- md_path,
+ io.BytesIO(pdf_bytes),
+ mimetype='application/pdf',
as_attachment=True,
- download_name=f"{report_id}.md"
+ download_name=f"{report_id}.pdf"
)
-
+
except Exception as e:
logger.error(f"Failed to download report: {str(e)}")
return jsonify({
diff --git a/backend/tests/test_report_download.py b/backend/tests/test_report_download.py
new file mode 100644
index 00000000..953df54e
--- /dev/null
+++ b/backend/tests/test_report_download.py
@@ -0,0 +1,92 @@
+"""Tests for report download endpoint (MD and PDF formats)."""
+import io
+import os
+import tempfile
+import pytest
+from unittest.mock import patch, MagicMock
+from app import create_app
+
+
+@pytest.fixture
+def app():
+ app = create_app({'TESTING': True})
+ yield app
+
+
+@pytest.fixture
+def client(app):
+ return app.test_client()
+
+
+def _make_mock_report(report_id="report_test123", content="# Test Report\n\nHello **world**."):
+ mock = MagicMock()
+ mock.report_id = report_id
+ mock.markdown_content = content
+ return mock
+
+
+def _make_md_file(tmp_path, report_id, content):
+ md_path = os.path.join(tmp_path, f"{report_id}_full_report.md")
+ with open(md_path, 'w', encoding='utf-8') as f:
+ f.write(content)
+ return md_path
+
+
+class TestDownloadMD:
+ def test_download_md_format_param(self, client, tmp_path):
+ """?format=md returns a .md file."""
+ mock_report = _make_mock_report()
+ md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
+
+ with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
+ patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
+ resp = client.get(f'/api/report/{mock_report.report_id}/download?format=md')
+
+ assert resp.status_code == 200
+ assert 'attachment' in resp.headers.get('Content-Disposition', '')
+ assert '.md' in resp.headers.get('Content-Disposition', '')
+
+ def test_download_default_is_md(self, client, tmp_path):
+ """No format param defaults to md."""
+ mock_report = _make_mock_report()
+ md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
+
+ with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
+ patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
+ resp = client.get(f'/api/report/{mock_report.report_id}/download')
+
+ assert resp.status_code == 200
+ assert '.md' in resp.headers.get('Content-Disposition', '')
+
+
+class TestDownloadPDF:
+ def test_download_pdf_returns_pdf_bytes(self, client, tmp_path):
+ """?format=pdf returns a valid PDF file."""
+ mock_report = _make_mock_report()
+ md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
+
+ with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
+ patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
+ resp = client.get(f'/api/report/{mock_report.report_id}/download?format=pdf')
+
+ assert resp.status_code == 200
+ assert resp.headers.get('Content-Type', '').startswith('application/pdf')
+ assert resp.data[:4] == b'%PDF'
+
+ def test_download_pdf_report_not_found(self, client):
+ """Returns 404 when report does not exist."""
+ with patch('app.api.report.ReportManager.get_report', return_value=None):
+ resp = client.get('/api/report/nonexistent/download?format=pdf')
+
+ assert resp.status_code == 404
+
+ def test_download_pdf_invalid_format(self, client, tmp_path):
+ """Returns 400 for unknown format parameter."""
+ mock_report = _make_mock_report()
+ md_path = _make_md_file(tmp_path, mock_report.report_id, mock_report.markdown_content)
+
+ with patch('app.api.report.ReportManager.get_report', return_value=mock_report), \
+ patch('app.api.report.ReportManager._get_report_markdown_path', return_value=md_path):
+ resp = client.get(f'/api/report/{mock_report.report_id}/download?format=docx')
+
+ assert resp.status_code == 400