Files
DadaoTask/backend/tests/test_knowledge.py
T
Steven b29286778f
CI / backend (push) Canceled after 0s
fix(knowledge): 分类侧栏按树的顺序排,已发布的知识也能直接改
两个都是"看着不对劲"的问题:

1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用),
   「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。
   后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name),
   前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。

2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝
   修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改:
   现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新),
   状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。

Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。

验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、
ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、
`knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
2026-10-31 02:06:49 +08:00

384 lines
13 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.
"""M3 知识域测试:草稿、来源、审议发布、隔离。
重点不是"接口能通",而是这几条容易做错的:
1. **知识的来源不可丢**:从工单沉淀出来的知识必须指回那张工单(`source_kind='ticket'`),
否则半年后没人能核对这条结论从哪来;
2. **自动沉淀只落草稿**:验收通过后知识是 `draft`,不是 `published`;
3. **同一件事只沉淀一次**:重复点"沉淀成知识"要报 7002,而不是悄悄生成两份;
4. **发布要管理员**:普通成员的发布请求必须是 403(7004);
5. **跨租户不可见**:别人的知识列表为空、直接按 id 取是 7001。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
async def _member(
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
) -> dict[str, Any]:
user = await login(client)
inv = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/join", json={"code": inv["code"]}, headers=user["headers"]
)
)
return user
async def _task(client: AsyncClient, user: dict[str, Any], ws_id: str, title: str) -> dict:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items",
json={"title": title, "status": "todo"},
headers=user["headers"],
)
)
async def _finish_ticket(
client: AsyncClient, owner: dict[str, Any], ws_id: str, helper: dict[str, Any], task: dict
) -> dict:
"""把任务升级成工单并走完到验收通过,返回工单。"""
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items/{task['id']}/promote",
json={"assignee_id": helper["user_id"]},
headers=owner["headers"],
)
)["ticket"]
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/accept", headers=helper["headers"]
)
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/start", headers=helper["headers"]
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/submit",
json={"note": "已经开了只读账号"},
headers=helper["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items/{ticket['id']}/ticket/verify",
json={"approve": True, "resolution_md": "账号权限已开通,走的是 A 方案"},
headers=owner["headers"],
)
)
return ticket
@pytest.mark.asyncio
async def test_create_manual_knowledge_is_draft(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "知识手记")
data = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={
"title": "报销单要贴发票原件",
"body_md": "财务只认纸质发票,扫描件不行。",
"doc_type": "note",
"tag_names": ["财务"],
},
headers=owner["headers"],
)
)
assert data["kind"] == "knowledge"
assert data["status"] == "draft"
assert data["knowledge"]["source_kind"] == "manual"
assert data["knowledge"]["source_item_id"] is None
assert [t["name"] for t in data["tags"]] == ["财务"]
listed = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/knowledge", headers=owner["headers"])
)
assert [i["id"] for i in listed["items"]] == [data["id"]]
@pytest.mark.asyncio
async def test_ticket_approval_creates_draft_with_source(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "闭环沉淀")
helper = await _member(client, owner, ws)
task = await _task(client, owner, ws["id"], "开通测试账号权限")
ticket = await _finish_ticket(client, owner, ws["id"], helper, task)
listed = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge",
params={"status": "draft"},
headers=owner["headers"],
)
)
assert listed["has_more"] is False
docs = [i for i in listed["items"] if i["knowledge"]["source_item_id"] == ticket["id"]]
assert len(docs) == 1, "验收通过应当恰好沉淀一份草稿"
doc = docs[0]
assert doc["status"] == "draft", "自动沉淀只能是草稿,不能直接发布"
assert doc["knowledge"]["source_kind"] == "ticket"
assert doc["knowledge"]["generated_by"] == "rule"
# 草稿正文必须带结论和"请先核对"的提示,否则用户会把它当已确认结论转发
assert "账号权限已开通" in doc["body_md"]
assert "请先核对再发布" in doc["body_md"]
assert doc["source"]["kind"] == "ticket"
@pytest.mark.asyncio
async def test_promote_twice_conflicts(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "重复沉淀")
task = await _task(client, owner, ws["id"], "写周报")
first = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge",
headers=owner["headers"],
)
)
assert first["knowledge"]["source_kind"] == "task"
assert first["knowledge"]["generated_by"] == "human"
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge", headers=owner["headers"]
)
assert resp.json()["code"] == 7002
@pytest.mark.asyncio
async def test_publish_requires_admin(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "发布权限")
member = await _member(client, owner, ws)
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "值班手册", "body_md": "先重启再上报。"},
headers=member["headers"],
)
)
reviewed = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "submit_review", "version": doc["version"]},
headers=member["headers"],
)
)
assert reviewed["status"] == "in_review"
denied = await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": reviewed["version"]},
headers=member["headers"],
)
assert denied.json()["code"] == 7004
published = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": reviewed["version"]},
headers=owner["headers"],
)
)
assert published["status"] == "published"
assert published["knowledge"]["published_at"] is not None
# 已发布的能直接改正文:改完后端自动重建切片,状态仍是已发布
edited = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v2", "version": published["version"]},
headers=owner["headers"],
)
)
assert edited["title"] == "值班手册 v2"
assert edited["knowledge"]["status_hint"] == "published"
assert edited["version"] == published["version"] + 1
published = edited
archived = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "archive", "version": published["version"]},
headers=owner["headers"],
)
)
assert archived["status"] == "archived"
assert archived["knowledge"]["archived_at"] is not None
# 归档是先收起来;要改先恢复成草稿,免得改完还挂着"现役"的样子
blocked = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"title": "值班手册 v3", "version": archived["version"]},
headers=owner["headers"],
)
assert blocked.json()["code"] == 7003
@pytest.mark.asyncio
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "非法动作")
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "草稿"},
headers=owner["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "archive", "version": doc["version"]},
headers=owner["headers"],
)
)
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status",
json={"action": "publish", "version": doc["version"]},
headers=owner["headers"],
)
body = resp.json()
assert body["code"] == 7003
assert body["data"]["allowed_from"] == ["draft", "in_review"]
assert "restore" in body["data"]["allowed_actions"]
@pytest.mark.asyncio
async def test_knowledge_is_tenant_scoped(client: AsyncClient) -> None:
alice = await login(client)
ws_a = await create_workspace(client, alice, "A 的知识")
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws_a['id']}/knowledge",
json={"title": "A 的内部结论"},
headers=alice["headers"],
)
)
bob = await login(client)
ws_b = await create_workspace(client, bob, "B 的工作区")
listed = unwrap(
await client.get(f"{V1}/workspaces/{ws_b['id']}/knowledge", headers=bob["headers"])
)
assert listed["items"] == []
cross = await client.get(
f"{V1}/workspaces/{ws_b['id']}/knowledge/{doc['id']}", headers=bob["headers"]
)
assert cross.json()["code"] == 7001
not_member = await client.get(f"{V1}/workspaces/{ws_a['id']}/knowledge", headers=bob["headers"])
assert not_member.json()["code"] == 3003
@pytest.mark.asyncio
async def test_search_and_filters(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "检索")
for title, doc_type in (
("周报模板", "howto"),
("客户投诉复盘", "postmortem"),
("周报提醒", "note"),
):
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": title, "body_md": f"{title} 的正文", "doc_type": doc_type},
headers=owner["headers"],
)
)
hit = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge",
params={"q": "周报"},
headers=owner["headers"],
)
)
assert sorted(i["title"] for i in hit["items"]) == ["周报提醒", "周报模板"]
by_type = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge",
params={"doc_type": "postmortem"},
headers=owner["headers"],
)
)
assert [i["title"] for i in by_type["items"]] == ["客户投诉复盘"]
@pytest.mark.asyncio
async def test_patch_uses_optimistic_lock(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "并发改")
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "初稿", "version": 0},
headers=owner["headers"],
)
)
updated = unwrap(
await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"body_md": "补一段", "version": doc["version"]},
headers=owner["headers"],
)
)
assert updated["version"] == doc["version"] + 1
stale = await client.patch(
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
json={"body_md": "又一段", "version": doc["version"]},
headers=owner["headers"],
)
assert stale.json()["code"] == 4009
assert stale.json()["data"]["current_version"] == updated["version"]
@pytest.mark.asyncio
async def test_draft_timeline_reads_like_a_story(client: AsyncClient) -> None:
"""过程段要写「待办 → 已完成」,只写"状态变更"等于没写。
这条用例是有来历的:时间线最初只查了 action,没带 payload,
结果生成的草稿里全是"状态变更",看不出事情是怎么走的。
"""
owner = await login(client)
ws = await create_workspace(client, owner, "时间线")
task = await _task(client, owner, ws["id"], "写清楚每一步")
for status in ("todo", "doing", "done"):
task = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/status",
json={"status": status, "version": task["version"]},
headers=owner["headers"],
)
)
doc = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{task['id']}/knowledge", headers=owner["headers"]
)
)
body = doc["body_md"]
assert "待办 → 进行中" in body
assert "进行中 → 已完成" in body