"""报告解读(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: → 指标详情页;文本 → 指标字典页关键词 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"] == []