"""Export Service 的单元与端到端测试。 沿用 conftest 隔离机制:APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录, 不读写真实数据。导出采用「创建即 queued + 后台 Task 执行」的异步模型,测试在同一 事件循环内创建并等待后台任务结束,得到终态 ExportJob 后再断言。 """ from __future__ import annotations import asyncio import base64 import re import zlib from datetime import datetime, timedelta, timezone import pytest from pydantic import ValidationError from app.config import get_settings from app.contracts import ( ExportFormat, ExportJob, ExportOptions, ExportRequest, ExportSource, ExportSourceType, ExportStatus, ) from app.errors import ApiError from app.export import service as export_service from app.export.exporters.html import HtmlExporter from app.export.markdown import parse_document MD = """# 进程调度 一些 **加粗** 和 *斜体*,[链接](https://a.b) 与 `code`。 - 项目一 - 项目二 ```python print(1) ``` ```mermaid graph LR ``` ```function_plot y = x ``` | a | b | |---|---| | 1 | 2 | 行内 $x^2$ 与块级 $$ y = mx + b $$ """ @pytest.fixture(autouse=True) def _reset_export_state(): """清空内存注册表,避免跨用例的任务/取消标志互相污染。 每个用例经 `asyncio.run()` 使用独立事件循环,模块级 Semaphore 会绑定到首个 循环,跨用例复用会触发「bound to a different event loop」;此处每例重建槽位。 """ export_service._jobs.clear() export_service._tasks.clear() export_service._cancel_flags.clear() export_service._render_slots = asyncio.Semaphore(export_service.MAX_CONCURRENT_RENDERS) yield export_service._jobs.clear() export_service._tasks.clear() export_service._cancel_flags.clear() def _create_and_wait(request: ExportRequest) -> object: """创建导出并在同一事件循环内等待后台任务结束,返回终态 ExportJob。""" async def _execute(): job = await export_service.create_export(request) return await export_service.wait_for_export(job.job_id) return asyncio.run(_execute()) # --------------------------------------------------------------------------- # # markdown → Document AST # --------------------------------------------------------------------------- # def _types(nodes) -> list[str]: return [n.type for n in nodes] def test_parse_document_heading_and_inline() -> None: doc = parse_document("# 标题\n\n一段 **加粗** 和 [链接](https://a.b)。") assert doc.type == "document" heading = doc.children[0] assert heading.type == "heading" assert heading.attributes["level"] == 1 para = doc.children[1] assert para.type == "paragraph" kinds = _types(para.children) assert "text" in kinds assert "strong" in kinds assert "link" in kinds link = next(c for c in para.children if c.type == "link") assert link.attributes["href"] == "https://a.b" def test_parse_document_list_and_code_fencing() -> None: doc = parse_document("- a\n- b\n\n```mermaid\ngraph LR\n```\n\n```function_plot\ny=x\n```\n\n```python\nx\n```") kinds = [c.type for c in doc.children] assert kinds[0] == "list" assert kinds[1] == "mermaid" assert kinds[2] == "function_plot" assert kinds[3] == "code_block" code = doc.children[3] assert code.attributes["language"] == "python" assert code.text == "x" def test_parse_document_table_and_math() -> None: doc = parse_document("| a | b |\n|---|---|\n| 1 | 2 |\n\n$x^2$\n\n$$\ny=mx\n$$") table = doc.children[0] assert table.type == "table" assert table.children[0].type == "table_row" assert table.children[0].children[0].attributes["head"] is True # 表格后是「行内数学所在段落」与「块级数学」 kinds = [c.type for c in doc.children[1:]] assert "paragraph" in kinds assert "math_block" in kinds def test_parse_document_image_maps_src_alt_title() -> None: doc = parse_document('![替代文本](https://a.b/img.png "标题")') img = doc.children[0].children[0] assert img.type == "image" assert img.attributes["src"] == "https://a.b/img.png" assert img.attributes["alt"] == "替代文本" assert img.attributes["title"] == "标题" def test_parse_document_function_plot_dash_alias() -> None: doc = parse_document("```function-plot\ny = x^2\n```") assert doc.children[0].type == "function_plot" assert doc.children[0].text == "y = x^2" # --------------------------------------------------------------------------- # # HtmlExporter # --------------------------------------------------------------------------- # async def _render(markdown: str, *, title: str = "") -> str: doc = parse_document(markdown) doc.attributes["title"] = title result = await HtmlExporter().export(doc, ExportOptions()) return result.content.decode("utf-8") def test_html_exporter_renders_basic_nodes_and_escapes() -> None: html = asyncio.run(_render("# 标题\n\n**加粗** [链接](https://a.b) 与 原始。")) assert "

标题

" in html assert "加粗" in html assert '链接' in html # 原始 HTML 必须被转义,不能注入文档 assert "<b>原始</b>" in html assert "原始" not in html def test_html_exporter_marks_mermaid_and_function_plot() -> None: result = asyncio.run(HtmlExporter().export(parse_document("```mermaid\ngraph LR\n```"), ExportOptions())) html = result.content.decode("utf-8") assert '
graph LR
' in html assert any("mermaid" in w for w in result.warnings) def test_html_exporter_rejects_unsafe_link_protocol() -> None: result = asyncio.run( HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions()) ) html = result.content.decode("utf-8") assert "javascript:" not in html assert "点我" in html assert any("不安全" in w for w in result.warnings) def test_html_exporter_rejects_unsafe_image_protocol() -> None: result = asyncio.run( HtmlExporter().export(parse_document("![alt](data:text/html,