Files
PlantAI-rag-new/tests/docgen/test_docgen_api_acceptance.py
jerryandClaude Fable 5 5888e1d5c1 test(docgen): 测试治理资产——131 条验收用例 + 三轮留痕 + E2E/回归套件
- tests/docgen/:DC-UNIT/API/CONF/E2E/UI/REGRESSION/PERF 分组用例与可复跑脚本,报告逐条留痕(R1-R3 全记录)
- 意图 30 条前后端同表双跑(pytest + node)
- Playwright headless:docgen-ui/docgen-regression spec + b3 benchpanel 第 5 TAB 断言更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 21:13:47 +08:00

392 lines
15 KiB
Python
Raw Permalink 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.
# -*- 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()