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