- 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 造数
234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
"""报告 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
|