# -*- coding: utf-8 -*- """tests/docgen/test_docgen_api_acceptance.py —— DC-API-01~16(BFF 端点验收口径)。 v1.2 签名适配(2026-09-03): - 按 dev2 已验证范式:monkeypatch routers.docgen 模块命名空间内的契约函数 (is_docgen_intent/ingest_bytes/search_documents/generate_document/apply_edit/ docx_from_markdown/pdf_from_markdown/update_metadata/delete_document/rebuild_index), TestClient + dependency_overrides 注入 tmp SQLite; - fixture 链全 sync + 手动事件循环(TL 联测诊断:pytest-asyncio 跨文件 loop 残留 bug); - 断言口径不变(验收预期),仅适配调用形态; - 前置统一:意图护栏放行、服务可用(默认 mock),单条按用例换行为。 执行:bash tests/docgen/run_all.sh api """ from __future__ import annotations import asyncio import sys from pathlib import Path import pytest REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO)) sys.path.insert(0, str(REPO / "sdk")) pytestmark = pytest.mark.api from fastapi.testclient import TestClient # noqa: E402 from app.db import Database, get_db # noqa: E402 from app.main import app # noqa: E402 from app.routers import docgen as docgen_mod # noqa: E402 def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) # --------------------------------------------------------------------------- # fixtures(全 sync,先例:sdk/app/routers/test_docgen_api.py) # --------------------------------------------------------------------------- @pytest.fixture() def db(tmp_path): database = Database(tmp_path / "docgen" / "plantai.db") _run(database.connect()) yield database _run(database.close()) @pytest.fixture() def client(db, tmp_path, monkeypatch): app.dependency_overrides[get_db] = lambda: db monkeypatch.setattr(docgen_mod, "_artifact_dir", lambda settings=None: tmp_path / "docgen") try: yield TestClient(app) finally: app.dependency_overrides.clear() @pytest.fixture(autouse=True) def _reset_mocks(monkeypatch): """默认放行形态:意图通过、服务可用;单条按需替换。""" async def intent_ok(text: str) -> bool: return True async def ingest_ok(filename, content, *, name, category="", tags="", description="", as_template=True): return {"document_id": "lib-1", "enc_path": "enc/doc1.bin", "embed_sha": "abc", "size": len(content)} async def search_ok(query, *, as_template=None, top_k=20): return [{"id": "tpl-1", "name": "实验报告模板"}] async def generate_ok(description, *, documents, template=None): return {"markdown": f"# {description}", "md_path": "artifacts/gen.md"} async def edit_ok(markdown: str, instruction: str): return {"markdown": markdown + "\n\n编辑完成"} async def docx_ok(markdown: str, title: str): return b"DOCX-BYTES" async def pdf_ok(markdown: str, title: str): return b"PDF-BYTES" async def update_meta_ok(document_id: str, metadata: dict): return {"updated": 1} async def delete_ok(document_id: str): return {"deleted": 1} async def rebuild_ok(): return {"rebuilt": True} monkeypatch.setattr(docgen_mod, "is_docgen_intent", intent_ok) monkeypatch.setattr(docgen_mod, "ingest_bytes", ingest_ok) monkeypatch.setattr(docgen_mod, "search_documents", search_ok) monkeypatch.setattr(docgen_mod, "generate_document", generate_ok) monkeypatch.setattr(docgen_mod, "apply_edit", edit_ok) monkeypatch.setattr(docgen_mod, "docx_from_markdown", docx_ok) monkeypatch.setattr(docgen_mod, "pdf_from_markdown", pdf_ok) monkeypatch.setattr(docgen_mod, "update_metadata", update_meta_ok) monkeypatch.setattr(docgen_mod, "delete_document", delete_ok) monkeypatch.setattr(docgen_mod, "rebuild_index", rebuild_ok) def _make_artifact(client) -> str: resp = client.post("/api/docgen/generate", json={"description": "写一份测试报告"}) assert resp.status_code == 200, resp.text return resp.json()["artifact"]["id"] # --------------------------------------------------------------------------- # DC-API-01/02 上传入库 # --------------------------------------------------------------------------- def test_DC_API_01_upload_library(client, db): resp = client.post( "/api/docgen/library", files={"file": ("样例通知.docx", b"PK\x03\x04 placeholder", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}, data={"name": "样例通知", "category": "通知", "as_template": "true"}, ) assert resp.status_code == 201, resp.text body = resp.json() assert body["ok"] is True assert body["items"][0]["library_id"] == "lib-1" assert body["items"][0]["name"] == "样例通知" assert body["items"][0]["as_template"] is True def test_DC_API_02_upload_vstore_down(client, monkeypatch): async def ingest_down(*args, **kwargs): raise docgen_mod.DocgenUnavailable("vstore down") monkeypatch.setattr(docgen_mod, "ingest_bytes", ingest_down) resp = client.post( "/api/docgen/library", files={"file": ("样例通知.docx", b"PK\x03\x04 placeholder", "application/octet-stream")}, ) assert resp.status_code == 503 body = resp.json() assert body["ok"] is False assert body["error"]["code"] == "UPSTREAM_DOWN" assert body["error"]["degraded"] is True # --------------------------------------------------------------------------- # DC-API-03/04/05 生成 # --------------------------------------------------------------------------- def test_DC_API_03_generate_ok(client, db): resp = client.post( "/api/docgen/generate", json={"description": "帮我写一份年度工作总结", "session_id": "sess1", "library_ids": [], "template_id": "tpl-1"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["ok"] is True aid = body["artifact"]["id"] assert body["artifact"]["status"] == "done" assert body["artifact"]["template_name"] == "实验报告模板" def _check(): async def inner(): row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (aid,)) assert row is not None and row["status"] == "done" return _run(inner()) _check() def test_DC_API_04_generate_intent_guard(client, monkeypatch): """非 docgen 意图(按冻结表)→ 422 INTENT_REJECTED,且不触发生成。""" calls = {"n": 0} async def intent_no(text: str) -> bool: return False async def generate_count(description, *, documents, template=None): calls["n"] += 1 return {"markdown": "# x", "md_path": "artifacts/x.md"} monkeypatch.setattr(docgen_mod, "is_docgen_intent", intent_no) monkeypatch.setattr(docgen_mod, "generate_document", generate_count) resp = client.post("/api/docgen/generate", json={"description": "今天天气怎么样"}) assert resp.status_code == 422 assert resp.json()["error"]["code"] == "INTENT_REJECTED" assert calls["n"] == 0 def test_DC_API_05_generate_error(client, db, monkeypatch): async def gen_fail(description, *, documents, template=None): raise RuntimeError("simulated pipeline failure") monkeypatch.setattr(docgen_mod, "generate_document", gen_fail) resp = client.post("/api/docgen/generate", json={"description": "帮我写一份年度工作总结"}) assert resp.status_code == 500 assert resp.json()["ok"] is False assert resp.json()["error"]["code"] == "INTERNAL" def _check(): async def inner(): rows = await db.fetchall("SELECT * FROM doc_artifacts") assert len(rows) == 1 and rows[0]["status"] == "error" return _run(inner()) _check() # --------------------------------------------------------------------------- # DC-API-06/07 content # --------------------------------------------------------------------------- def test_DC_API_06_get_content(client): aid = _make_artifact(client) resp = client.get(f"/api/docgen/artifacts/{aid}/content") assert resp.status_code == 200 body = resp.json() assert body["ok"] is True assert body["markdown"].startswith("# 写一份测试报告") assert body["status"] == "done" def test_DC_API_07_patch_content(client, tmp_path): aid = _make_artifact(client) resp = client.patch(f"/api/docgen/artifacts/{aid}/content", json={"markdown": "# 新标题\n\n新正文"}) assert resp.status_code == 200 assert resp.json()["ok"] is True assert resp.json()["markdown"] == "# 新标题\n\n新正文" # 落盘复验 assert (tmp_path / "docgen" / "artifacts" / "gen.md").read_text(encoding="utf-8") == "# 新标题\n\n新正文" # --------------------------------------------------------------------------- # DC-API-08/09/10 edit/export/download # --------------------------------------------------------------------------- def test_DC_API_08_edit(client): aid = _make_artifact(client) resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "润色一下第一段"}) assert resp.status_code == 200 body = resp.json() assert body["ok"] is True assert body["markdown"].endswith("编辑完成") def test_DC_API_09_export(client): aid = _make_artifact(client) for fmt in ("pdf", "docx"): resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": fmt}) assert resp.status_code == 200, resp.text body = resp.json() assert body["ok"] is True assert body["download_url"].endswith(f"/api/docgen/artifacts/{aid}/download?format={fmt}") resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "exe"}) assert resp.status_code == 422 assert resp.json()["error"]["code"] == "VALIDATION" def test_DC_API_10_download_attachment(client, tmp_path): """下载头断言:attachment + RFC 5987 filename*(dev2 实现已验证)。""" aid = _make_artifact(client) client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "pdf"}) resp = client.get(f"/api/docgen/artifacts/{aid}/download", params={"format": "pdf"}) assert resp.status_code == 200 assert resp.content == b"PDF-BYTES" assert resp.headers["content-disposition"].startswith("attachment;") assert resp.headers["content-type"] == "application/pdf" # --------------------------------------------------------------------------- # DC-API-11/12/13/14 主库管理与边界 # --------------------------------------------------------------------------- def test_DC_API_11_list_library(client): resp = client.get("/api/docgen/library", params={"q": "通知", "category": "通知", "as_template": "true"}) assert resp.status_code == 200 assert resp.json()["ok"] is True def test_DC_API_12_get_library_doc(client, db): def _seed(): async def inner(): await db.execute( "INSERT INTO doc_library (id, name, category, tags, description, as_template, " "enc_path, embed_sha, active, created_at) VALUES " "('lib-1', '样例', '通知', '', '', 1, 'e1', 's1', 1, '2026-09-03T00:00:00Z')", ) return _run(inner()) _seed() resp = client.get("/api/docgen/library/lib-1") assert resp.status_code == 200 assert resp.json()["item"]["name"] == "样例" def test_DC_API_13_patch_delete_rebuild(client, db): def _seed(): async def inner(): await db.execute( "INSERT INTO doc_library (id, name, category, tags, description, as_template, " "enc_path, embed_sha, active, created_at) VALUES " "('lib-1', '样例', '通知', '', '', 1, 'e1', 's1', 1, '2026-09-03T00:00:00Z')", ) return _run(inner()) _seed() resp = client.patch("/api/docgen/library/lib-1", json={"as_template": False}) assert resp.status_code == 200 assert resp.json()["item"]["as_template"] is False resp = client.delete("/api/docgen/library/lib-1") assert resp.status_code == 204 def _gone(): async def inner(): row = await db.fetchone("SELECT * FROM doc_library WHERE id = 'lib-1'") assert row is None return _run(inner()) _gone() resp = client.post("/api/docgen/library/rebuild-index") assert resp.status_code == 200 assert resp.json()["ok"] is True def test_DC_API_14_unknown_ids(client): resp = client.get("/api/docgen/artifacts/nonexist/content") assert resp.status_code == 404 assert resp.json()["ok"] is False assert resp.json()["error"]["code"] == "NOT_FOUND" # --------------------------------------------------------------------------- # DC-API-15/16 配额与 TTL 护栏(dev2 已实现) # --------------------------------------------------------------------------- class _TinySettings: docgen_quota_gb = 0.0 # 任何文件体积都超限 docgen_artifact_ttl_days = 30 def test_DC_API_15_quota_exceeded(client, monkeypatch): """制品目录超 docgen_quota_gb → generate/export 被拒 507 QUOTA_EXCEEDED。""" _make_artifact(client) # 先落一个制品占体积 monkeypatch.setattr(docgen_mod, "get_settings", lambda: _TinySettings()) resp = client.post("/api/docgen/generate", json={"description": "帮我写一份年度工作总结"}) assert resp.status_code == 507 assert resp.json()["ok"] is False assert resp.json()["error"]["code"] == "QUOTA_EXCEEDED" assert resp.json()["error"]["degraded"] is True def test_DC_API_16_ttl_cleanup(client, db, tmp_path): """过期 artifact(created_at 超 TTL)在 generate 前被清理:文件删除 + status=expired。""" from datetime import datetime, timedelta, timezone old = (datetime.now(timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%dT%H:%M:%SZ") (tmp_path / "docgen" / "artifacts").mkdir(parents=True, exist_ok=True) for fname in ("old.md", "old.docx", "old.pdf"): (tmp_path / "docgen" / "artifacts" / fname).write_text("x", encoding="utf-8") def _ins(): async def inner(): await db.execute( "INSERT INTO doc_artifacts (id, session_id, status, title, template_name, " "md_path, docx_path, pdf_path, error, created_at) VALUES " "('exp-1', '', 'done', 't', '', 'artifacts/old.md', 'artifacts/old.docx', " "'artifacts/old.pdf', NULL, ?)", (old,), ) return _run(inner()) _ins() resp = client.post("/api/docgen/generate", json={"description": "帮我写一份年度工作总结"}) assert resp.status_code == 200 def _check(): async def inner(): row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = 'exp-1'") assert row["status"] == "expired" assert row["md_path"] is None and row["docx_path"] is None and row["pdf_path"] is None return _run(inner()) _check() assert not (tmp_path / "docgen" / "artifacts" / "old.md").exists() assert not (tmp_path / "docgen" / "artifacts" / "old.docx").exists() assert not (tmp_path / "docgen" / "artifacts" / "old.pdf").exists()