#47a: 红头/标题字号颜色从 render_html 产物解析(新增 docgen/html_css.py: 内嵌 style 块 + 调用方传入的模板 css,组合选择器展开、px->pt),解析失败 回退历史常量(FF0000/22pt、h1/h2/h3 22/16/14pt,行为不变)。converter 与 导出路由把 css 传入 DOCX 转换,与 PDF/预览同源;非红头模板(如 report 灰 #333)不误渲染红头。r3 的 doc-body 包裹/表格/图片结构不回归。 #52: intent.py WRITE_VERB 补总结/概括/归纳,新增动词自指保护(唯一命中的 DOC_TYPE 等于命中动词本身时不算写文档意图,如「总结一下植物光合作用的过程」 仍 qa);cases_intent.md 同步规则与用例 31-34(共 34 条)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
852 lines
35 KiB
Python
852 lines
35 KiB
Python
"""app/routers/test_docgen_api.py —— docgen 路由层单元测试(P2)。
|
||
|
||
TestClient + mock 服务层(monkeypatch routers.docgen 模块内的契约函数),
|
||
不连真实 vstore/LLM。数据库用临时 SQLite(dependency_overrides,先例:
|
||
app/test_sessions_api.py)。制品落盘目录 monkeypatch 到 tmp_path。
|
||
|
||
覆盖:library 上传(成功/空文件/vstore 不可用)、generate(成功/意图护栏/
|
||
检索失败空模版集/服务未就绪落 error 行)、content(读/存)、edit、export+download
|
||
(RFC 5987 文件名)、主库管理(列表过滤/单条/改 meta/删除/rebuild-index)、
|
||
ArtifactBlock 块联合、配额 _check_quota(507 QUOTA_EXCEEDED 拒绝 generate/export)
|
||
与 TTL 惰性清理 _prune_expired_artifacts(generate 前执行,TL 裁决 2026-09-03)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.db import Database, get_db
|
||
from app.main import app
|
||
from app.routers import docgen as docgen_mod
|
||
|
||
|
||
def _run(coro):
|
||
"""同步跑一个协程(新建 event loop,避免与 pytest-asyncio 竞争)。"""
|
||
return asyncio.new_event_loop().run_until_complete(coro)
|
||
|
||
|
||
def _drain_pending():
|
||
"""跑完 generate 暂存的后台任务协程(请求返回后,fresh loop)。"""
|
||
import app.routers.docgen as mod
|
||
|
||
pending = getattr(mod, "_pending_test_tasks", [])
|
||
while pending:
|
||
_run(pending.pop())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# fixtures
|
||
# ---------------------------------------------------------------------------
|
||
# 注意(TL 联测诊断 2026-09-03):db fixture 必须为纯 sync + 手动事件循环
|
||
# (QA 验收脚本 tests/docgen/test_docgen_api_acceptance.py 同款可靠模式)。
|
||
# pytest-asyncio 1.4.0 的 event_loop fixture 跨文件复用有 bug:async fixture
|
||
# 与 sync 测试混用、多文件合跑时 loop finalizers 跨文件残留导致 async fixture
|
||
# 链崩溃('requested an async fixture')。fixture 链必须全 sync。
|
||
|
||
@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
|
||
# 制品落盘目录 → tmp(不碰真实 data/docgen)
|
||
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):
|
||
"""每个用例前把契约函数重置为「放行」形态(意图通过、服务可用)。
|
||
|
||
每用例独立 Semaphore(防未 drain 任务跨用例泄漏名额 → 误 429);
|
||
teardown drain 掉暂存的后台任务协程(fresh loop 里跑完,释放名额)。
|
||
"""
|
||
import app.routers.docgen as _mod
|
||
|
||
monkeypatch.setattr(_mod, "_DOCGEN_SEMAPHORE", asyncio.Semaphore(2))
|
||
_mod._pending_test_tasks = [] # 属性不存在时 setattr(spawn_sync 也会初始化)
|
||
|
||
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, **kwargs):
|
||
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(html: str, css: 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}
|
||
|
||
async def docx_html_ok(html: str, title: str, css: str = ""):
|
||
return b"DOCX-HTML-BYTES"
|
||
|
||
def render_ok(markdown, *, title, key="document", letterhead=""):
|
||
return (f"<html><body><main><h1>{title}</h1>{markdown}</main></body></html>", "")
|
||
|
||
async def bioflow_render_ok(markdown, *, title, letterhead=""):
|
||
return render_ok(markdown, title=title, letterhead=letterhead)
|
||
|
||
# 缺陷 #52:LLM env 齐备(任务守卫不误杀);缺键专项用例自行覆盖
|
||
monkeypatch.setenv("DOCGEN_LLM_BASE_URL", "https://api.deepseek.com")
|
||
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "test-key")
|
||
monkeypatch.setenv("DOCGEN_LLM_MODEL", "deepseek-chat")
|
||
# 后台任务在请求所在 loop 里无法原地跑完(TestClient 每请求一新 loop):
|
||
# spawn 时暂存协程(未绑定 loop),请求返回后由 _drain_pending 在新 loop 跑完
|
||
docgen_mod._pending_test_tasks = []
|
||
|
||
def spawn_sync(coro):
|
||
docgen_mod._pending_test_tasks.append(coro)
|
||
return None
|
||
|
||
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, "docx_from_html_document", docx_html_ok)
|
||
monkeypatch.setattr(docgen_mod, "render_html", render_ok)
|
||
monkeypatch.setattr(docgen_mod, "render_bioflow_report", bioflow_render_ok)
|
||
monkeypatch.setattr(docgen_mod, "pdf_from_html_document", 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)
|
||
monkeypatch.setattr(docgen_mod, "_spawn_task", spawn_sync)
|
||
yield
|
||
# teardown:drain 暂存的后台任务(释放 Semaphore 名额,防跨用例 BUSY 泄漏)。
|
||
# 防御性 try/except:db fixture 可能先于本 fixture 关闭(fixture 逆序析构),
|
||
# 届时任务写库抛「连接已关」——只影响清理,不得掩盖 monkeypatch 恢复。
|
||
try:
|
||
_drain_pending()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _upstream_down_async(*args, **kwargs):
|
||
raise docgen_mod.DocgenUnavailable("down")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# library 上传
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_library_upload_ok(client, db):
|
||
resp = client.post(
|
||
"/api/docgen/library",
|
||
files={"file": ("资料.md", "## 水稻栽培要点".encode("utf-8"), "text/markdown")},
|
||
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_library_upload_empty_file_422(client):
|
||
resp = client.post(
|
||
"/api/docgen/library",
|
||
files={"file": ("empty.md", b"", "text/markdown")},
|
||
data={},
|
||
)
|
||
assert resp.status_code == 422
|
||
body = resp.json()
|
||
assert body["ok"] is False
|
||
assert body["error"]["code"] == "VALIDATION"
|
||
|
||
|
||
def test_library_upload_vstore_down_503(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": ("a.md", b"hello", "text/markdown")},
|
||
data={},
|
||
)
|
||
assert resp.status_code == 503
|
||
body = resp.json()
|
||
assert body["error"]["code"] == "UPSTREAM_DOWN"
|
||
assert body["error"]["degraded"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# generate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_generate_ok(client, db, tmp_path):
|
||
"""缺陷 #52 契约:generate → 202 {ok,task_id,status:"queued"};任务同步跑完后
|
||
轮询 done 且 artifact 回填(测试内 _spawn_task 同步执行)。"""
|
||
resp = client.post(
|
||
"/api/docgen/generate",
|
||
json={"description": "写一份水稻实验报告", "session_id": "s1",
|
||
"library_ids": [], "template_id": "tpl-1"},
|
||
)
|
||
assert resp.status_code == 202, resp.text
|
||
body = resp.json()
|
||
assert body["ok"] is True
|
||
task_id = body["task_id"]
|
||
assert body["status"] == "queued"
|
||
assert len(task_id) == 36 # uuid4
|
||
|
||
_drain_pending()
|
||
|
||
# 轮询任务(后台任务已跑完 → done + artifact 回填)
|
||
resp = client.get(f"/api/docgen/tasks/{task_id}")
|
||
assert resp.status_code == 200, resp.text
|
||
task = resp.json()["task"]
|
||
assert task["id"] == task_id
|
||
assert task["session_id"] == "s1"
|
||
assert task["status"] == "done"
|
||
assert task["error"] is None
|
||
artifact = task["artifact"]
|
||
assert artifact is not None
|
||
assert artifact["template_name"] == "实验报告模板"
|
||
assert artifact["has_html"] is False
|
||
assert artifact["letterhead"] == ""
|
||
assert artifact["file_name"]
|
||
|
||
# 会话任务列表
|
||
resp = client.get("/api/docgen/tasks", params={"session_id": "s1"})
|
||
assert resp.status_code == 200
|
||
assert [t["id"] for t in resp.json()["tasks"]] == [task_id]
|
||
|
||
# 落库行
|
||
def _check():
|
||
async def inner():
|
||
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (artifact["id"],))
|
||
assert row is not None
|
||
assert row["status"] == "done"
|
||
assert row["session_id"] == "s1"
|
||
return _run(inner())
|
||
|
||
_check()
|
||
# 制品文件已写
|
||
assert (tmp_path / "docgen" / "artifacts" / "gen.md").read_text(encoding="utf-8").startswith("# 写一份")
|
||
|
||
|
||
def test_generate_intent_rejected_422_real_route(client):
|
||
"""真实 route 判定:非写文档意图(问答类)→ 422 INTENT_REJECTED(TL R1 验收)。"""
|
||
resp = client.post("/api/docgen/generate", json={"description": "今天天气怎么样"})
|
||
assert resp.status_code == 422
|
||
body = resp.json()
|
||
assert body["error"]["code"] == "INTENT_REJECTED"
|
||
|
||
|
||
def test_generate_empty_description_422(client):
|
||
resp = client.post("/api/docgen/generate", json={"description": " "})
|
||
assert resp.status_code == 422
|
||
assert resp.json()["error"]["code"] == "VALIDATION"
|
||
|
||
|
||
def test_generate_search_failure_skips_template(client, db, monkeypatch):
|
||
"""vstore 检索失败 → 空模版集合(生成跳过模版,不阻断)。"""
|
||
calls = {}
|
||
|
||
async def search_down(query, *, as_template=None, top_k=20):
|
||
raise docgen_mod.DocgenUnavailable("vstore down")
|
||
|
||
async def generate_capture(description, *, documents, template=None, **kwargs):
|
||
calls["template"] = template
|
||
calls["docs"] = documents
|
||
return {"markdown": "# 无模版生成", "md_path": "artifacts/gen2.md"}
|
||
|
||
monkeypatch.setattr(docgen_mod, "search_documents", search_down)
|
||
monkeypatch.setattr(docgen_mod, "generate_document", generate_capture)
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份总结"})
|
||
assert resp.status_code == 202
|
||
task_id = resp.json()["task_id"]
|
||
_drain_pending()
|
||
# 后台任务已跑完:确认模版空集 + 空库快照透传给了生成主体
|
||
assert calls["template"] is None
|
||
assert calls["docs"] == [] # 空库
|
||
task = client.get(f"/api/docgen/tasks/{task_id}").json()["task"]
|
||
assert task["status"] == "done"
|
||
|
||
|
||
def test_generate_pipeline_unavailable_task_error(client, db, monkeypatch):
|
||
"""缺陷 #52:生成服务不可用 → 202 后任务落 error(中文可读),不落 artifact。"""
|
||
monkeypatch.setattr(docgen_mod, "generate_document", _upstream_down_async)
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份总结"})
|
||
assert resp.status_code == 202
|
||
task_id = resp.json()["task_id"]
|
||
_drain_pending()
|
||
task = client.get(f"/api/docgen/tasks/{task_id}").json()["task"]
|
||
assert task["status"] == "error"
|
||
assert task["error"]
|
||
assert task["artifact"] is None
|
||
def _check():
|
||
async def inner():
|
||
rows = await db.fetchall("SELECT * FROM doc_artifacts")
|
||
assert len(rows) == 0
|
||
return _run(inner())
|
||
|
||
_check()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 制品:content / edit / export / download
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_artifact(client) -> str:
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份测试文档"})
|
||
assert resp.status_code == 202, resp.text
|
||
_drain_pending()
|
||
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
|
||
assert task["status"] == "done", task
|
||
return task["artifact"]["id"]
|
||
|
||
|
||
def test_artifact_content_ok(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_artifact_content_not_found(client):
|
||
resp = client.get("/api/docgen/artifacts/nope/content")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
|
||
|
||
def test_artifact_content_patch_saves(client, tmp_path):
|
||
aid = _make_artifact(client)
|
||
resp = client.patch(
|
||
f"/api/docgen/artifacts/{aid}/content", json={"markdown": "# 修改后的内容"}
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json()["markdown"] == "# 修改后的内容"
|
||
assert (tmp_path / "docgen" / "artifacts" / "gen.md").read_text(encoding="utf-8") == "# 修改后的内容"
|
||
|
||
|
||
def test_artifact_edit_ok(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_artifact_edit_no_intent_guard(client, monkeypatch):
|
||
"""2026-09-04 语义:edit 端点护栏已移除(被调用本身即活动文档上下文),
|
||
route 判 qa 也放行(HEAD 4749f77)。"""
|
||
aid = _make_artifact(client)
|
||
|
||
def route_no(text, has_attachments, has_active_artifact):
|
||
return "qa"
|
||
|
||
monkeypatch.setattr(docgen_mod, "route", route_no)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "帮我查个文献"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["ok"] is True
|
||
|
||
|
||
def test_artifact_edit_unavailable_503(client, monkeypatch):
|
||
aid = _make_artifact(client)
|
||
monkeypatch.setattr(docgen_mod, "apply_edit", _upstream_down_async)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "加一段"})
|
||
assert resp.status_code == 503
|
||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||
|
||
|
||
def test_artifact_edit_missing_artifact_404(client):
|
||
resp = client.post("/api/docgen/artifacts/nope/edit", json={"instruction": "加一段"})
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_export_docx_and_download(client, tmp_path):
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
|
||
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=docx")
|
||
# 落盘
|
||
assert (tmp_path / "docgen" / "artifacts" / f"{aid}.docx").read_bytes() == b"DOCX-HTML-BYTES"
|
||
# 下载
|
||
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=docx")
|
||
assert resp.status_code == 200
|
||
assert resp.content == b"DOCX-HTML-BYTES"
|
||
assert resp.headers["content-disposition"].startswith("attachment;")
|
||
assert resp.headers["content-type"].startswith("application/vnd.openxmlformats")
|
||
|
||
|
||
def test_export_pdf(client):
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "pdf"})
|
||
assert resp.status_code == 200
|
||
assert "download?format=pdf" in resp.json()["download_url"]
|
||
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=pdf")
|
||
assert resp.status_code == 200
|
||
assert resp.content == b"PDF-BYTES"
|
||
assert resp.headers["content-type"] == "application/pdf"
|
||
|
||
|
||
def test_export_invalid_format_422(client):
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "xlsx"})
|
||
assert resp.status_code == 422
|
||
assert resp.json()["error"]["code"] == "VALIDATION"
|
||
|
||
|
||
def test_export_unavailable_503(client, monkeypatch):
|
||
"""#47a 语义:HTML 转换失败回退 md2docx;两条路径都不可用才 503。"""
|
||
aid = _make_artifact(client)
|
||
monkeypatch.setattr(docgen_mod, "docx_from_html_document", _upstream_down_async)
|
||
monkeypatch.setattr(docgen_mod, "docx_from_markdown", _upstream_down_async)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
|
||
assert resp.status_code == 503
|
||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||
|
||
|
||
def test_download_without_export_404(client):
|
||
aid = _make_artifact(client)
|
||
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=pdf")
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 主库管理
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _seed_library(client, db):
|
||
def _seed():
|
||
async def inner():
|
||
now = "2026-09-03T00:00:00Z"
|
||
await db.execute(
|
||
"INSERT INTO doc_library (id, name, category, tags, description, as_template, "
|
||
"enc_path, embed_sha, active, created_at) VALUES "
|
||
"('l1', '水稻资料', '农学', '水稻,栽培', 'desc1', 1, 'e1', 's1', 1, ?)", (now,)
|
||
)
|
||
await db.execute(
|
||
"INSERT INTO doc_library (id, name, category, tags, description, as_template, "
|
||
"enc_path, embed_sha, active, created_at) VALUES "
|
||
"('l2', '小麦资料', '农学', '小麦', 'desc2', 0, 'e2', 's2', 1, ?)", (now,)
|
||
)
|
||
return _run(inner())
|
||
|
||
_seed()
|
||
|
||
|
||
def test_library_list_and_filters(client, db):
|
||
_seed_library(client, db)
|
||
resp = client.get("/api/docgen/library")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["ok"] is True
|
||
assert len(body["items"]) == 2
|
||
# q 过滤
|
||
resp = client.get("/api/docgen/library", params={"q": "水稻"})
|
||
assert [i["id"] for i in resp.json()["items"]] == ["l1"]
|
||
# as_template 过滤
|
||
resp = client.get("/api/docgen/library", params={"as_template": "true"})
|
||
assert [i["id"] for i in resp.json()["items"]] == ["l1"]
|
||
# category 过滤
|
||
resp = client.get("/api/docgen/library", params={"category": "农学"})
|
||
assert len(resp.json()["items"]) == 2
|
||
|
||
|
||
def test_library_get_and_patch_meta(client, db):
|
||
_seed_library(client, db)
|
||
resp = client.get("/api/docgen/library/l1")
|
||
assert resp.status_code == 200
|
||
assert resp.json()["item"]["name"] == "水稻资料"
|
||
|
||
resp = client.patch(
|
||
"/api/docgen/library/l1",
|
||
json={"name": "水稻栽培手册", "as_template": False},
|
||
)
|
||
assert resp.status_code == 200
|
||
item = resp.json()["item"]
|
||
assert item["name"] == "水稻栽培手册"
|
||
assert item["as_template"] is False
|
||
|
||
|
||
def test_library_patch_vstore_error_tolerated(client, db, monkeypatch):
|
||
"""vstore 元数据透传失败 → BFF 已落库,仍 200。"""
|
||
_seed_library(client, db)
|
||
monkeypatch.setattr(docgen_mod, "update_metadata", _upstream_down_async)
|
||
resp = client.patch("/api/docgen/library/l1", json={"description": "新描述"})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["item"]["description"] == "新描述"
|
||
|
||
|
||
def test_library_delete_ok_and_vstore_error_tolerated(client, db, monkeypatch):
|
||
_seed_library(client, db)
|
||
monkeypatch.setattr(docgen_mod, "delete_document", _upstream_down_async)
|
||
resp = client.delete("/api/docgen/library/l1")
|
||
assert resp.status_code == 204
|
||
def _check():
|
||
async def inner():
|
||
row = await db.fetchone("SELECT * FROM doc_library WHERE id = 'l1'")
|
||
assert row is None
|
||
return _run(inner())
|
||
|
||
_check()
|
||
|
||
|
||
def test_library_delete_missing_404(client):
|
||
resp = client.delete("/api/docgen/library/nope")
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_rebuild_index_ok(client):
|
||
resp = client.post("/api/docgen/library/rebuild-index")
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["ok"] is True
|
||
assert body["rebuilt"] is True
|
||
|
||
|
||
def test_rebuild_index_vstore_down_503(client, monkeypatch):
|
||
monkeypatch.setattr(docgen_mod, "rebuild_index", _upstream_down_async)
|
||
resp = client.post("/api/docgen/library/rebuild-index")
|
||
assert resp.status_code == 503
|
||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 块联合:ArtifactBlock
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_block_union_accepts_artifact_block():
|
||
from app.schemas import Block, MessageOut
|
||
|
||
msg = MessageOut(
|
||
id="m1", role="assistant",
|
||
blocks=[{"type": "artifact", "artifact_id": "a1", "title": "报告",
|
||
"template_name": "实验报告", "status": "done"}],
|
||
)
|
||
block = msg.blocks[0]
|
||
assert block.type == "artifact"
|
||
assert block.artifact_id == "a1"
|
||
assert msg.model_dump()["blocks"][0]["type"] == "artifact"
|
||
|
||
|
||
def test_block_union_rejects_unknown_type():
|
||
from app.schemas import MessageOut
|
||
|
||
with pytest.raises(Exception):
|
||
MessageOut(id="m1", role="assistant", blocks=[{"type": "nope"}])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 配额 / TTL(TL 裁决 2026-09-03:V1 由 BFF 实现)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _TinySettings:
|
||
"""docgen_quota_gb = 0:任何文件体积都超限(artifacts/ 只要有文件)。"""
|
||
|
||
docgen_quota_gb = 0.0
|
||
docgen_artifact_ttl_days = 30
|
||
|
||
|
||
def test_generate_quota_exceeded_507(client, db, tmp_path, monkeypatch):
|
||
# 先正常生成一个制品(占用 artifacts/ 体积)
|
||
aid = _make_artifact(client)
|
||
assert (tmp_path / "docgen" / "artifacts" / "gen.md").exists()
|
||
monkeypatch.setattr(docgen_mod, "get_settings", lambda: _TinySettings())
|
||
resp = client.post("/api/docgen/generate", json={"description": "再写一份测试报告"})
|
||
assert resp.status_code == 507
|
||
body = resp.json()
|
||
assert body["ok"] is False
|
||
assert body["error"]["code"] == "QUOTA_EXCEEDED"
|
||
assert body["error"]["degraded"] is True
|
||
|
||
|
||
def test_export_quota_exceeded_507(client, db, tmp_path, monkeypatch):
|
||
aid = _make_artifact(client)
|
||
assert (tmp_path / "docgen" / "artifacts" / "gen.md").exists()
|
||
monkeypatch.setattr(docgen_mod, "get_settings", lambda: _TinySettings())
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
|
||
assert resp.status_code == 507
|
||
assert resp.json()["error"]["code"] == "QUOTA_EXCEEDED"
|
||
|
||
|
||
def test_check_quota_passes_when_under_limit(client, db, tmp_path):
|
||
"""配额充足(默认 50GB 设置走真实 get_settings 时仅用 tmp 目录)→ 不拒绝。"""
|
||
aid = _make_artifact(client) # 默认设置下生成成功即证明配额未误拦
|
||
resp = client.post("/api/docgen/generate", json={"description": "再写一份测试报告"})
|
||
assert resp.status_code == 202
|
||
resp2 = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
|
||
assert resp2.status_code == 200
|
||
|
||
|
||
def _insert_artifact_row(db, aid, created_at, status="done"):
|
||
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 "
|
||
"(?, '', ?, 't', '', ?, ?, ?, NULL, ?)",
|
||
(aid, status, "artifacts/old.md", "artifacts/old.docx", "artifacts/old.pdf",
|
||
created_at),
|
||
)
|
||
return _run(inner())
|
||
|
||
_ins()
|
||
|
||
|
||
def test_generate_prunes_expired_artifacts(client, db, tmp_path):
|
||
"""generate 开始前:过期行(TTL 超 30 天)→ 文件删除 + status 置 expired。"""
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
old = (datetime.now(timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
fresh = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
# 造两行:过期(有文件)+ 新鲜(有文件)
|
||
(tmp_path / "docgen" / "artifacts").mkdir(parents=True, exist_ok=True)
|
||
for aid, ts in (("exp-1", old), ("fresh-1", fresh)):
|
||
_insert_artifact_row(db, aid, ts)
|
||
for fname in ("old.md", "old.docx", "old.pdf"):
|
||
(tmp_path / "docgen" / "artifacts" / fname).write_text("x", encoding="utf-8")
|
||
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份测试报告"})
|
||
assert resp.status_code == 202
|
||
|
||
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
|
||
fresh_row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = 'fresh-1'")
|
||
assert fresh_row["status"] == "done"
|
||
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()
|
||
|
||
|
||
def test_prune_expired_missing_files_tolerated(client, db, tmp_path):
|
||
"""过期行文件缺失 → 清理容错(不抛异常),行仍置 expired。"""
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
old = (datetime.now(timezone.utc) - timedelta(days=40)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
_insert_artifact_row(db, "exp-nofile", old)
|
||
# 不创建任何文件
|
||
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份测试报告"})
|
||
assert resp.status_code == 202
|
||
|
||
def _check():
|
||
async def inner():
|
||
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = 'exp-nofile'")
|
||
assert row["status"] == "expired"
|
||
return _run(inner())
|
||
|
||
_check()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 意图护栏 R1(TL 2026-09-03:按端点类型传参;edit 放行编辑指令;export 无护栏)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_edit_endpoint_accepts_edit_instruction_real_route(client):
|
||
"""TL R1 验收:edit 端点「润色一下第一段」→ 200(route 判 edit,放行)。"""
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "润色一下第一段"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["ok"] is True
|
||
|
||
|
||
def test_export_no_intent_guard_real_route(client):
|
||
"""TL R1 验收:export 端点无护栏,任意合法 format → 200。"""
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["ok"] is True
|
||
|
||
|
||
def test_export_invalid_format_still_422(client):
|
||
"""TL R1 验收:export 护栏取消后 format 非法仍 422(pydantic Literal 白名单)。"""
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "xlsx"})
|
||
assert resp.status_code == 422
|
||
assert resp.json()["error"]["code"] == "VALIDATION"
|
||
|
||
|
||
def test_edit_endpoint_qa_instruction_passes_real_route(client):
|
||
"""2026-09-04 语义:edit 端点无护栏(HEAD 4749f77),问答类指令也走编辑链路。"""
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "帮我查个文献"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["ok"] is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 缺陷 #52 异步任务化专项
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_generate_returns_202_and_task_roundtrip(client, db):
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份异步测试文档", "session_id": "s52"})
|
||
assert resp.status_code == 202
|
||
body = resp.json()
|
||
assert set(body.keys()) == {"ok", "task_id", "status"}
|
||
assert body["ok"] is True and body["status"] == "queued"
|
||
# 任务不存在 → 404
|
||
resp = client.get("/api/docgen/tasks/does-not-exist")
|
||
assert resp.status_code == 404
|
||
_drain_pending()
|
||
# 任务详情契约字段(附录 B)
|
||
task = client.get(f"/api/docgen/tasks/{body['task_id']}").json()["task"]
|
||
assert task["status"] == "done"
|
||
assert isinstance(task["progress"], list)
|
||
assert task["artifact"]["title"]
|
||
|
||
|
||
def test_generate_missing_llm_env_task_error_names_keys(client, db, monkeypatch):
|
||
"""DOCGEN_LLM_* 不齐 → 任务 error 写明缺哪个键。"""
|
||
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份测试文档"})
|
||
assert resp.status_code == 202
|
||
_drain_pending()
|
||
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
|
||
assert task["status"] == "error"
|
||
assert "DOCGEN_LLM_API_KEY" in (task["error"] or "")
|
||
|
||
|
||
def test_generate_busy_429_when_semaphore_full(client, db, monkeypatch):
|
||
"""满并发 → 429 BUSY 信封(快路径不建任务)。"""
|
||
import asyncio
|
||
|
||
# 意图护栏放行(本用例只关心并发名额,不测 route 判型)
|
||
monkeypatch.setattr(docgen_mod, "route", lambda *a, **k: "docgen")
|
||
monkeypatch.setattr(docgen_mod, "_DOCGEN_SEMAPHORE", asyncio.Semaphore(1))
|
||
resp1 = client.post("/api/docgen/generate", json={"description": "写一份占用名额"})
|
||
assert resp1.status_code == 202
|
||
# 第二个请求:满并发(名额被第一个任务占用)
|
||
resp2 = client.post("/api/docgen/generate", json={"description": "再写一份"})
|
||
assert resp2.status_code == 429
|
||
body = resp2.json()
|
||
assert body["ok"] is False
|
||
assert body["error"]["code"] == "BUSY"
|
||
# 跑完第一个任务(finally 释放名额),任务终态 done
|
||
_drain_pending()
|
||
task = client.get(f"/api/docgen/tasks/{resp1.json()['task_id']}").json()["task"]
|
||
assert task["status"] == "done"
|
||
|
||
|
||
def test_startup_cleanup_marks_running_tasks_error(db, tmp_path):
|
||
"""服务重启:running/queued → status='error', error='服务重启,任务中断,请重试'。"""
|
||
from app.routers import docgen as docgen_mod2
|
||
|
||
# 直接写一个 running 行(不经 API)
|
||
def _seed():
|
||
async def inner():
|
||
now = "2026-09-10T00:00:00Z"
|
||
await db.execute(
|
||
"INSERT INTO docgen_tasks (id, session_id, status, artifact_id, title, "
|
||
"error, progress, created_at, updated_at) VALUES "
|
||
"('t-running', 's', 'running', NULL, 't', NULL, '[]', ?, ?)",
|
||
(now, now),
|
||
)
|
||
return _run(inner())
|
||
|
||
_seed()
|
||
# 模拟重启:重新 connect()(幂等迁移 + 启动清理)
|
||
database = Database(db.path)
|
||
_run(database.connect())
|
||
|
||
def _check():
|
||
async def inner():
|
||
row = await db.fetchone("SELECT * FROM docgen_tasks WHERE id = 't-running'")
|
||
assert row["status"] == "error"
|
||
assert row["error"] == "服务重启,任务中断,请重试"
|
||
return _run(inner())
|
||
|
||
_check()
|
||
_run(database.close())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# r3-42-01 内置模板回退语义(2026-09-10)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_pick_template_builtin_key_resolves_without_vstore_hit():
|
||
"""template_id 是内置 key → 直接解析为内置模版,不依赖 vstore 检索命中。"""
|
||
from app.services.docgen.templating import get_template_asset, list_templates
|
||
|
||
keys = [e["key"] for e in list_templates()]
|
||
assert "official" in keys and "plan" in keys and "document" in keys
|
||
|
||
# vstore 检索结果为空集(库内无条目)
|
||
tpl = docgen_mod._pick_template([], "official")
|
||
assert tpl is not None
|
||
assert tpl["id"] == "official"
|
||
assert tpl["source"] == "builtin"
|
||
assert tpl["name"] == "通用公文(红头)"
|
||
# 内置模版必须真实存在(get_template_asset 非空才解析)
|
||
assert get_template_asset("official") is not None
|
||
|
||
|
||
def test_pick_template_non_builtin_id_still_vstore():
|
||
"""非内置 key → vstore 检索按 id 命中;未命中 → None(自动推荐)。"""
|
||
tpl = docgen_mod._pick_template([{"id": "lib-9", "name": "库内模板"}], "lib-9")
|
||
assert tpl is not None and tpl["name"] == "库内模板"
|
||
assert docgen_mod._pick_template([], "lib-9") is None
|
||
# 未指定 template_id → vstore 推荐第一条
|
||
assert docgen_mod._pick_template([{"id": "lib-1"}], None)["id"] == "lib-1"
|
||
|
||
|
||
def test_generate_builtin_official_uses_key(client, db, monkeypatch):
|
||
"""r3-42-01 复现:vstore 检索空 + template_id=official → 产物 key=official。"""
|
||
async def search_empty(query, *, as_template=None, top_k=20):
|
||
return []
|
||
|
||
monkeypatch.setattr(docgen_mod, "search_documents", search_empty)
|
||
resp = client.post("/api/docgen/generate", json={
|
||
"description": "撰写一份水稻病虫害防治方案", "session_id": "r3-42",
|
||
"template_id": "official",
|
||
})
|
||
assert resp.status_code == 202, resp.text
|
||
_drain_pending()
|
||
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
|
||
assert task["status"] == "done", task["error"]
|
||
artifact = task["artifact"]
|
||
assert artifact["template_key"] == "official", artifact
|
||
assert artifact["template_name"] == "通用公文(红头)", artifact
|