Files

392 lines
15 KiB
Python
Raw Permalink Normal View History

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