Files
cma-management/backend/tests/test_doc_center_reports_20260924.py
Hermes CI Fix 2cff58ca6a feat(docs-center): report reading section (type=report) on /api/cma/docs
Increment #2 over PoC ede0524 (task yanxue-20260924-cma-report-source).

- backend derives reports from existing drop points (wisdom/_reports + every profile pending-tasks/_reports), read live per request, no cache, no rebuild

- gate = front-matter plus non-empty conclusion; missing either means silently out of the list (receipts/audits never enter); title/date/domain/sources missing are marked incomplete instead of rejected

- entity + role partitioning (ceo/it cross-entity; others own entity plus group/generic); kpi_refs resolve to a one-hop metric link

- new GET /api/cma/docs/report/{rid} detail endpoint; knowledge branch unchanged (type defaults to knowledge)

- frontend: CMAKnowledge adds a report reading pane (filters + detail dialog), reuses the existing markdown renderer, no new deps

(cherry picked from commit 7c6e0857e68133f5ba2a539fd3569aeb82547911)
2026-10-18 16:31:52 +08:00

312 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""报告解读(type=report)端点测试 —— 任务 yanxue-20260924-cma-report-source
规范:/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md
覆盖:
1. 正样本:带 front-matter + `conclusion` ⇒ 进列表(字段完整 / kpi_links 一跳链)
2. 负对照 a:无 front-matter 的回执件 ⇒ **不进列表、不报错**(excluded.no_frontmatter 计数)
3. 负对照 b:有 front-matter 但缺 `conclusion` ⇒ 不进列表(excluded.missing_conclusion)
4. 软必填:title/date/domain/sources 缺失 ⇒ 仍收录 + 标 incomplete + 兜底值
5. 筛选:domain / entity / kind / date_from+date_to
6. 权限:非本账套 entity 的报告不可见(列表不含 + 详情 403);ceo/it 跨账套可见
7. 详情:正文 200 / 未知 rid 404 / 非法 rid 400(路径穿越)
8. 核心判据:改 md 的 conclusion ⇒ 下一请求即变(运行期读盘,不改前端、不 rebuild)
9. 回归:`type` 缺省 = 知识库分支(既有结构 categories/skipped/format 逐字不变)
10. 落点缺失 ⇒ available=false 且 count=0,不 500
"""
import json
import os
import pytest
from fastapi.testclient import TestClient
from tests.conftest import (
TEST_USER_PASSWORD, auth_header, create_test_user, get_token_for_user,
)
REPORTS_ENV = "CMA_REPORTS_DIRS"
DOCS_ENV = "CMA_DOCS_DIR"
@pytest.fixture
def token(client: TestClient, db) -> str:
"""默认会话 = ceo 角色 + entity_id=1(酣客)"""
create_test_user(db)
return get_token_for_user(client)
def write_report(root, name, *, title="样例报告", date="2026-09-24", domain="tech",
entity="通用", kind="report", conclusion="一句话结论(≤60字)",
sources="srv/hermes/wisdom/_reports/x.md", extra="", body="正文段落。\n"):
path = root / f"{name}.md"
lines = ["---"]
if title is not None:
lines.append(f"title: {title}")
if date is not None:
lines.append(f"date: {date}")
if domain is not None:
lines.append(f"domain: {domain}")
if entity is not None:
lines.append(f"entity: {entity}")
if kind is not None:
lines.append(f"kind: {kind}")
if conclusion is not None:
lines.append(f"conclusion: {conclusion}")
if sources is not None:
lines.append(f"sources: {sources}")
if extra:
lines.append(extra)
lines.append("---")
path.write_text("\n".join(lines) + "\n" + body, encoding="utf-8")
return path
@pytest.fixture
def reports_dir(tmp_path, monkeypatch):
"""隔离的临时报告落点(不动任何真实落点)"""
root = tmp_path / "wisdom-reports"
root.mkdir(parents=True)
monkeypatch.setenv(REPORTS_ENV, f"wisdom={root}")
return root
@pytest.fixture
def docs_dir(tmp_path, monkeypatch):
root = tmp_path / "cma-content"
(root / "docs").mkdir(parents=True)
(root / "categories.json").write_text(json.dumps({"categories": [
{"key": "term", "name": "术语解释", "icon": "📖", "order": 10},
]}, ensure_ascii=False), encoding="utf-8")
monkeypatch.setenv(DOCS_ENV, str(root))
return root
@pytest.fixture
def two_entities(client, db):
"""额外造一个 active 账套(博海,id=2)—— 权限分区用例用"""
from app.models import Entity
if not db.query(Entity).filter(Entity.id == 2).first():
db.add(Entity(id=2, name="博海测试企业", short_name="博海", status="active"))
db.commit()
return db
def login_for(client, db, username, entity_id, role="finance"):
"""建用户 + 只授权指定账套 + 登录,返回 token(显式 UserEntity ⇒ 不触发全网自动授权)"""
from app.models import UserEntity
user = create_test_user(db, username=username, name=username, role=role)
db.add(UserEntity(user_id=user.id, entity_id=entity_id))
db.commit()
r = client.post("/api/cma/auth/login", json={
"username": username, "password": TEST_USER_PASSWORD, "entity_id": entity_id,
})
assert r.status_code == 200, r.text
return r.json()["token"]
class TestReportListPositive:
def test_frontmatter_report_listed(self, client, reports_dir, token):
write_report(reports_dir, "r-ok", extra="kpi_refs: kpi:7, 应收账款周转率")
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["type"] == "report" and body["count"] == 1 and body["available"] is True
rec = body["data"][0]
assert rec["id"] == "wisdom:r-ok" and rec["title"] == "样例报告"
assert rec["conclusion"] == "一句话结论(≤60字)"
assert rec["domain"] == "tech" and rec["entity"] == "通用" and rec["kind"] == "report"
assert rec["sources"] == ["srv/hermes/wisdom/_reports/x.md"]
assert rec["incomplete"] == []
# kpi_refs 一跳链:数字/kpi:<id> → 指标详情页;文本 → 指标字典页关键词
links = {x["raw"]: x["url"] for x in rec["kpi_links"]}
assert links["kpi:7"] == "/kpis/7"
assert links["应收账款周转率"].startswith("/kpis?keyword=")
# 列表不带正文(与知识库同口径)
assert "content" not in rec
assert body["sources"][0]["key"] == "wisdom" and body["sources"][0]["md_count"] == 1
def test_facets_and_sort(self, client, reports_dir, token):
write_report(reports_dir, "r-a", title="旧", date="2026-09-20", domain="finance")
write_report(reports_dir, "r-b", title="新", date="2026-09-24", domain="tech")
body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
assert [d["title"] for d in body["data"]] == ["新", "旧"] # date desc
assert {f["key"] for f in body["facets"]["domain"]} == {"finance", "tech"}
assert body["facets"]["date"] == {"min": "2026-09-20", "max": "2026-09-24"}
class TestNegativeControls:
"""★ 本单负对照判据:不进列表、不报错"""
def test_no_frontmatter_excluded_silently(self, client, reports_dir, token):
(reports_dir / "receipt-no-front.md").write_text(
"# 回执\n\n任务已完成,无 front-matter。\n", encoding="utf-8")
write_report(reports_dir, "r-ok")
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
assert r.status_code == 200, r.text # 不报错
body = r.json()
assert [d["id"] for d in body["data"]] == ["wisdom:r-ok"]
assert body["excluded"]["no_frontmatter"] == 1
assert not any("receipt-no-front" in d["id"] for d in body["data"])
def test_missing_conclusion_excluded_silently(self, client, reports_dir, token):
write_report(reports_dir, "r-no-conclusion", conclusion=None)
write_report(reports_dir, "r-empty-conclusion", conclusion=" ")
write_report(reports_dir, "r-ok")
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
assert r.status_code == 200, r.text
body = r.json()
assert [d["id"] for d in body["data"]] == ["wisdom:r-ok"]
assert body["excluded"]["missing_conclusion"] == 2
# 自诊断(diagnose=1)能看到被排除原因,但默认响应不含任何报错
d = client.get("/api/cma/docs?type=report&diagnose=1", headers=auth_header(token)).json()
reasons = {x["file"]: x["reason"] for x in d["excluded_detail"]}
assert reasons["r-no-conclusion.md"] == "missing_conclusion"
def test_excluded_report_detail_404_not_500(self, client, reports_dir, token):
(reports_dir / "receipt-no-front.md").write_text("无 front-matter\n", encoding="utf-8")
r = client.get("/api/cma/docs/report/wisdom:receipt-no-front", headers=auth_header(token))
assert r.status_code == 422 and "no_frontmatter" in r.text
def test_soft_required_fields_marked_incomplete(self, client, reports_dir, token):
write_report(reports_dir, "r-partial", title=None, date=None, domain=None, sources=None)
body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
rec = body["data"][0]
assert rec["incomplete"] == ["title", "date", "domain", "sources"]
assert rec["title"] == "r-partial" # 兜底:文件名
assert rec["domain"] == "未分类" # 兜底:未分类
assert rec["date"] # 兜底:文件 mtime 日
class TestFilters:
def test_domain_entity_kind_date(self, client, reports_dir, token):
write_report(reports_dir, "r-fin", date="2026-09-20", domain="finance", entity="通用", kind="analysis")
write_report(reports_dir, "r-tech", date="2026-09-24", domain="tech", entity="通用", kind="warning")
base = "/api/cma/docs?type=report"
def ids(q):
return [d["id"] for d in client.get(base + q, headers=auth_header(token)).json()["data"]]
assert ids("&domain=finance") == ["wisdom:r-fin"]
assert ids("&kind=warning") == ["wisdom:r-tech"]
assert ids("&date_from=2026-09-22") == ["wisdom:r-tech"]
assert ids("&date_to=2026-09-22") == ["wisdom:r-fin"]
assert ids("&domain=market") == []
class TestPermissionByEntity:
"""权限:按 entity + CMA 既有 roles 分区(客户只看自己实体)"""
def _seed(self, reports_dir):
write_report(reports_dir, "r-hanke", date="2026-09-24", entity="酣客")
write_report(reports_dir, "r-bohai", date="2026-09-24", entity="博海")
write_report(reports_dir, "r-common", date="2026-09-24", entity="通用")
write_report(reports_dir, "r-group", date="2026-09-24", entity="集团")
write_report(reports_dir, "r-nomark", date="2026-09-24", entity=None)
def test_entity1_finance_sees_only_own(self, client, db, reports_dir, two_entities):
self._seed(reports_dir)
tok = login_for(client, db, "fin1", 1)
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
ids = {d["id"] for d in body["data"]}
assert ids == {"wisdom:r-hanke", "wisdom:r-common", "wisdom:r-group", "wisdom:r-nomark"}
assert body["scope"]["cross_entity"] is False
assert body["scope"]["entity_id"] == 1
# 详情:跨账套报告不可见
r = client.get("/api/cma/docs/report/wisdom:r-bohai", headers=auth_header(tok))
assert r.status_code == 403 and "无权访问企业" in r.text
# 本账套报告可取
assert client.get("/api/cma/docs/report/wisdom:r-hanke",
headers=auth_header(tok)).status_code == 200
def test_entity2_finance_sees_own_only(self, client, db, reports_dir, two_entities):
self._seed(reports_dir)
tok = login_for(client, db, "fin2", 2)
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
ids = {d["id"] for d in body["data"]}
assert ids == {"wisdom:r-bohai", "wisdom:r-common", "wisdom:r-group", "wisdom:r-nomark"}
assert client.get("/api/cma/docs/report/wisdom:r-hanke",
headers=auth_header(tok)).status_code == 403
def test_ceo_cross_entity(self, client, db, reports_dir, two_entities):
self._seed(reports_dir)
tok = login_for(client, db, "ceo1", 1, role="ceo")
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
assert len(body["data"]) == 5 and body["scope"]["cross_entity"] is True
def test_it_cross_entity(self, client, db, reports_dir, two_entities):
self._seed(reports_dir)
tok = login_for(client, db, "it1", 1, role="it")
body = client.get("/api/cma/docs?type=report", headers=auth_header(tok)).json()
assert len(body["data"]) == 5 and body["scope"]["cross_entity"] is True
def test_no_token_401(self, client, reports_dir):
assert client.get("/api/cma/docs?type=report").status_code == 401
assert client.get("/api/cma/docs/report/wisdom:r-ok").status_code == 401
class TestReportDetail:
def test_detail_body_and_unknown_and_traversal(self, client, reports_dir, token):
write_report(reports_dir, "r-ok", body="## 小标题\n\n结论正文。\n")
d = client.get("/api/cma/docs/report/wisdom:r-ok", headers=auth_header(token))
assert d.status_code == 200
rec = d.json()
assert rec["id"] == "wisdom:r-ok" and rec["content"] == "## 小标题\n\n结论正文。\n"
assert client.get("/api/cma/docs/report/wisdom:nope",
headers=auth_header(token)).status_code == 404
assert client.get("/api/cma/docs/report/nosuchsource:r-ok",
headers=auth_header(token)).status_code == 404
from app.api import doc_center
from fastapi import HTTPException
for bad in ["r-ok", "wisdom:../escape", "wisdom:a/b", "wisdom:", "wisdom:..",
"wisdom:.hidden"]:
with pytest.raises(HTTPException) as ei:
doc_center.get_report(bad, request=None, db=None, current_user=None)
assert ei.value.status_code in (400, 404), bad
class TestRuntimeSource:
"""核心判据:运行期读盘 —— 改一篇报告的结构字段即生效,不改前端、不 rebuild"""
def test_edit_conclusion_visible_on_next_request(self, client, reports_dir, token):
p = write_report(reports_dir, "r-ok", conclusion="旧结论")
r1 = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
assert r1["data"][0]["conclusion"] == "旧结论"
p.write_text(p.read_text(encoding="utf-8").replace("旧结论", "新结论"), encoding="utf-8")
r2 = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
assert r2["data"][0]["conclusion"] == "新结论"
def test_new_report_visible_without_restart(self, client, reports_dir, token):
assert client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()["count"] == 0
write_report(reports_dir, "r-new")
body = client.get("/api/cma/docs?type=report", headers=auth_header(token)).json()
assert body["count"] == 1 and body["data"][0]["id"] == "wisdom:r-new"
class TestKnowledgeRegression:
"""回归:type 缺省 = 知识库分支(既有结构逐字不变)+ 报告来源不污染知识库列表"""
def test_knowledge_branch_intact(self, client, docs_dir, reports_dir, token):
d = docs_dir / "docs" / "term-a.md"
d.write_text('---\ntitle: "A术语"\ncategory: term\ncategory_name: 术语解释\n'
'icon: "📖"\nsummary: "摘要"\norder: 10\n---\n正文\n', encoding="utf-8")
write_report(reports_dir, "r-ok")
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert "categories" in body and "skipped" in body and body["format"] == "markdown"
assert [x["slug"] for x in body["data"]] == ["term-a"]
assert "type" not in body
# 报告落点的件不得混进知识库列表
assert all(x["slug"] != "r-ok" for x in body["data"])
def test_missing_reports_dir_not_500(self, client, tmp_path, monkeypatch, token):
monkeypatch.setenv(REPORTS_ENV, f"wisdom={tmp_path / 'not-exist'}")
r = client.get("/api/cma/docs?type=report", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert body["count"] == 0 and body["available"] is False and body["data"] == []