Files
cma-management/backend/tests/test_doc_center_20260924.py
T
wecom-fullstackandwecom-yanxue f786ff9ae3
ci/woodpecker/push/main Pipeline failed
test(docs-center): 金丝雀分类断言改「必备分类在场+相对顺序」(分类扩展不再留假红)
- test_prod_content_readable 原硬编码 [term,formula,practice,faq],与生产 categories.json 全等比较;
  2026-10-12 14:05 内容目录新增 manual(模块手册,order 5) 后期望值失鲜 ⇒ 基线/改后两棵树同红。
- 改为子集式断言(必备分类在场 + 过滤后相对顺序),不再硬编码全集;分类扩展不再红。
- 同族断言盘点:本文件其余分类断言走 tmp content_dir 自持 categories.json,不依赖生产内容目录,无需改。
2026-10-13 07:48:18 +08:00

222 lines
11 KiB
Python
Raw 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.
"""文档中心(md 单一来源)端点测试 —— 任务 yanxue-20260924-cma-docs-center-poc
覆盖:
1. 列表:元数据/分类视图/排序/默认不带正文、`with_content=true` 带正文
2. 单篇:200 / 未知 slug 404 / 非法 slug 400(含路径穿越)
3. 鉴权:无 token → 401(不得成为 B 类缺口);持 token → 200
4. 健壮性:缺 front-matter / 缺必填字段 → skipped 且不影响其它文档;目录缺失 → available=false 不 500
5. **核心判据**:改 md 后同一进程再次请求即返回新内容(运行期读盘、无缓存、无 build-time 打包)
6. 生产内容目录金丝雀(目录不存在时跳过)
"""
import json
import os
import pytest
from fastapi.testclient import TestClient
from tests.conftest import create_test_user, get_token_for_user, auth_header
DOCS_ENV = "CMA_DOCS_DIR"
PROD_CONTENT = "/srv/hermes/shared/cma-content"
def write_doc(root, slug, body="#### 标题\n\n正文段落。\n", title="标题", category="term",
summary="摘要", category_name="术语解释", icon="📖", order=10, extra=""):
path = root / "docs" / f"{slug}.md"
path.write_text(
"---\n"
f'title: "{title}"\n'
f"category: {category}\n"
f"category_name: {category_name}\n"
f'icon: "{icon}"\n'
f'summary: "{summary}"\n'
f"order: {order}\n"
f"{extra}"
"---\n" + body,
encoding="utf-8",
)
return path
@pytest.fixture
def content_dir(tmp_path, monkeypatch):
"""隔离的临时内容目录(不动生产目录)"""
root = tmp_path / "cma-content"
(root / "docs").mkdir(parents=True)
(root / "categories.json").write_text(json.dumps({"categories": [
{"key": "faq", "name": "常见问题", "icon": "❓", "order": 40},
{"key": "term", "name": "术语解释", "icon": "📖", "order": 10},
]}, ensure_ascii=False), encoding="utf-8")
monkeypatch.setenv(DOCS_ENV, str(root))
return root
@pytest.fixture
def token(client: TestClient, db) -> str:
create_test_user(db)
return get_token_for_user(client)
class TestListDocs:
def test_meta_only_and_category_order(self, client, content_dir, token):
write_doc(content_dir, "term-b", title="B术语", order=20)
write_doc(content_dir, "term-a", title="A术语", order=10)
write_doc(content_dir, "faq-x", title="常见问题X", category="faq",
category_name="常见问题", icon="❓", order=10)
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["count"] == 3 and body["available"] is True and body["format"] == "markdown"
assert body["source"].endswith("/docs")
# 分类顺序 = categories.json 的 order(term 10 在 faq 40 之前,与文件写入顺序无关)
assert [c["key"] for c in body["categories"]] == ["term", "faq"]
assert [c["count"] for c in body["categories"]] == [2, 1]
assert body["categories"][0]["name"] == "术语解释" and body["categories"][0]["icon"] == "📖"
# 文档顺序 = (分类 order, 篇内 order, slug)
assert [d["slug"] for d in body["data"]] == ["term-a", "term-b", "faq-x"]
assert "content" not in body["data"][0], "默认不得带正文"
assert set(body["data"][0]) >= {"slug", "title", "summary", "category",
"category_name", "icon", "order", "bytes", "updated_at"}
assert body["skipped"] == []
def test_with_content(self, client, content_dir, token):
write_doc(content_dir, "term-a", body="#### H\n\n**加粗** 正文\n")
r = client.get("/api/cma/docs?with_content=true", headers=auth_header(token))
assert r.status_code == 200
assert r.json()["data"][0]["content"] == "#### H\n\n**加粗** 正文\n"
def test_filter_by_category(self, client, content_dir, token):
write_doc(content_dir, "term-a")
write_doc(content_dir, "faq-x", category="faq", category_name="常见问题")
r = client.get("/api/cma/docs?category=faq", headers=auth_header(token))
body = r.json()
assert [d["slug"] for d in body["data"]] == ["faq-x"]
# 分类视图按全量统计,不受过滤影响
assert [c["count"] for c in body["categories"]] == [1, 1]
def test_unregistered_category_still_listed(self, client, content_dir, token):
write_doc(content_dir, "other-a", category="其他类", category_name="其他类")
r = client.get("/api/cma/docs", headers=auth_header(token))
body = r.json()
assert body["count"] == 1
assert [c["key"] for c in body["categories"]] == ["term", "faq", "其他类"]
class TestGetDoc:
def test_detail(self, client, content_dir, token):
write_doc(content_dir, "term-a", body="#### H\n\n正文x\n")
r = client.get("/api/cma/docs/term-a", headers=auth_header(token))
assert r.status_code == 200
d = r.json()
assert d["slug"] == "term-a" and d["title"] == "标题" and d["content"] == "#### H\n\n正文x\n"
def test_unknown_slug_404(self, client, content_dir, token):
r = client.get("/api/cma/docs/no-such-doc", headers=auth_header(token))
assert r.status_code == 404
@pytest.mark.parametrize("slug", ["Upper", "with space", "a@b", "-lead", "x" * 65])
def test_invalid_slug_400(self, client, content_dir, token, slug):
r = client.get(f"/api/cma/docs/{slug}", headers=auth_header(token))
assert r.status_code in (400, 404), f"非法 slug 不得进入文件系统查找: {slug} → {r.status_code}"
if r.status_code == 400:
assert "无效的文档标识" in r.text
def test_path_traversal_blocked(self, content_dir):
"""越权取文件:`../`、绝对路径、URL 编码一律被 slug 白名单拦下(无 HTTP 层绕行)"""
from app.api import doc_center
from fastapi import HTTPException
for bad in ["../categories", "..%2F..%2Fetc%2Fpasswd", "/etc/passwd", "%2e%2e%2fsecret",
"....//x", "a/b"]:
with pytest.raises(HTTPException) as ei:
doc_center.get_doc(bad)
assert ei.value.status_code == 400
# 正对照:合法 slug 走查文件路径(不存在 → 404,不是 400)
with pytest.raises(HTTPException) as ei:
doc_center.get_doc("term-a")
assert ei.value.status_code == 404
class TestAuth:
def test_no_token_401(self, client, content_dir):
assert client.get("/api/cma/docs").status_code == 401
assert client.get("/api/cma/docs/term-a").status_code == 401
def test_with_token_200(self, client, content_dir, token):
write_doc(content_dir, "term-a")
assert client.get("/api/cma/docs", headers=auth_header(token)).status_code == 200
class TestRobustness:
def test_bad_md_skipped_not_fatal(self, client, content_dir, token):
(content_dir / "docs" / "no-front.md").write_text("没有 front-matter\n", encoding="utf-8")
write_doc(content_dir, "missing-title", title="", summary="s")
(content_dir / "docs" / "BadSlug.md").write_text(
'---\ntitle: "T"\ncategory: term\nsummary: "S"\n---\nX\n', encoding="utf-8")
write_doc(content_dir, "term-good")
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert [d["slug"] for d in body["data"]] == ["term-good"]
files = {s["file"]: s["reason"] for s in body["skipped"]}
assert "no-front.md" in files and "front-matter" in files["no-front.md"]
assert "missing-title.md" in files and "title" in files["missing-title.md"]
assert "BadSlug.md" in files and "slug" in files["BadSlug.md"]
def test_missing_dir_not_500(self, client, tmp_path, monkeypatch, token):
monkeypatch.setenv(DOCS_ENV, str(tmp_path / "not-exist"))
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert body["count"] == 0 and body["available"] is False and body["data"] == []
def test_front_matter_multi_colon_and_quotes(self, content_dir):
from app.api import doc_center
p = write_doc(content_dir, "term-q", title='带:冒号"引号"的标题', summary="摘要: 含冒号")
doc, err = doc_center._read_doc(p)
assert err is None
assert doc["title"] == '带:冒号"引号"的标题' and doc["summary"] == "摘要: 含冒号"
class TestRuntimeSource:
"""核心判据:内容运行期读盘 —— 改 md 即生效,无需改前端 / rebuild / 重启"""
def test_edit_md_visible_on_next_request(self, client, content_dir, token):
p = write_doc(content_dir, "term-a", body="#### 旧内容\n")
r1 = client.get("/api/cma/docs/term-a", headers=auth_header(token)).json()
assert r1["content"] == "#### 旧内容\n"
# 只改 md 文件(不改码、不重启、不构建)
p.write_text(p.read_text(encoding="utf-8").replace("旧内容", "新内容"), encoding="utf-8")
r2 = client.get("/api/cma/docs/term-a", headers=auth_header(token)).json()
assert r2["content"] == "#### 新内容\n"
def test_add_md_visible_without_restart(self, client, content_dir, token):
assert client.get("/api/cma/docs", headers=auth_header(token)).json()["count"] == 0
write_doc(content_dir, "term-new", title="新增篇目")
body = client.get("/api/cma/docs?with_content=true", headers=auth_header(token)).json()
assert body["count"] == 1 and body["data"][0]["title"] == "新增篇目"
@pytest.mark.skipif(not os.path.isdir(PROD_CONTENT), reason="生产内容目录不存在(非本机环境)")
class TestProdContentCanary:
"""生产内容目录金丝雀:真实目录必须可读、无 skipped、分类齐备(防内容被写坏)"""
def test_prod_content_readable(self, client, token, monkeypatch):
monkeypatch.setenv(DOCS_ENV, PROD_CONTENT)
r = client.get("/api/cma/docs", headers=auth_header(token))
assert r.status_code == 200
body = r.json()
assert body["available"] is True and body["skipped"] == [], body["skipped"]
assert body["count"] >= 20, f"迁移应有 20 篇,实际 {body['count']}"
# 口径(2026-10-12 失鲜收口):分类集合随内容扩展(当日新增 manual/模块手册,order 5),
# 故只断言「必备分类在场 + 相对顺序正确」,不再硬编码全集,避免下次扩类再留假红。
keys = [c["key"] for c in body["categories"]]
required = ["term", "formula", "practice", "faq"]
assert set(required) <= set(keys), \
f"生产内容缺必备分类:{sorted(set(required) - set(keys))};实际 {keys}"
assert [k for k in keys if k in required] == required, f"必备分类相对顺序异常:{keys}"
for slug in ("term-kpi", "formula-achievement-rate", "practice-threshold-tips", "faq-data-sync"):
d = client.get(f"/api/cma/docs/{slug}", headers=auth_header(token)).json()
assert d["title"] and d["content"].strip()