Files
meida_backend/tests/unit/test_report_export.py
T
xsl cd0ddb131b feat(report): 报告 PDF 导出管线(Phase A,WeasyPrint 服务端渲染)
- POST /reports/{id}/export:request_key 幂等 + hd_export 日配额三段式(reserve/commit/refund)+ tier2 熔断场景闸(BudgetGuardDep),仅 succeeded 报告可导
- GET /reports/{id}/export/download:归属校验 + 路径收敛(rendered_asset_key 必须落在 report_export_dir 内),FileResponse 携带 UTF-8 文件名
- celery 任务 meida.report.export(cpu 队列):渲染→落盘 var/exports→回写 rendered_asset_key;业务性拒绝(报告状态变化)结案退款不重试,基础设施异常重试一次后兜底结案退款
- packages/report/pdf:Jinja2 模板 + WeasyPrint 渲染器(url_fetcher 全拒外部资源防 SSRF;维度中文名按 DIMENSION_BY_KEY 回填;色板维度渲染色卡)
- 单测 10 例(资格/幂等/并发同键/下载校验/渲染);scripts/seed_pdf_e2e.sql 供本地 E2E 造数
2026-08-30 13:17:23 +08:00

234 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""报告 PDF 导出(Phase A)的单测:渲染器与路由接线,不连库不发网。
- 渲染器:构造样例 result(32 维,ok/skipped 混合 + 色卡维度)→ 断言
输出为合法 PDF 头且非空;产物缺 copy → 40900;url_fetcher 全拒。
- 路由:参照 test_admin_app.py 的方式——create_app + ASGITransport +
dependency_overrides 换假服务,只验证接线与信封/错误码透传;
真实的资格/幂等/配额语义由 E2E 与后续集成测试覆盖。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from apps.api.deps import current_user_id
from apps.api.main import create_app
from apps.api.routers.reports import report_export_service_dep
from packages.report.dimensions import DIMENSIONS
from packages.report.export_service import ExportFile, ExportSubmission
from packages.report.pdf.renderer import (
ReportPdfMeta,
_reject_all_urls,
render_report_pdf,
)
from packages.shared.errors import AppError, ErrorCode
META = ReportPdfMeta(report_id=101, version=1, generated_at=datetime(2026, 8, 29, 8, 0, tzinfo=UTC))
def _sample_result() -> dict[str, Any]:
"""与流水线产物同构的最小样例:21 项 ok(含色卡维度),11 项 skipped。"""
dimensions: list[dict[str, Any]] = []
for spec in DIMENSIONS:
if spec.basic_coverage == "none":
dimensions.append(
{
"number": spec.number,
"key": spec.key,
"value": None,
"confidence": 0.0,
"source": "none",
"evidence": "初级版报告不覆盖该维度",
"rule_hits": [],
"status": "skipped",
}
)
continue
value: Any = f"{spec.name_cn}样例结论"
if spec.key == "best_colors":
value = [
{"hex": "#A8C3A0", "name_cn": "鼠尾草绿"},
{"hex": "#D8B4A0", "name_cn": "裸粉"},
]
dimensions.append(
{
"number": spec.number,
"key": spec.key,
"value": value,
"confidence": 0.9,
"source": spec.source,
"evidence": "问卷与视觉分析一致",
"rule_hits": [],
"status": "ok",
}
)
return {
"diagnosis": {"dimensions": dimensions, "coverage": {"basic_full": 10, "total": 32}},
"copy": {
"summary": "你的整体气质偏向自然优雅,适合柔和明亮的色彩搭配。",
"sections": [
{
"title": "个人风格核心诊断",
"paragraphs": ["第一段样例文案。", "第二段样例文案。"],
},
{"title": "身材分析与穿搭指南", "paragraphs": ["第三段样例文案。"]},
],
"model": "unit-test",
"prompt_version": "v1",
"dropped_keys": [],
},
"validation": {
"passed": True,
"checks": {"pejorative": 0, "consistency": 0, "absolute": 0},
},
}
# --- 渲染器 ---------------------------------------------------------------
def test_render_produces_pdf_with_cjk_content() -> None:
pdf = render_report_pdf(_sample_result(), meta=META)
assert pdf.startswith(b"%PDF-")
# Noto CJK 子集嵌入后体积不可能只有几 KB;阈值防「渲染出空壳」退化。
assert len(pdf) > 20_000
def test_render_rejects_result_without_copy() -> None:
with pytest.raises(AppError) as excinfo:
render_report_pdf({"diagnosis": {"dimensions": []}}, meta=META)
assert excinfo.value.code is ErrorCode.STATE_CONFLICT
def test_url_fetcher_rejects_everything_including_file_scheme() -> None:
# SSRF 面:模板不允许任何外部资源,含 file:// 本地文件。
for url in ("https://evil.example/x.png", "file:///etc/passwd"):
with pytest.raises(ValueError, match="不允许加载外部资源"):
_reject_all_urls(url)
# --- 路由接线(假服务,不连库) --------------------------------------------
class _FakeExportService:
def __init__(self) -> None:
self.create_calls: list[dict[str, Any]] = []
self.behavior: str = "ok"
self.export_file: ExportFile | None = None
async def create_export(self, **kwargs: Any) -> ExportSubmission:
self.create_calls.append(kwargs)
if self.behavior == "not_succeeded":
raise AppError(ErrorCode.STATE_CONFLICT, message="只有已完成的报告可以导出 PDF")
if self.behavior == "degraded":
raise AppError(ErrorCode.LLM_SCENE_DEGRADED)
return ExportSubmission(
task_id=987654321098765432, report_id=kwargs["report_id"], status="pending"
)
async def get_export_file(self, **kwargs: Any) -> ExportFile:
if self.export_file is None:
raise AppError(ErrorCode.REPORT_EXPORT_NOT_READY)
return self.export_file
@asynccontextmanager
async def _client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
"""不进 lifespan:单测不建 DB engine / LLM 网关,只测装配与信封。"""
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
yield http
def _build_app(service: _FakeExportService) -> FastAPI:
app = create_app()
app.dependency_overrides[current_user_id] = lambda: 42
app.dependency_overrides[report_export_service_dep] = lambda: service
return app
_AUTH = {"Authorization": "Bearer unit-test-token"}
async def test_export_endpoint_returns_task_envelope() -> None:
service = _FakeExportService()
async with _client(_build_app(service)) as client:
resp = await client.post(
"/api/v1/reports/101/export", json={"request_key": "k-1"}, headers=_AUTH
)
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
assert body["data"]["task_id"] == "987654321098765432"
assert body["data"]["status"] == "pending"
assert service.create_calls == [{"user_id": 42, "report_id": 101, "request_key": "k-1"}]
async def test_export_endpoint_fills_request_key_when_absent() -> None:
service = _FakeExportService()
async with _client(_build_app(service)) as client:
resp = await client.post("/api/v1/reports/101/export", json={}, headers=_AUTH)
assert resp.status_code == 200
key = service.create_calls[0]["request_key"]
assert str(key).startswith("report-export:42:101:")
async def test_export_endpoint_propagates_state_conflict_as_40900() -> None:
service = _FakeExportService()
service.behavior = "not_succeeded"
async with _client(_build_app(service)) as client:
resp = await client.post("/api/v1/reports/101/export", json={}, headers=_AUTH)
assert resp.status_code == 409
assert resp.json()["code"] == 40900
async def test_export_endpoint_propagates_scene_degraded_as_60006() -> None:
# 熔断(tier2 起 hd_export 被拦)经服务透传到统一信封。
service = _FakeExportService()
service.behavior = "degraded"
async with _client(_build_app(service)) as client:
resp = await client.post("/api/v1/reports/101/export", json={}, headers=_AUTH)
assert resp.status_code == 503
assert resp.json()["code"] == 60006
async def test_export_requires_authentication() -> None:
async with _client(create_app()) as client:
resp = await client.post("/api/v1/reports/101/export", json={})
assert resp.status_code == 401
assert resp.json()["code"] == 40100
resp = await client.get("/api/v1/reports/101/export/download")
assert resp.status_code == 401
assert resp.json()["code"] == 40100
async def test_download_streams_pdf_with_content_disposition(tmp_path: Path) -> None:
pdf_path = tmp_path / "var" / "exports" / "report_101_v1.pdf"
pdf_path.parent.mkdir(parents=True)
pdf_path.write_bytes(b"%PDF-1.7\nunit-test-bytes")
service = _FakeExportService()
service.export_file = ExportFile(path=pdf_path, filename="美搭形象报告-v1.pdf")
async with _client(_build_app(service)) as client:
resp = await client.get("/api/v1/reports/101/export/download", headers=_AUTH)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF-")
# 中文文件名必须走 RFC 5987 的 filename* 编码段
assert "filename*" in resp.headers["content-disposition"]
async def test_download_before_export_is_40421() -> None:
service = _FakeExportService()
async with _client(_build_app(service)) as client:
resp = await client.get("/api/v1/reports/101/export/download", headers=_AUTH)
assert resp.status_code == 404
assert resp.json()["code"] == 40421