"""Export Service 的单元与端到端测试。 沿用 conftest 隔离机制:APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录, 不读写真实数据。导出采用「创建即 queued + 后台 Task 执行」的异步模型,测试在同一 事件循环内创建并等待后台任务结束,得到终态 ExportJob 后再断言。 """ from __future__ import annotations import asyncio from datetime import datetime, timedelta, timezone import pytest from pydantic import ValidationError from app.config import get_settings from app.contracts import ( ExportFormat, 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(): """清空内存注册表,避免跨用例的任务/取消标志互相污染。""" export_service._jobs.clear() export_service._tasks.clear() export_service._cancel_flags.clear() 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 # --------------------------------------------------------------------------- # # 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_include_title_and_metadata() -> None: doc = parse_document("正文") doc.attributes["title"] = "操作系统复习" doc.attributes["metadata"] = {"tags": ["os", "复习"]} opts = ExportOptions(include_title=True, include_metadata=True) result = asyncio.run(HtmlExporter().export(doc, opts)) html = result.content.decode("utf-8") assert '

操作系统复习

' in html assert "os, 复习" in html # --------------------------------------------------------------------------- # # ExportService # --------------------------------------------------------------------------- # def _markdown_request(markdown: str, *, format: ExportFormat = ExportFormat.html) -> ExportRequest: return ExportRequest( source=ExportSource(type=ExportSourceType.markdown, markdown=markdown), format=format, ) def test_export_markdown_source_completes_and_writes_file() -> None: finished = _create_and_wait(_markdown_request(MD)) assert finished.status == ExportStatus.completed assert finished.file is not None assert finished.file.mime_type == "text/html" assert finished.file.size > 0 assert len(finished.file.sha256) == 64 path = get_settings().exports_path / f"{finished.job_id}.html" assert path.exists() content = path.read_text(encoding="utf-8") assert "进程调度" in content def test_export_note_source_resolves_title_and_metadata() -> None: from app.services import note_service async def _go(): note = await note_service.create_note( title="操作系统复习", markdown="# 进程调度\n\n内容。", folder="导出", tags=["os"] ) request = ExportRequest( source=ExportSource(type=ExportSourceType.note, note_id=note.note_id), format=ExportFormat.html, options=ExportOptions(include_metadata=True), ) job = await export_service.create_export(request) return await export_service.wait_for_export(job.job_id) finished = asyncio.run(_go()) assert finished.status == ExportStatus.completed assert finished.file is not None assert finished.file.file_name == "操作系统复习.html" content = (get_settings().exports_path / f"{finished.job_id}.html").read_text(encoding="utf-8") assert "操作系统复习" in content assert "进程调度" in content def test_export_pdf_unsupported() -> None: with pytest.raises(ApiError) as exc: asyncio.run( export_service.create_export(_markdown_request("# x", format=ExportFormat.pdf)) ) assert exc.value.status_code == 400 assert exc.value.code == "EXPORT_FORMAT_UNSUPPORTED" def test_export_unknown_note_404() -> None: request = ExportRequest( source=ExportSource(type=ExportSourceType.note, note_id="note_missing"), format=ExportFormat.html, ) with pytest.raises(ApiError) as exc: asyncio.run(export_service.create_export(request)) assert exc.value.status_code == 404 assert exc.value.code == "EXPORT_SOURCE_NOT_FOUND" def test_export_empty_markdown_invalid() -> None: with pytest.raises(ApiError) as exc: asyncio.run(export_service.create_export(_markdown_request(" "))) assert exc.value.status_code == 400 assert exc.value.code == "EXPORT_OPTIONS_INVALID" def test_export_cancel_queued_job() -> None: async def _go(): job = await export_service.create_export(_markdown_request("# x")) cancelled = export_service.cancel_export(job.job_id) assert cancelled is not None return await export_service.wait_for_export(job.job_id) finished = asyncio.run(_go()) assert finished.status == ExportStatus.cancelled assert finished.file is None def test_export_file_expired_410() -> None: async def _go(): job = await export_service.create_export(_markdown_request("# x")) finished = await export_service.wait_for_export(job.job_id) past = datetime.now(timezone.utc) - timedelta(hours=1) export_service._jobs[job.job_id] = finished.model_copy( update={"file": finished.file.model_copy(update={"expires_at": past})} ) return job.job_id job_id = asyncio.run(_go()) with pytest.raises(ApiError) as exc: export_service.get_export_file(job_id) assert exc.value.status_code == 410 assert exc.value.code == "EXPORT_FILE_EXPIRED" def test_export_list_and_get() -> None: finished = _create_and_wait(_markdown_request("# 列表测试")) items, total = export_service.list_exports(limit=50, offset=0) assert total == 1 assert items[0].job_id == finished.job_id got = export_service.get_export(finished.job_id) assert got is not None and got.status == ExportStatus.completed assert export_service.get_export("export_missing") is None # --------------------------------------------------------------------------- # # 契约校验 # --------------------------------------------------------------------------- # def test_export_source_requires_matching_field() -> None: with pytest.raises(ValidationError): ExportSource(type=ExportSourceType.note, note_id=None) with pytest.raises(ValidationError): ExportSource(type=ExportSourceType.markdown, markdown=None)