- 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>
628 lines
25 KiB
Python
628 lines
25 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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):
|
||
"""每个用例前把契约函数重置为「放行」形态(意图通过、服务可用)。"""
|
||
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)
|
||
|
||
|
||
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):
|
||
resp = client.post(
|
||
"/api/docgen/generate",
|
||
json={"description": "写一份水稻实验报告", "session_id": "s1",
|
||
"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
|
||
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):
|
||
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 == 200
|
||
assert calls["template"] is None
|
||
assert calls["docs"] == [] # 空库
|
||
|
||
|
||
def test_generate_pipeline_unavailable_503_and_error_row(client, db, monkeypatch):
|
||
monkeypatch.setattr(docgen_mod, "generate_document", _upstream_down_async)
|
||
resp = client.post("/api/docgen/generate", json={"description": "写一份总结"})
|
||
assert resp.status_code == 503
|
||
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
|
||
# error 行已落库(前端可查状态)
|
||
def _check():
|
||
async def inner():
|
||
rows = await db.fetchall("SELECT * FROM doc_artifacts")
|
||
assert len(rows) == 1
|
||
assert rows[0]["status"] == "error"
|
||
assert rows[0]["error"]
|
||
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 == 200, resp.text
|
||
return resp.json()["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_intent_rejected(client, monkeypatch):
|
||
"""护栏机制:route 判 qa → 422(monkeypatch route;真实判定见新增用例)。"""
|
||
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 == 422
|
||
assert resp.json()["error"]["code"] == "INTENT_REJECTED"
|
||
|
||
|
||
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-BYTES"
|
||
# 下载
|
||
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=docx")
|
||
assert resp.status_code == 200
|
||
assert resp.content == b"DOCX-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):
|
||
aid = _make_artifact(client)
|
||
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 == 200
|
||
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 == 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
|
||
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 == 200
|
||
|
||
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_rejected_real_route(client):
|
||
"""edit 端点传问答类指令:route 判 qa(EDIT_WORD 不命中)→ 422 护栏仍生效。"""
|
||
aid = _make_artifact(client)
|
||
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "帮我查个文献"})
|
||
assert resp.status_code == 422
|
||
assert resp.json()["error"]["code"] == "INTENT_REJECTED"
|