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()
|