Files
DadaoTask/backend/tests/test_knowledge.py
T
Steven 690779c5fb
CI / backend (push) Canceled after 0s
fix(wechat): 公众号新版页面抓不到正文,改从 cgiDataNew 里读
用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符
("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进
window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content";
三种 UA 拿到的字节数完全一样,也不是 UA 的问题。

- 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义,
  话题标签是双重转义过的 HTML,先 unescape 再走 DOM。
- 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按
  12 张上限补在末尾(图文型文章靠就近配对会漏)。
- 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。

实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。

同时补上用户提的两件:

- 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片,
  只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。
  删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。
- 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它",
  侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
2026-11-06 02:08:36 +08:00

541 lines
19 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
@pytest.mark.asyncio
async def test_list_pages_through_every_document(client: AsyncClient) -> None:
"""翻页要能翻到底,且不重不漏。
这条用例是有来历的:知识列表的游标解出来是 `Cursor(created_at, id)`,
查询里却写成了 `cur.item_id`——属性不存在,第二页必 500。
库小于一页(默认 30 条)时永远走不到这条路径,所以一直没暴露,
直到把旧备忘导进来 84 条,用户点「加载更多」就看到"服务器内部错误"。
"""
owner = await login(client)
ws = await create_workspace(client, owner, "翻页")
titles = [f"备忘 {i:02d}" for i in range(25)]
for title in titles:
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": title},
headers=owner["headers"],
)
)
seen: list[str] = []
cursor: str | None = None
for _ in range(10): # 25 条 / 每页 10 条,三轮就该到底,多留几次余地
params: dict[str, Any] = {"limit": 10}
if cursor:
params["cursor"] = cursor
page = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge", params=params, headers=owner["headers"]
)
)
seen.extend(i["title"] for i in page["items"])
cursor = page["next_cursor"]
if not page["has_more"] or not cursor:
break
assert cursor is None
assert sorted(seen) == titles
assert len(seen) == len(set(seen)), "翻页翻出了重复条目"
async def test_delete_removes_from_list_and_search(client: AsyncClient) -> None:
"""删掉的知识不能还留在列表和检索里——切片要一起删。"""
owner = await login(client)
ws = await create_workspace(client, owner, "删除知识")
note = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={
"title": "回滚前先停灰度",
"body_md": "灰度流量上涨后错误率飙升,第一步先停灰度。",
"tag_names": ["发布", "回滚"],
},
headers=owner["headers"],
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}/status",
json={"action": "publish", "version": note["version"]},
headers=owner["headers"],
)
)
# 不带 confirm 不给删,防手滑
resp = await client.delete(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}", headers=owner["headers"]
)
assert resp.json()["code"] == 1002
unwrap(
await client.delete(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}",
params={"confirm": "true"},
headers=owner["headers"],
)
)
page = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/knowledge", headers=owner["headers"])
)
assert page["items"] == []
hits = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge/search",
params={"q": "灰度"},
headers=owner["headers"],
)
)
assert hits["items"] == []
tags = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/knowledge/tags", headers=owner["headers"])
)
assert tags["items"] == [], "删掉的知识不该再给标签贡献条数"
# 删完再从收藏的链接点进来:要当它不存在,不能还渲染出一篇"删掉了的知识"
detail = await client.get(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}", headers=owner["headers"]
)
assert detail.json()["code"] == 7001
again = await client.delete(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}",
params={"confirm": "true"},
headers=owner["headers"],
)
assert again.json()["message"] == "已经删过了", "重复删不该报错,回一句就够"
async def test_only_author_or_admin_can_delete(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "谁能删")
member = await _member(client, owner, ws)
note = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": "别人的笔记", "body_md": "随手记的"},
headers=owner["headers"],
)
)
resp = await client.delete(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}",
params={"confirm": "true"},
headers=member["headers"],
)
assert resp.json()["code"] == 3004
# 作者自己能删
unwrap(
await client.delete(
f"{V1}/workspaces/{ws['id']}/knowledge/{note['id']}",
params={"confirm": "true"},
headers=owner["headers"],
)
)
async def test_tags_endpoint_counts_documents(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "标签统计")
for title, tags in (("甲", ["发布", "回滚"]), ("乙", ["发布"])):
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/knowledge",
json={"title": title, "tag_names": tags},
headers=owner["headers"],
)
)
got = unwrap(
await client.get(f"{V1}/workspaces/{ws['id']}/knowledge/tags", headers=owner["headers"])
)
counts = {row["name"]: row["count"] for row in got["items"]}
assert counts == {"发布": 2, "回滚": 1}