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)
312 lines
15 KiB
Python
312 lines
15 KiB
Python
"""报告解读(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"] == []
|