CI / backend (push) Canceled after 0s
用户发来 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 按标签数"有多少条知识用了它", 侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
541 lines
19 KiB
Python
541 lines
19 KiB
Python
"""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}
|